import Foundation import UIKit import TelegramCore import Display import TelegramPresentationData import TelegramUIPreferences import TextFormat import TelegramStringFormatting // MARK: - Public layout data types public struct InstantPageV2Layout { public let contentSize: CGSize public let items: [InstantPageV2LaidOutItem] /// Snapshot of the `index` values of every `.details` item present in `items`, captured at layout time. public let detailsIndices: [Int] /// Media dictionary inherited from the page's `LayoutContext.media`. Used by /// `InstantPageV2View.updateInlineImages()` to resolve each text view's /// `line.imageItems` MediaIds at update time. Nested layouts (details body, /// table cells, table title) carry the parent's same map. public let media: [EngineMedia.Id: EngineMedia] /// Webpage carried for the same reason — `updateInlineImages()` needs it to /// form the `WebpageReference` for `ImageMediaReference.webPage(...)`. May /// be nil for non-webpage-anchored layouts; in that case the lookup proceeds /// but no fetch signal can be bound (image view simply isn't created). public let webpage: TelegramMediaWebpage? public init(contentSize: CGSize, items: [InstantPageV2LaidOutItem], detailsIndices: [Int], media: [EngineMedia.Id: EngineMedia] = [:], webpage: TelegramMediaWebpage? = nil) { self.contentSize = contentSize self.items = items self.detailsIndices = detailsIndices self.media = media self.webpage = webpage } /// Returns every `InstantPageMedia` produced by this layout (or its nested sub-layouts) /// in laid-out order. Used by the gallery helper to enumerate sibling medias and find /// central index, mirroring V1's `mediasFromItems(_:)`. public func allMedias() -> [InstantPageMedia] { var result: [InstantPageMedia] = [] InstantPageV2Layout.collectMedias(in: self.items, into: &result) return result } private static func collectMedias(in items: [InstantPageV2LaidOutItem], into result: inout [InstantPageMedia]) { for item in items { switch item { case let .mediaImage(m): result.append(m.media) case let .mediaVideo(m): result.append(m.media) case let .mediaMap(m): result.append(m.media) case let .mediaCoverImage(m): result.append(m.media) case let .details(d): if let inner = d.innerLayout { collectMedias(in: inner.items, into: &result) } case let .table(t): if let title = t.titleSubLayout { collectMedias(in: title.items, into: &result) } for cell in t.cells { if let sub = cell.subLayout { collectMedias(in: sub.items, into: &result) } } default: continue } } } } public enum InstantPageV2LaidOutItem { case text(InstantPageV2TextItem) case codeBlock(InstantPageV2CodeBlockItem) case divider(InstantPageV2DividerItem) case listMarker(InstantPageV2ListMarkerItem) case blockQuoteBar(InstantPageV2BarItem) case shape(InstantPageV2ShapeItem) case mediaPlaceholder(InstantPageV2MediaPlaceholderItem) case details(InstantPageV2DetailsItem) case table(InstantPageV2TableItem) case anchor(InstantPageV2AnchorItem) case mediaImage(InstantPageV2MediaImageItem) case mediaVideo(InstantPageV2MediaVideoItem) case mediaMap(InstantPageV2MediaMapItem) case mediaCoverImage(InstantPageV2MediaCoverImageItem) case formula(InstantPageV2FormulaItem) case thinking(InstantPageV2ThinkingItem) public var frame: CGRect { switch self { case let .text(item): return item.frame case let .codeBlock(item): return item.frame case let .divider(item): return item.frame case let .listMarker(item): return item.frame case let .blockQuoteBar(item): return item.frame case let .shape(item): return item.frame case let .mediaPlaceholder(item): return item.frame case let .details(item): return item.frame case let .table(item): return item.frame case let .anchor(item): return item.frame case let .mediaImage(item): return item.frame case let .mediaVideo(item): return item.frame case let .mediaMap(item): return item.frame case let .mediaCoverImage(item): return item.frame case let .formula(item): return item.frame case let .thinking(item): return item.frame } } /// Returns a copy of `self` with its top-level frame translated by `delta`. /// Sub-layouts inside details/table cells are not re-translated — they're already /// expressed in their parent's local coordinates. public func offsetBy(_ delta: CGPoint) -> InstantPageV2LaidOutItem { switch self { case var .text(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .text(item) case var .codeBlock(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .codeBlock(item) case var .divider(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .divider(item) case var .listMarker(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .listMarker(item) case var .blockQuoteBar(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .blockQuoteBar(item) case var .shape(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .shape(item) case var .mediaPlaceholder(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaPlaceholder(item) case var .details(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .details(item) case var .table(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .table(item) case var .anchor(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .anchor(item) case var .mediaImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaImage(item) case var .mediaVideo(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaVideo(item) case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item) case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item) case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item) case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item) } } } public struct InstantPageV2TextItem { public var frame: CGRect public let textItem: InstantPageTextItem // V1 type reused as payload } public struct InstantPageV2CodeBlockItem { public var frame: CGRect public let backgroundColor: UIColor public let cornerRadius: CGFloat public let textItem: InstantPageTextItem public let inset: UIEdgeInsets } public struct InstantPageV2ThinkingItem { public var frame: CGRect /// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never /// char-reveal-masked); the shimmer + whole-block fade are the only animations. public let textItem: InstantPageTextItem } public struct InstantPageV2DividerItem { public var frame: CGRect public let color: UIColor } public struct InstantPageV2CheckboxColors { public let background: UIColor public let stroke: UIColor public let border: UIColor public init(background: UIColor, stroke: UIColor, border: UIColor) { self.background = background self.stroke = stroke self.border = border } } public enum InstantPageV2ListMarkerKind { case bullet case number(String) case checklist(checked: Bool, colors: InstantPageV2CheckboxColors) } public struct InstantPageV2ListMarkerItem { public var frame: CGRect public let kind: InstantPageV2ListMarkerKind public let color: UIColor } public struct InstantPageV2BarItem { public var frame: CGRect public let color: UIColor public let cornerRadius: CGFloat } public enum InstantPageV2ShapeKind { case roundedRect(cornerRadius: CGFloat) case line(thickness: CGFloat) } public struct InstantPageV2ShapeItem { public var frame: CGRect public let kind: InstantPageV2ShapeKind public let color: UIColor } public struct InstantPageV2FormulaItem { public var frame: CGRect // outer frame in parent coords public let attachment: InstantPageMathAttachment // rendered image + dimensions (theme-baked) public let isScrollable: Bool // true only for block formulas wider than bounds public let imageFrame: CGRect // image rect in this item's local coords; size must equal attachment.rendered.size for pixel-perfect rendering public let scrollContentSize: CGSize // == frame.size unless isScrollable } public enum InstantPageV2MediaPlaceholderKind { case image case video case audio case webEmbed case postEmbed case collage case slideshow case channelBanner case map case relatedArticles } public struct InstantPageV2MediaImageItem { public var frame: CGRect public let cornerRadius: CGFloat public let media: InstantPageMedia public let webPage: TelegramMediaWebpage public let attributes: [InstantPageImageAttribute] // always empty for image; kept for symmetry public init(frame: CGRect, cornerRadius: CGFloat, media: InstantPageMedia, webPage: TelegramMediaWebpage, attributes: [InstantPageImageAttribute]) { self.frame = frame self.cornerRadius = cornerRadius self.media = media self.webPage = webPage self.attributes = attributes } } public struct InstantPageV2MediaVideoItem { public var frame: CGRect public let cornerRadius: CGFloat public let media: InstantPageMedia public let webPage: TelegramMediaWebpage public let attributes: [InstantPageImageAttribute] // always empty public init(frame: CGRect, cornerRadius: CGFloat, media: InstantPageMedia, webPage: TelegramMediaWebpage, attributes: [InstantPageImageAttribute]) { self.frame = frame self.cornerRadius = cornerRadius self.media = media self.webPage = webPage self.attributes = attributes } } public struct InstantPageV2MediaMapItem { public var frame: CGRect public let cornerRadius: CGFloat public let media: InstantPageMedia public let webPage: TelegramMediaWebpage public let attributes: [InstantPageImageAttribute] // [InstantPageMapAttribute] with zoom + dimensions public init(frame: CGRect, cornerRadius: CGFloat, media: InstantPageMedia, webPage: TelegramMediaWebpage, attributes: [InstantPageImageAttribute]) { self.frame = frame self.cornerRadius = cornerRadius self.media = media self.webPage = webPage self.attributes = attributes } } public struct InstantPageV2MediaCoverImageItem { public var frame: CGRect public let cornerRadius: CGFloat public let media: InstantPageMedia // media.media == .webpage(synthesized fake webpage with cover image) public let webPage: TelegramMediaWebpage // the parent IV's webpage (for WebpageReference) public let attributes: [InstantPageImageAttribute] // always empty public init(frame: CGRect, cornerRadius: CGFloat, media: InstantPageMedia, webPage: TelegramMediaWebpage, attributes: [InstantPageImageAttribute]) { self.frame = frame self.cornerRadius = cornerRadius self.media = media self.webPage = webPage self.attributes = attributes } } public struct InstantPageV2MediaPlaceholderItem { public var frame: CGRect public let kind: InstantPageV2MediaPlaceholderKind public let cornerRadius: CGFloat } public struct InstantPageV2DetailsItem { public var frame: CGRect public let index: Int public let sideInset: CGFloat public let titleTextItem: InstantPageTextItem public let titleFrame: CGRect // local to this item's frame public let separatorColor: UIColor public let isExpanded: Bool public let innerLayout: InstantPageV2Layout? public let defaultExpanded: Bool // from the InstantPageBlock model } public enum InstantPageV2TableVerticalAlignment { case top, middle, bottom } public struct InstantPageV2TableCell { public let frame: CGRect // local to the table's content area public let isHeader: Bool public let horizontalAlignment: NSTextAlignment public let verticalAlignment: InstantPageV2TableVerticalAlignment public let backgroundColor: UIColor? public let subLayout: InstantPageV2Layout? } public struct InstantPageV2TableItem { public var frame: CGRect public let titleSubLayout: InstantPageV2Layout? public let titleFrame: CGRect? public let contentSize: CGSize // grid intrinsic size; may exceed frame.width → scroll public let contentInset: CGFloat // page horizontalInset; the renderer shifts the grid right by it and pads the scroll content by it on BOTH sides public let cells: [InstantPageV2TableCell] public let horizontalLines: [CGRect] public let verticalLines: [CGRect] public let bordered: Bool public let striped: Bool public let borderColor: UIColor } public struct InstantPageV2AnchorItem { public var frame: CGRect // zero-height public let name: String } // MARK: - Public entry points public func layoutInstantPageV2( webpage: TelegramMediaWebpage, instantPage: InstantPage, userLocation: MediaResourceUserLocation, boundingWidth: CGFloat, horizontalInset: CGFloat, theme: InstantPageTheme, strings: PresentationStrings, dateTimeFormat: PresentationDateTimeFormat, cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?, expandedDetails: [Int: Bool], fitToWidth: Bool, computeRevealCharacterRects: Bool = false ) -> InstantPageV2Layout { guard case let .Loaded(loadedContent) = webpage.content else { return InstantPageV2Layout(contentSize: .zero, items: [], detailsIndices: []) } var media = instantPage.media.mapValues(EngineMedia.init) if let image = loadedContent.image, let id = image.id { media[id] = .image(image) } if let video = loadedContent.file, let id = video.id { media[id] = .file(video) } var context = LayoutContext( theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, userLocation: userLocation, webpage: webpage, media: media, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, rtl: instantPage.rtl, fitToWidth: fitToWidth, computeRevealCharacterRects: computeRevealCharacterRects, mediaIndexCounter: 0, detailsIndexCounter: 0, expandedDetails: expandedDetails ) return layoutBlockSequence( instantPage.blocks, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: .topLevel, context: &context ) } /// Used by `ChatMessageRichDataBubbleContentNode` to anchor the date/checks status node at the /// bottom-right of the last text line in the bubble. public func lastTextLineFrame(in layout: InstantPageV2Layout) -> CGRect? { // Walk items in reverse; descend into the LAST sub-layout when the last container has one. for item in layout.items.reversed() { switch item { case let .text(text): if let last = text.textItem.lines.last { return last.frame.offsetBy(dx: text.frame.minX, dy: text.frame.minY) } case let .codeBlock(block): if let last = block.textItem.lines.last { return last.frame .offsetBy(dx: block.textItem.frame.minX, dy: block.textItem.frame.minY) .offsetBy(dx: block.frame.minX, dy: block.frame.minY) } case let .details(details): if let inner = details.innerLayout, let innerFrame = lastTextLineFrame(in: inner) { return innerFrame.offsetBy(dx: details.frame.minX, dy: details.frame.minY + details.titleFrame.maxY) } // Title fallback if let last = details.titleTextItem.lines.last { return last.frame .offsetBy(dx: details.titleTextItem.frame.minX, dy: details.titleTextItem.frame.minY) .offsetBy(dx: details.frame.minX, dy: details.frame.minY) } case let .table(table): // Walk cells in reverse row-major order (last cell of last row first). // The renderer shifts cells down by gridOffsetY (title height) — match that here. let gridOffsetY = table.titleFrame?.height ?? 0.0 for cell in table.cells.reversed() { if let subLayout = cell.subLayout, let frame = lastTextLineFrame(in: subLayout) { return frame .offsetBy(dx: cell.frame.minX, dy: cell.frame.minY + gridOffsetY) .offsetBy(dx: table.frame.minX, dy: table.frame.minY) } } default: continue } } return nil } /// Variant of `lastTextLineFrame(in:)` that returns the last text line frame only when the /// bottom-most top-level item in the layout is itself a text item. When the layout ends in a /// non-text item (table, image, divider, list marker, details, …) it returns nil, so callers /// place the trailing status below all content rather than beside text buried above the final /// item. /// /// Also returns `trailingBottomPadding`: the renderer draws the baseline at the line frame's maxY, /// so the visible text of a plain line sits ~5pt below it. A status that *trails on the line* should /// anchor at `maxY + trailingBottomPadding` to align with where the text actually renders. The pad /// is 0 when the line is taller than its font line height (an inline animated emoji, ~pointSize·24/17, /// already pushes maxY down to the right spot). Callers should NOT apply the pad when the status /// wraps onto its own line below the text — there it should sit at the bare maxY. public func lastTextLineFrameIfLastItemIsText(in layout: InstantPageV2Layout) -> (frame: CGRect, trailingBottomPadding: CGFloat)? { guard let bottomItem = layout.items.max(by: { $0.frame.maxY < $1.frame.maxY }), case let .text(text) = bottomItem, let last = text.textItem.lines.last else { return nil } // The stored line frame always has minX = 0 — alignment (center / right / natural-RTL) is // applied at render time by `v2FrameForLine`. Apply the same correction here so the returned // frame's `maxX` reflects the line's actual on-screen right edge, not just its width anchored // at the textItem's left. Without this, a right-aligned or RTL last line — whose visible right // edge sits at `textItem.width`, all the way at the right text inset — would feed the status // node a `contentWidth` equal to just `lineWidth`. The trail/wrap decision would then think // the date fits trailing the line, and place it directly on top of the line at the right text // inset where the line itself ends. The width is unchanged; only `origin.x` shifts. let displayedLineFrame = v2FrameForLine(last, boundingWidth: text.textItem.frame.width, alignment: text.textItem.alignment) let lineFrame = displayedLineFrame.offsetBy(dx: text.frame.minX, dy: text.frame.minY) var ascent: CGFloat = 0.0 var descent: CGFloat = 0.0 var leading: CGFloat = 0.0 _ = CTLineGetTypographicBounds(last.line, &ascent, &descent, &leading) let isInflatedByAttachment = lineFrame.height > ascent + descent + 1.0 return (lineFrame, isInflatedByAttachment ? 0.0 : 5.0) } // MARK: - Layout context private struct LayoutContext { let theme: InstantPageTheme let strings: PresentationStrings let dateTimeFormat: PresentationDateTimeFormat let userLocation: MediaResourceUserLocation let webpage: TelegramMediaWebpage let media: [EngineMedia.Id: EngineMedia] let cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight? let rtl: Bool let fitToWidth: Bool let computeRevealCharacterRects: Bool var mediaIndexCounter: Int = 0 var detailsIndexCounter: Int = 0 let expandedDetails: [Int: Bool] } // MARK: - Driver private func layoutBlockSequence( _ blocks: [InstantPageBlock], boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, context: inout LayoutContext ) -> InstantPageV2Layout { var items: [InstantPageV2LaidOutItem] = [] var detailsIndices: [Int] = [] var contentHeight: CGFloat = 0.0 var previousBlock: InstantPageBlock? for (i, block) in blocks.enumerated() { let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth, kind: kind) let localItems = layoutBlock( block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, isCover: false, previousItems: items, isLast: i == blocks.count - 1, context: &context ) // Translate local items by (0, contentHeight + spacing) and append. let dy = contentHeight + spacing var blockMaxY: CGFloat = 0.0 for var item in localItems { item = item.offsetBy(CGPoint(x: 0.0, y: dy)) if case let .details(d) = item { detailsIndices.append(d.index) } let f = item.frame if f.maxY > blockMaxY { blockMaxY = f.maxY } items.append(item) } if blockMaxY > contentHeight { contentHeight = blockMaxY previousBlock = block } } let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth, kind: kind) contentHeight += closingSpacing var contentSize = CGSize(width: boundingWidth, height: contentHeight) if context.fitToWidth { // Match V1 InstantPageLayout.swift:1114 — include `+ horizontalInset` so the contentSize // reserves a right margin equal to the left inset. Without this, the longest text item's // right edge equals contentSize.width, and the bubble's containerNode (sized to // boundingSize.width - 2) clips the last 2pt of text. var maxX: CGFloat = 0.0 for item in items { maxX = max(maxX, ceil(item.frame.maxX) + horizontalInset) } contentSize.width = min(maxX, boundingWidth) } return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage) } // MARK: - Markdown block context stamping helpers private func stampMarkdownContext(_ items: [InstantPageV2LaidOutItem], kind: InstantPageMarkdownBlockContext.Kind) { for item in items { switch item { case let .text(textItem): if textItem.textItem.markdownContext == nil { textItem.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind) } case let .codeBlock(block): if block.textItem.markdownContext == nil { block.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind) } default: break } } } /// Marks every text item produced by a blockquote's children as quoted (depth + 1), /// preserving each child's own kind (heading/list/paragraph/…). private func bumpQuoteDepth(_ items: [InstantPageV2LaidOutItem]) { for item in items { let target: InstantPageTextItem? switch item { case let .text(textItem): target = textItem.textItem case let .codeBlock(block): target = block.textItem default: target = nil } guard let target else { continue } if var ctx = target.markdownContext { ctx.quoteDepth += 1 target.markdownContext = ctx } else { target.markdownContext = InstantPageMarkdownBlockContext(kind: .paragraph, quoteDepth: 1) } } } private func layoutBlock( _ block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, isCover: Bool, previousItems: [InstantPageV2LaidOutItem], isLast: Bool, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let _ = isLast // reserved for Tasks 7–9 switch block { case let .cover(inner): return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, isCover: true, previousItems: previousItems, isLast: isLast, context: &context) case let .title(text): let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) stampMarkdownContext(titleItems, kind: .title) return titleItems case let .subtitle(text): return layoutSimpleText(text, category: .subheader, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .kicker(text): return layoutSimpleText(text, category: .kicker, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .header(text): return layoutSimpleText(text, category: .header, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .subheader(text): return layoutSimpleText(text, category: .subheader, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .heading(text, level): return layoutHeading(text, level: level, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .footer(text): return layoutSimpleText(text, category: .caption, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .paragraph(text): return layoutParagraph(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, previousItems: previousItems, context: &context) case let .authorDate(author, date): return layoutAuthorDate(author: author, date: date, boundingWidth: boundingWidth, horizontalInset: horizontalInset, previousItems: previousItems, context: &context) case .divider: return layoutDivider(boundingWidth: boundingWidth, context: context) case let .anchor(name): return [.anchor(InstantPageV2AnchorItem(frame: CGRect(x: 0.0, y: 0.0, width: 0.0, height: 0.0), name: name))] case let .list(items, ordered): return layoutList(items, ordered: ordered, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, context: &context) case let .preformatted(text, language): return layoutCodeBlock(text, language: language, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .blockQuote(blocks, caption): return layoutBlockQuote(blocks: blocks, caption: caption, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, isLast: isLast, context: &context) case let .pullQuote(text, caption): return layoutQuoteText(text: text, caption: caption, isPull: true, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .image(id, caption, url, webpageId): if case let .image(image) = context.media[id], let largest = largestImageRepresentation(image.representations) { let naturalSize = CGSize(width: CGFloat(largest.dimensions.width), height: CGFloat(largest.dimensions.height)) let mediaUrl: InstantPageUrlItem? = url.flatMap { InstantPageUrlItem(url: $0, webpageId: webpageId) } let mediaIndex = context.mediaIndexCounter context.mediaIndexCounter += 1 let instantPageMedia = InstantPageMedia( index: mediaIndex, media: .image(image), url: mediaUrl, caption: caption.text, credit: caption.credit ) let webpage = context.webpage return layoutTypedMediaWithCaption( produceItem: { frame, cornerRadius in .mediaImage(InstantPageV2MediaImageItem( frame: frame, cornerRadius: cornerRadius, media: instantPageMedia, webPage: webpage, attributes: [] )) }, naturalSize: naturalSize, caption: caption, isCover: isCover, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) } else { // Fallback when the image is not present in the page's media dict — preserve V1 // behavior, which returns an empty layout for unknown image IDs (V1 // InstantPageLayout.swift:623). The existing layoutMediaWithCaption would emit a // grey rectangle; matching V1 instead. return [] } case let .video(id, caption, _, _): if case let .file(file) = context.media[id], let dimensions = file.dimensions { let naturalSize = CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height)) let mediaIndex = context.mediaIndexCounter context.mediaIndexCounter += 1 let instantPageMedia = InstantPageMedia( index: mediaIndex, media: .file(file), url: nil, caption: caption.text, credit: caption.credit ) let webpage = context.webpage return layoutTypedMediaWithCaption( produceItem: { frame, cornerRadius in .mediaVideo(InstantPageV2MediaVideoItem( frame: frame, cornerRadius: cornerRadius, media: instantPageMedia, webPage: webpage, attributes: [] )) }, naturalSize: naturalSize, caption: caption, isCover: isCover, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) } else { return [] } case let .audio(_, caption): return layoutMediaWithCaption(kind: .audio, naturalSize: CGSize(width: boundingWidth, height: 56.0), caption: caption, isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .webEmbed(url, _, dimensions, caption, _, _, coverId): // V1 (InstantPageLayout.swift:848): if the embed has a URL and a resolvable cover image, // V1 synthesizes a fake webpage holding the cover image and renders it as an image cell // with a play overlay. Otherwise V1 produces a WKWebView embed (out of scope for V2). if let url = url, let coverId = coverId, case let .image(coverImage) = context.media[coverId] { let embedHeight: CGFloat = CGFloat(dimensions?.height ?? 240) let naturalSize = CGSize(width: boundingWidth, height: embedHeight) let size = PixelDimensions(width: Int32(naturalSize.width), height: Int32(naturalSize.height)) let loadedContent = TelegramMediaWebpageLoadedContent( url: url, displayUrl: url, hash: 0, type: "video", websiteName: nil, title: nil, text: nil, embedUrl: url, embedType: "video", embedSize: size, duration: nil, author: nil, isMediaLargeByDefault: nil, imageIsVideoCover: false, image: coverImage, file: nil, story: nil, attributes: [], instantPage: nil ) let coverWebpage = TelegramMediaWebpage( webpageId: EngineMedia.Id(namespace: Namespaces.Media.LocalWebpage, id: -1), content: .Loaded(loadedContent) ) let mediaIndex = context.mediaIndexCounter context.mediaIndexCounter += 1 let instantPageMedia = InstantPageMedia( index: mediaIndex, media: .webpage(coverWebpage), url: nil, caption: caption.text, credit: caption.credit ) let webpage = context.webpage return layoutTypedMediaWithCaption( produceItem: { frame, cornerRadius in .mediaCoverImage(InstantPageV2MediaCoverImageItem( frame: frame, cornerRadius: cornerRadius, media: instantPageMedia, webPage: webpage, attributes: [] )) }, naturalSize: naturalSize, caption: caption, isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) } else { // No cover image → keep the existing grey-placeholder path (plain web embed). let h: CGFloat = CGFloat(dimensions?.height ?? 240) return layoutMediaWithCaption(kind: .webEmbed, naturalSize: CGSize(width: boundingWidth, height: h), caption: caption, isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) } case let .postEmbed(_, _, _, _, _, _, caption): return layoutMediaWithCaption(kind: .postEmbed, naturalSize: CGSize(width: boundingWidth, height: 140.0), caption: caption, isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .collage(_, caption): return layoutMediaWithCaption(kind: .collage, naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .slideshow(_, caption): return layoutMediaWithCaption(kind: .slideshow, naturalSize: CGSize(width: boundingWidth, height: 240.0), caption: caption, isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .channelBanner(channel): if channel == nil { return [] } return layoutMediaWithCaption(kind: .channelBanner, naturalSize: CGSize(width: boundingWidth, height: 60.0), caption: InstantPageCaption(text: .empty, credit: .empty), isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .map(latitude, longitude, zoom, dimensions, caption): let naturalSize = CGSize(width: CGFloat(dimensions.width), height: CGFloat(dimensions.height)) let map = TelegramMediaMap( latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, venue: nil, liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil ) let mapAttributes: [InstantPageImageAttribute] = [InstantPageMapAttribute(zoom: zoom, dimensions: dimensions.cgSize)] let mediaIndex = context.mediaIndexCounter context.mediaIndexCounter += 1 let instantPageMedia = InstantPageMedia( index: mediaIndex, media: .geo(map), url: nil, caption: caption.text, credit: caption.credit ) let webpage = context.webpage return layoutTypedMediaWithCaption( produceItem: { frame, cornerRadius in .mediaMap(InstantPageV2MediaMapItem( frame: frame, cornerRadius: cornerRadius, media: instantPageMedia, webPage: webpage, attributes: mapAttributes )) }, naturalSize: naturalSize, caption: caption, isCover: false, cornerRadius: 8.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) case let .relatedArticles(_, articles): let h = min(CGFloat(articles.count) * 80.0, 320.0) return layoutMediaWithCaption(kind: .relatedArticles, naturalSize: CGSize(width: boundingWidth, height: max(h, 80.0)), caption: InstantPageCaption(text: .empty, credit: .empty), isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .formula(latex): return layoutFormulaBlock(latex: latex, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, context: &context) case let .details(title, blocks, expanded): return layoutDetails(title: title, blocks: blocks, defaultExpanded: expanded, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .table(title, rows, bordered, striped): return layoutTable(title: title, rows: rows, bordered: bordered, striped: striped, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case let .thinking(text): return layoutThinking(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) case .unsupported: return [] } } // MARK: - Formula block layout /// Lays out a top-level `InstantPageBlock.formula(latex:)`. The latex is rendered synchronously /// through `instantPageMathAttachment(...)` (SwiftMath → `MTMathRenderer`) using the current /// paragraph theme's color and font size; the resulting pre-rendered `UIImage` is wrapped in /// an `InstantPageV2FormulaItem`. Wide formulas set `isScrollable = true`; on render failure /// the raw latex source is laid out as a regular paragraph (matches V1 fallback). private func layoutFormulaBlock( latex: String, boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Style stack matches V1's per-block formula (paragraph category, not header). let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) let attributes = styleStack.textAttributes() let textColor = (attributes[.foregroundColor] as? UIColor) ?? context.theme.textCategories.paragraph.color let fontSize = (attributes[.font] as? UIFont)?.pointSize ?? context.theme.textCategories.paragraph.font.size guard let attachment = instantPageMathAttachment(latex: latex, fontSize: fontSize, textColor: textColor, mode: .block) else { // Render failure: fall back to the raw latex source as a regular paragraph. return layoutParagraph(.plain(latex), boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind, previousItems: [], context: &context) } let availableWidth = boundingWidth - horizontalInset * 2.0 let renderedSize = attachment.rendered.size if renderedSize.width > availableWidth { // Wide formula: scroll view fills the bubble's available width so the image can pan inside. let frame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: renderedSize.height) let item = InstantPageV2FormulaItem( frame: frame, attachment: attachment, isScrollable: true, imageFrame: CGRect(x: horizontalInset, y: 0.0, width: renderedSize.width, height: renderedSize.height), scrollContentSize: CGSize(width: renderedSize.width + horizontalInset * 2.0, height: renderedSize.height) ) return [.formula(item)] } else { // Narrow formula: report the image's natural extent (left-inset + width) so the // bubble's `fitToWidth` contentSize shrinks instead of stretching to `boundingWidth`. // When the formula is the widest item, the bubble centers itself in the chat row. let frame = CGRect(x: horizontalInset, y: 0.0, width: renderedSize.width, height: renderedSize.height) let item = InstantPageV2FormulaItem( frame: frame, attachment: attachment, isScrollable: false, imageFrame: CGRect(origin: .zero, size: renderedSize), scrollContentSize: renderedSize ) return [.formula(item)] } } // MARK: - Details layout private func layoutDetails( title: RichText, blocks: [InstantPageBlock], defaultExpanded: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let index = context.detailsIndexCounter context.detailsIndexCounter += 1 // Title text item at top. // V1 (InstantPageDetailsItem.swift:98–101): boundingWidth - detailsInset*2 - titleInset, titleHeight = max(44, titleSize.height + 26). let titleStyleStack = InstantPageTextStyleStack() setupStyleStack(titleStyleStack, theme: context.theme, category: .paragraph, link: false) let (titleTextItem, _, _) = layoutTextItem( attributedStringForRichText(title, styleStack: titleStyleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron offset: CGPoint(x: 0.0, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) guard let titleTextItem = titleTextItem else { return [] } let titleHeight = max(44.0, titleTextItem.frame.height + 26.0) titleTextItem.frame.origin.x = horizontalInset + 23.0 titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5) let isExpanded = context.expandedDetails[index] ?? defaultExpanded // V1 uses max(44.0, titleSize.height + 26.0); matched here. let titleFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: titleHeight) var innerLayout: InstantPageV2Layout? var totalHeight = titleHeight if isExpanded { let layout = layoutBlockSequence( blocks, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: .detail, context: &context ) innerLayout = layout totalHeight += layout.contentSize.height } let item = InstantPageV2DetailsItem( frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight), index: index, sideInset: horizontalInset, titleTextItem: titleTextItem, titleFrame: titleFrame, separatorColor: context.theme.separatorColor, isExpanded: isExpanded, innerLayout: innerLayout, defaultExpanded: defaultExpanded ) return [.details(item)] } // MARK: - Table layout private struct V2TableRow { var minColumnWidths: [Int: CGFloat] var maxColumnWidths: [Int: CGFloat] } let v2TableCellInsets: UIEdgeInsets = { return UIEdgeInsets(top: 15.0, left: 13.0, bottom: 15.0, right: 13.0) }() let v2TableBorderWidth: CGFloat = { return UIScreenPixel * 2.0 }() let v2TableCornerRadius: CGFloat = 10.0 private func layoutTable( title: RichText, rows: [InstantPageTableRow], bordered: Bool, striped: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { if rows.isEmpty { return [] } // Style stack shared across all cell text measurements. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .table, link: false) let borderWidth = bordered ? v2TableBorderWidth : 0.0 // Size columns against the inset content width (mirrors V1's `boundingWidth - horizontalInset*2`), // so a fitting table aligns with body text on both sides. The item frame stays full-width (flush) // and the renderer bakes the inset back in as a left margin on the scroll content. let contentBoundingWidth = boundingWidth - horizontalInset * 2.0 let totalCellPadding = v2TableCellInsets.left + v2TableCellInsets.right let cellWidthLimit = contentBoundingWidth - totalCellPadding var tableRows: [V2TableRow] = [] var columnCount: Int = 0 var columnSpans: [Range: (CGFloat, CGFloat)] = [:] var rowSpans: [Int: [(Int, Int)]] = [:] // Pass 1: measure min/max intrinsic width per cell. var r: Int = 0 for row in rows { var minColumnWidths: [Int: CGFloat] = [:] var maxColumnWidths: [Int: CGFloat] = [:] var i: Int = 0 for cell in row.cells { // Advance i past any rowspan-inherited columns (matches V1 lines 311–319). if let rowSpan = rowSpans[r] { for columnAndSpan in rowSpan { if columnAndSpan.0 == i { i += columnAndSpan.1 } else { break } } } var minCellWidth: CGFloat = 1.0 var maxCellWidth: CGFloat = 1.0 if let text = cell.text { // Mirror V1 (`InstantPageTableItem.layoutTableItem`): `attributedStringForRichText`'s // boundingWidth sizes inline attachments to `cellWidthLimit - totalCellPadding`, while // the line-break budget passed to `layoutTextItem` is the full `cellWidthLimit`. (V1 // subtracts `totalCellPadding` only on the attribute-string arg, not the layout arg.) let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding) if let shortestItem = layoutTextItem( attrStr, boundingWidth: cellWidthLimit, offset: CGPoint(), minimizeWidth: true, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ).0 { minCellWidth = shortestItem.effectiveWidth() + totalCellPadding } if let longestItem = layoutTextItem( attrStr, boundingWidth: cellWidthLimit, offset: CGPoint(), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ).0 { maxCellWidth = max(minCellWidth, longestItem.effectiveWidth() + totalCellPadding) } } if cell.colspan > 1 { minColumnWidths[i] = 1.0 maxColumnWidths[i] = 1.0 let spanRange = i ..< i + Int(cell.colspan) if let (minSW, maxSW) = columnSpans[spanRange] { columnSpans[spanRange] = (max(minSW, minCellWidth), max(maxSW, maxCellWidth)) } else { columnSpans[spanRange] = (minCellWidth, maxCellWidth) } } else { minColumnWidths[i] = minCellWidth maxColumnWidths[i] = maxCellWidth } let colspan = cell.colspan > 1 ? Int(clamping: cell.colspan) : 1 if cell.rowspan > 1 { for j in r ..< r + Int(cell.rowspan) { if rowSpans[j] == nil { rowSpans[j] = [(i, colspan)] } else { rowSpans[j]!.append((i, colspan)) } } } i += colspan } tableRows.append(V2TableRow(minColumnWidths: minColumnWidths, maxColumnWidths: maxColumnWidths)) columnCount = max(columnCount, i) r += 1 } // Aggregate column min/max across all rows. let maxContentWidth = contentBoundingWidth - borderWidth var availableWidth = maxContentWidth var minColumnWidths: [Int: CGFloat] = [:] var maxColumnWidths: [Int: CGFloat] = [:] var maxTotalWidth: CGFloat = 0.0 for i in 0 ..< columnCount { var minWidth: CGFloat = 1.0 var maxWidth: CGFloat = 1.0 for row in tableRows { if let w = row.minColumnWidths[i] { minWidth = max(minWidth, w) } if let w = row.maxColumnWidths[i] { maxWidth = max(maxWidth, w) } } minColumnWidths[i] = minWidth maxColumnWidths[i] = maxWidth availableWidth -= minWidth maxTotalWidth += maxWidth } // Apply colspan constraints. for (range, span) in columnSpans { let (minSpanWidth, maxSpanWidth) = span var minWidth: CGFloat = 0.0 var maxWidth: CGFloat = 0.0 for i in range { if let w = minColumnWidths[i] { minWidth += w } if let w = maxColumnWidths[i] { maxWidth += w } } if minWidth < minSpanWidth { let delta = minSpanWidth - minWidth for i in range { if let w = minColumnWidths[i] { let growth = floor(delta / CGFloat(range.count)) minColumnWidths[i] = w + growth availableWidth -= growth } } } if maxWidth < maxSpanWidth { let delta = maxSpanWidth - maxWidth for i in range { if let w = maxColumnWidths[i] { let growth = round(delta / CGFloat(range.count)) maxColumnWidths[i] = w + growth maxTotalWidth += growth } } } } // Width allocation: distribute available width across columns. var totalWidth = maxTotalWidth var finalColumnWidths: [Int: CGFloat] let widthToDistribute: CGFloat if availableWidth > 0 { widthToDistribute = availableWidth finalColumnWidths = minColumnWidths } else { widthToDistribute = maxContentWidth - maxTotalWidth finalColumnWidths = maxColumnWidths } if widthToDistribute > 0.0 { var distributedWidth = widthToDistribute for i in 0 ..< finalColumnWidths.count { var width = finalColumnWidths[i]! let maxWidth = maxColumnWidths[i]! let growth = min(round(widthToDistribute * maxWidth / maxTotalWidth), distributedWidth) width += growth distributedWidth -= growth finalColumnWidths[i] = width } totalWidth = contentBoundingWidth } else { totalWidth += borderWidth } // Pass 2 & 3: produce per-cell frames + sub-layouts. // Private struct to hold an in-progress cell before row height is known. struct PendingCell { let rowIndex: Int let column: Int let colspan: Int let rowspan: Int let cell: InstantPageTableCell var frame: CGRect // height is tentative until row height resolved let isHeader: Bool let isFilled: Bool // background fill (header or stripe) let subLayout: InstantPageV2Layout? let subLayoutContentHeight: CGFloat // height of cell content (before padding) } var finalizedCells: [InstantPageV2TableCell] = [] var origin = CGPoint(x: borderWidth / 2.0, y: borderWidth / 2.0) var totalHeight: CGFloat = 0.0 var rowHeights: [Int: CGFloat] = [:] var awaitingSpanCells: [Int: [(Int, PendingCell)]] = [:] for i in 0 ..< rows.count { let row = rows[i] var maxRowHeight: CGFloat = 0.0 var isEmptyRow = true origin.x = borderWidth / 2.0 var k: Int = 0 var rowCells: [PendingCell] = [] for cell in row.cells { // Skip columns occupied by row spans. if let cells = awaitingSpanCells[i] { for colAndCell in cells { if colAndCell.1.column == k { for j in 0 ..< colAndCell.1.colspan { if let width = finalColumnWidths[k + j] { origin.x += width } } k += colAndCell.1.colspan } else { break } } } let colspan: Int = cell.colspan > 1 ? Int(clamping: cell.colspan) : 1 let rowspan: Int = cell.rowspan > 1 ? Int(clamping: cell.rowspan) : 1 var cellWidth: CGFloat = 0.0 for j in 0 ..< colspan { if let width = finalColumnWidths[k + j] { cellWidth += width } } // Build cell sub-layout via recursive layoutBlockSequence. var subLayout: InstantPageV2Layout? var subLayoutHeight: CGFloat = 0.0 if let cellText = cell.text { let cellContentWidth = cellWidth - totalCellPadding if cellContentWidth > 0.0 { let cellLayout = layoutBlockSequence( [.paragraph(cellText)], boundingWidth: cellContentWidth, horizontalInset: 0.0, kind: .cell, context: &context ) stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header)) subLayout = cellLayout subLayoutHeight = cellLayout.contentSize.height isEmptyRow = false } } var cellHeight: CGFloat? if subLayout != nil { cellHeight = ceil(subLayoutHeight) + v2TableCellInsets.top + v2TableCellInsets.bottom } var isFilled = cell.header if !isFilled && striped { isFilled = i % 2 == 0 } let pendingCell = PendingCell( rowIndex: i, column: k, colspan: colspan, rowspan: rowspan, cell: cell, frame: CGRect(x: origin.x, y: origin.y, width: cellWidth, height: cellHeight ?? 20.0), isHeader: cell.header, isFilled: isFilled, subLayout: subLayout, subLayoutContentHeight: subLayoutHeight ) if rowspan == 1 { rowCells.append(pendingCell) if let ch = cellHeight { maxRowHeight = max(maxRowHeight, ch) } } else { for j in i ..< i + rowspan { if awaitingSpanCells[j] == nil { awaitingSpanCells[j] = [(k, pendingCell)] } else { awaitingSpanCells[j]!.append((k, pendingCell)) } } } k += colspan origin.x += cellWidth } // Capture theme color value before the closure to avoid capturing `inout context`. let tableHeaderColor = context.theme.tableHeaderColor // Helper: finalize a pending cell with known row height → produce InstantPageV2TableCell. let finalizeCell: (PendingCell, CGFloat) -> InstantPageV2TableCell = { pending, height in let finalFrame = CGRect(x: pending.frame.minX, y: pending.frame.minY, width: pending.frame.width, height: height) // Compute sub-layout frame within the cell (horizontal inset + vertical alignment). var subLayout = pending.subLayout if var sl = subLayout { let textHeight = pending.subLayoutContentHeight let vertOffset: CGFloat switch pending.cell.verticalAlignment { case .top: vertOffset = v2TableCellInsets.top case .middle: vertOffset = max(v2TableCellInsets.top, (height - textHeight) / 2.0) case .bottom: vertOffset = max(v2TableCellInsets.top, height - textHeight - v2TableCellInsets.bottom) } let horizOffset: CGFloat switch pending.cell.alignment { case .left: horizOffset = v2TableCellInsets.left case .center: horizOffset = (pending.frame.width - sl.contentSize.width) / 2.0 case .right: horizOffset = pending.frame.width - sl.contentSize.width - v2TableCellInsets.right } // Translate all items in the sub-layout by the inset. let delta = CGPoint(x: horizOffset, y: vertOffset) let translatedItems = sl.items.map { $0.offsetBy(delta) } sl = InstantPageV2Layout(contentSize: sl.contentSize, items: translatedItems, detailsIndices: sl.detailsIndices, media: sl.media, webpage: sl.webpage) subLayout = sl } let bgColor: UIColor? = pending.isFilled ? tableHeaderColor : nil let hAlign: NSTextAlignment switch pending.cell.alignment { case .left: hAlign = .left case .center: hAlign = .center case .right: hAlign = .right } let vAlign: InstantPageV2TableVerticalAlignment switch pending.cell.verticalAlignment { case .top: vAlign = .top case .middle: vAlign = .middle case .bottom: vAlign = .bottom } return InstantPageV2TableCell( frame: finalFrame, isHeader: pending.isHeader, horizontalAlignment: hAlign, verticalAlignment: vAlign, backgroundColor: bgColor, subLayout: subLayout ) } if !isEmptyRow { rowHeights[i] = maxRowHeight } else { rowHeights[i] = 0.0 maxRowHeight = 0.0 } // Resolve any row-spanning cells whose bottom row is now known. var completedSpans = [Int: Set]() if let cells = awaitingSpanCells[i] { isEmptyRow = false for colAndCell in cells { let pending = colAndCell.1 let utmostRow = pending.rowIndex + pending.rowspan - 1 if rowHeights[utmostRow] == nil { continue } var cellHeight: CGFloat = 0.0 for k in pending.rowIndex ..< utmostRow + 1 { if let h = rowHeights[k] { cellHeight += h } if completedSpans[k] == nil { completedSpans[k] = Set([colAndCell.0]) } else { completedSpans[k]!.insert(colAndCell.0) } } if pending.frame.height > cellHeight { let delta = pending.frame.height - cellHeight cellHeight = pending.frame.height maxRowHeight += delta rowHeights[i] = maxRowHeight } finalizedCells.append(finalizeCell(pending, cellHeight)) } } for pending in rowCells { finalizedCells.append(finalizeCell(pending, maxRowHeight)) } // Remove completed span cells from awaitingSpanCells. if !completedSpans.isEmpty { awaitingSpanCells = awaitingSpanCells.reduce([Int: [(Int, PendingCell)]]()) { current, rowAndValue in var result = current let cells = rowAndValue.value.filter { column, _ in if let completedSet = completedSpans[rowAndValue.key] { return !completedSet.contains(column) } return true } if !cells.isEmpty { result[rowAndValue.key] = cells } return result } } if !isEmptyRow { totalHeight += maxRowHeight origin.y += maxRowHeight } } totalHeight += borderWidth // RTL: flip all cell frames horizontally within totalWidth. if context.rtl { finalizedCells = finalizedCells.map { cell in let flippedX = totalWidth - cell.frame.minX - cell.frame.width let flippedFrame = CGRect(x: flippedX, y: cell.frame.minY, width: cell.frame.width, height: cell.frame.height) return InstantPageV2TableCell( frame: flippedFrame, isHeader: cell.isHeader, horizontalAlignment: cell.horizontalAlignment, verticalAlignment: cell.verticalAlignment, backgroundColor: cell.backgroundColor, subLayout: cell.subLayout ) } } // Build border lines (table-local coords). var horizontalLines: [CGRect] = [] var verticalLines: [CGRect] = [] if bordered { // Interior lines: for each cell, emit a top line if not in the first row, // and a left line if not in the first column. for cell in finalizedCells { let isFirstRow = cell.frame.minY <= borderWidth / 2.0 + 0.5 let isFirstCol = cell.frame.minX <= borderWidth / 2.0 + 0.5 if !isFirstRow { horizontalLines.append(CGRect(x: cell.frame.minX, y: cell.frame.minY, width: cell.frame.width, height: borderWidth)) } if !isFirstCol { verticalLines.append(CGRect(x: cell.frame.minX, y: cell.frame.minY, width: borderWidth, height: cell.frame.height)) } } } // Title sub-layout (above the grid). var titleSubLayout: InstantPageV2Layout? var titleFrame: CGRect? if case .empty = title { // no title } else { let titleLayout = layoutBlockSequence( [.paragraph(title)], boundingWidth: totalWidth - v2TableCellInsets.left * 2.0, horizontalInset: 0.0, kind: .cell, context: &context ) titleSubLayout = titleLayout let titleHeight = titleLayout.contentSize.height + v2TableCellInsets.top + v2TableCellInsets.bottom titleFrame = CGRect(x: 0.0, y: 0.0, width: totalWidth, height: titleHeight) } // The table item frame spans the full visible bubble interior (`boundingWidth`); the scroll // viewport equals what is actually visible. contentSize.width is the intrinsic grid width // (may exceed frame.width → horizontal scroll); the renderer adds the inset on both sides. let tableFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight + (titleFrame?.height ?? 0.0)) let contentSize = CGSize( width: totalWidth, height: totalHeight + (titleFrame?.height ?? 0.0) ) let tableItem = InstantPageV2TableItem( frame: tableFrame, titleSubLayout: titleSubLayout, titleFrame: titleFrame, contentSize: contentSize, contentInset: horizontalInset, cells: finalizedCells, horizontalLines: horizontalLines, verticalLines: verticalLines, bordered: bordered, striped: striped, borderColor: context.theme.tableBorderColor ) return [.table(tableItem)] } // MARK: - Media placeholder layout /// Caption+credit sub-helper. Items are positioned in block-global coordinates (y measured from the /// top of the media block); `offset` is the y-position of the bottom of the placeholder, so /// caption items start at `offset + topPadding`. The caller uses the returned total height to /// compute block size. Returns (items, totalHeight). private func layoutCaptionAndCredit( _ caption: InstantPageCaption, offset: CGFloat, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> ([InstantPageV2LaidOutItem], CGFloat) { var items: [InstantPageV2LaidOutItem] = [] var y = offset var totalHeight: CGFloat = 0.0 var rtl = context.rtl var captionIsEmpty = true if case .empty = caption.text { // no caption text } else { captionIsEmpty = false totalHeight += 14.0 y += 14.0 let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .caption, link: false) let attributedString = attributedStringForRichText(caption.text, styleStack: styleStack) let (textItem, captionItems, captionSize) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: y), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) totalHeight += captionSize.height y += captionSize.height items.append(contentsOf: captionItems) rtl = textItem?.containsRTL ?? rtl } if case .empty = caption.credit { // no credit text } else { if captionIsEmpty { totalHeight += 14.0 y += 14.0 } else { totalHeight += 10.0 y += 10.0 } let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .credit, link: false) let attributedString = attributedStringForRichText(caption.credit, styleStack: styleStack) let (_, creditItems, creditSize) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: rtl ? .right : .natural, offset: CGPoint(x: horizontalInset, y: y), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) totalHeight += creditSize.height items.append(contentsOf: creditItems) } return (items, totalHeight) } /// Variant of `layoutMediaWithCaption` that emits a caller-produced typed media item /// instead of a `.mediaPlaceholder`. The frame-fitting logic + caption/credit text item /// layout is otherwise identical. private func layoutTypedMediaWithCaption( produceItem: (CGRect, CGFloat) -> InstantPageV2LaidOutItem, naturalSize: CGSize, caption: InstantPageCaption, isCover: Bool, cornerRadius: CGFloat, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let availableWidth = boundingWidth - horizontalInset * 2.0 let scaledSize: CGSize if naturalSize.width > 0.0 && naturalSize.height > 0.0 { let scale = min(availableWidth / naturalSize.width, 1.0) scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) } else { scaledSize = CGSize(width: availableWidth, height: naturalSize.height) } let mediaFrame = CGRect(x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height) var result: [InstantPageV2LaidOutItem] = [produceItem(mediaFrame, cornerRadius)] let (captionItems, captionHeight) = layoutCaptionAndCredit( caption, offset: scaledSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) result.append(contentsOf: captionItems) // Same cover-padding logic as layoutMediaWithCaption: extend the last text item's frame by // 14pt when isCover && captionHeight > 0.0. if isCover && captionHeight > 0.0 { if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) { if case var .text(lastText) = result[lastIndex] { lastText.frame = CGRect( origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0) ) result[lastIndex] = .text(lastText) } } } return result } private func layoutMediaWithCaption( kind: InstantPageV2MediaPlaceholderKind, naturalSize: CGSize, caption: InstantPageCaption, isCover: Bool, cornerRadius: CGFloat, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Scale naturalSize to fit within (boundingWidth - horizontalInset*2) × naturalSize.height. let availableWidth = boundingWidth - horizontalInset * 2.0 let scaledSize: CGSize if naturalSize.width > 0.0 && naturalSize.height > 0.0 { let scale = min(availableWidth / naturalSize.width, 1.0) scaledSize = CGSize(width: floor(naturalSize.width * scale), height: floor(naturalSize.height * scale)) } else { scaledSize = CGSize(width: availableWidth, height: naturalSize.height) } let placeholderFrame = CGRect( x: horizontalInset, y: 0.0, width: scaledSize.width, height: scaledSize.height ) let placeholderItem = InstantPageV2MediaPlaceholderItem( frame: placeholderFrame, kind: kind, cornerRadius: cornerRadius ) var result: [InstantPageV2LaidOutItem] = [.mediaPlaceholder(placeholderItem)] let (captionItems, captionHeight) = layoutCaptionAndCredit( caption, offset: scaledSize.height, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context ) result.append(contentsOf: captionItems) // isCover adds extra 14pt bottom padding — but only when caption/credit text was actually // rendered (matches V1 lines 204-206: `contentSize.height > 0 && isCover`). For an // empty-caption cover image no padding is added. // Implemented by extending the last text item's frame rather than emitting an invisible shape // view that would silently consume tap area. if isCover && captionHeight > 0.0 { if let lastIndex = result.lastIndex(where: { if case .text = $0 { return true } else { return false } }) { if case var .text(lastText) = result[lastIndex] { lastText.frame = CGRect( origin: lastText.frame.origin, size: CGSize(width: lastText.frame.width, height: lastText.frame.height + 14.0) ) result[lastIndex] = .text(lastText) } } } return result } // MARK: - Simple-block layout functions private func layoutSimpleText( _ text: RichText, category: InstantPageTextCategoryType, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: category, link: false) let attributedString = attributedStringForRichText(text, styleStack: styleStack) let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) return items } private func layoutHeading( _ text: RichText, level: Int32, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, attributes: context.theme.headingTextAttributes(level: level, link: false)) let attributedString = attributedStringForRichText(text, styleStack: styleStack) let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) stampMarkdownContext(items, kind: .heading(level: max(1, min(6, Int(level))))) return items } private func layoutParagraph( _ text: RichText, boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, previousItems: [InstantPageV2LaidOutItem], context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let _ = previousItems let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: kind == .cell ? .table : .paragraph, link: false) let attributedString = attributedStringForRichText(text, styleStack: styleStack) let (_, items, _) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) return items } private func layoutAuthorDate( author: RichText, date: Int32, boundingWidth: CGFloat, horizontalInset: CGFloat, previousItems: [InstantPageV2LaidOutItem], context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Literal port of V1 InstantPageLayout.swift case .authorDate (lines 231–272). // Reads context.strings, formats date via DateFormatter with locale from localeWithStrings, // splices author and date into InstantPage_AuthorAndDateTitle format string. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .caption, link: false) // Capture strings by value to avoid capturing inout context in an escaping closure. let strings = context.strings let stringForDate: (Int32) -> String = { d in let formatter = DateFormatter() formatter.locale = localeWithStrings(strings) formatter.dateStyle = .long formatter.timeStyle = .none return formatter.string(from: Date(timeIntervalSince1970: Double(d))) } var text: RichText? if case .empty = author { if date != 0 { text = .plain(stringForDate(date)) } } else { if date != 0 { let dateText = RichText.plain(stringForDate(date)) let formatString = context.strings.InstantPage_AuthorAndDateTitle("%1$@", "%2$@").string let authorRange = formatString.range(of: "%1$@")! let dateRange = formatString.range(of: "%2$@")! if authorRange.lowerBound < dateRange.lowerBound { let byPart = String(formatString[formatString.startIndex ..< authorRange.lowerBound]) let middlePart = String(formatString[authorRange.upperBound ..< dateRange.lowerBound]) let endPart = String(formatString[dateRange.upperBound...]) text = .concat([.plain(byPart), author, .plain(middlePart), dateText, .plain(endPart)]) } else { let beforePart = String(formatString[formatString.startIndex ..< dateRange.lowerBound]) let middlePart = String(formatString[dateRange.upperBound ..< authorRange.lowerBound]) let endPart = String(formatString[authorRange.upperBound...]) text = .concat([.plain(beforePart), dateText, .plain(middlePart), author, .plain(endPart)]) } } else { text = author } } guard let resolvedText = text else { return [] } var previousItemHasRTL = false if case let .text(prev) = previousItems.last, prev.textItem.containsRTL { previousItemHasRTL = true } let alignment: NSTextAlignment = (context.rtl || previousItemHasRTL) ? .right : .natural let (_, items, _) = layoutTextItem( attributedStringForRichText(resolvedText, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0, alignment: alignment, offset: CGPoint(x: horizontalInset, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) return items } private func layoutDivider( boundingWidth: CGFloat, context: LayoutContext ) -> [InstantPageV2LaidOutItem] { // Geometry matches V1 InstantPageLayout.swift lines 361–363: // lineWidth = floor(boundingWidth / 2.0), x = floor((boundingWidth - lineWidth) / 2.0), h = 1pt. // Color matches V1: theme.textCategories.caption.color. let lineWidth = floor(boundingWidth / 2.0) let frame = CGRect( x: floor((boundingWidth - lineWidth) / 2.0), y: 0.0, width: lineWidth, height: 1.0 ) return [.divider(InstantPageV2DividerItem(frame: frame, color: context.theme.textCategories.caption.color))] } // MARK: - Code block layout (ported from V1 InstantPageLayout.swift lines 329–351) private func layoutCodeBlock( _ text: RichText, language: String?, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { let backgroundInset: CGFloat = 15.0 let textXOffset: CGFloat = 11.0 let cornerRadius: CGFloat = 0.0 let attributedString: NSAttributedString if let language, !language.isEmpty { // V1 line 333: call attributedStringForPreformattedText with language. attributedString = attributedStringForPreformattedText( text, language: language, theme: context.theme, cachedMessageSyntaxHighlight: context.cachedMessageSyntaxHighlight ) } else { // V1 lines 335–338: fall back to plain paragraph style when no language. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .codeBlock, link: false) attributedString = attributedStringForRichText(text, styleStack: styleStack) } // V1 line 341: text bounding width excludes horizontalInset×2 and backgroundInset×2. let innerWidth = boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0 // V1 line 342: offset is (17.0, backgroundInset); V2 lays the text in block-local coords. let (textItem, _, textSize) = layoutTextItem( attributedString, boundingWidth: innerWidth, offset: CGPoint(x: 0.0, y: 0.0), fitToWidth: context.fitToWidth, opaqueBackground: true, computeRevealCharacterRects: context.computeRevealCharacterRects ) guard let textItem = textItem else { return [] } textItem.markdownContext = InstantPageMarkdownBlockContext(kind: .code(language: language)) // Position text within the block's content area. // V1 line 342: x=17.0, y=backgroundInset (block-local). textItem.frame = CGRect( x: textXOffset, y: backgroundInset, width: textItem.frame.width, height: textItem.frame.height ) // V1 line 348: block spans full boundingWidth (x=0), height = contentSize.height + backgroundInset*2. let blockHeight = textSize.height + backgroundInset * 2.0 let blockFrame = CGRect( x: 0.0, y: 0.0, width: boundingWidth, height: blockHeight ) return [.codeBlock(InstantPageV2CodeBlockItem( frame: blockFrame, backgroundColor: context.theme.codeBlockBackgroundColor, cornerRadius: cornerRadius, textItem: textItem, inset: UIEdgeInsets(top: backgroundInset, left: textXOffset, bottom: backgroundInset, right: backgroundInset) ))] } private func layoutThinking( _ text: RichText, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps // its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old // hardcoded "Thinking…" header, which used the message theme's dimmed description color). let base = context.theme.textCategories.paragraph let dimmedAttributes = InstantPageTextAttributes( font: base.font, color: base.color.withAlphaComponent(0.55), underline: false ) let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes) let attributedString = attributedStringForRichText(text, styleStack: styleStack) // Mirror a normal `.text` item's sizing: lay the text out flush (offset .zero) and put the page // inset onto the BLOCK frame, so the `.thinking` item's frame == a `.text` item's frame // (`(horizontalInset, 0, textWidth, height)`) instead of a full-bleed `(0, 0, boundingWidth, …)` // box. The shimmer (sized to `item.frame.size`) then hugs the text rather than the whole page // width; the rendered text stays at the same place (`horizontalInset`) since the block carries // the inset. let (textItem, _, textSize) = layoutTextItem( attributedString, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: CGPoint(x: 0.0, y: 0.0), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) guard let textItem = textItem else { return [] } let blockFrame = CGRect(x: horizontalInset, y: 0.0, width: textSize.width, height: textSize.height) return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))] } // MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517–586) private func layoutBlockQuote( blocks: [InstantPageBlock], caption: RichText, boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, isLast: Bool, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Legacy single-paragraph fast path: preserve today's italicized body styling. if blocks.count == 1, case let .paragraph(text) = blocks[0] { return layoutQuoteText(text: text, caption: caption, isPull: false, boundingWidth: boundingWidth, horizontalInset: horizontalInset, context: &context) } let verticalInset: CGFloat = 4.0 let lineInset: CGFloat = context.fitToWidth ? 12.0 : 20.0 let barWidth: CGFloat = 3.0 let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset let innerHorizontalInset = horizontalInset + lineInset var result: [InstantPageV2LaidOutItem] = [] var contentHeight: CGFloat = verticalInset // Fixed, compact gap between a quote's child blocks. The full page-flow spacing // (spacingBetweenBlocks ~27pt around quotes) is too airy when nested; the first // child hugs the top (only verticalInset above it). let childSpacing: CGFloat = 10.0 for (i, child) in blocks.enumerated() { let spacing: CGFloat = i == 0 ? 0.0 : childSpacing let childItems = layoutBlock( child, boundingWidth: innerBoundingWidth, horizontalInset: innerHorizontalInset, kind: kind, isCover: false, previousItems: result, isLast: i == blocks.count - 1 && isLast, context: &context ) let dy = contentHeight + spacing let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: 0.0, y: dy)) } let childMaxY = offsetItems.map { $0.frame.maxY }.max() ?? dy contentHeight = max(contentHeight, childMaxY) result.append(contentsOf: offsetItems) } // Optional caption (mirrors layoutQuoteText's caption branch). if case .empty = caption { // no caption } else { contentHeight += 14.0 let captionStyleStack = InstantPageTextStyleStack() setupStyleStack(captionStyleStack, theme: context.theme, category: .caption, link: false) let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack) let (_, captionItems, captionSize) = layoutTextItem( attributedCaption, boundingWidth: innerBoundingWidth, alignment: .natural, offset: CGPoint(x: innerHorizontalInset, y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) result.append(contentsOf: captionItems) contentHeight += captionSize.height } contentHeight += verticalInset // Vertical bar on the leading edge (matches the blockQuote branch of layoutQuoteText). let bar = InstantPageV2BarItem( frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight), color: context.theme.textCategories.paragraph.color, cornerRadius: barWidth / 2.0 ) result.append(.blockQuoteBar(bar)) // Caption items (appended above) are also bumped to quoteDepth 1 and will render with a // `>` prefix. The whole-message markdown converter drops blockquote captions entirely, and // markdown-sent quotes carry empty captions, so this is benign. bumpQuoteDepth(result) return result } private func layoutQuoteText( text: RichText, caption: RichText, isPull: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // V1 line 518/553: verticalInset = 4.0 for both variants. let verticalInset: CGFloat = 4.0 // V1 line 518: lineInset = 20.0 (blockQuote only; pullQuote uses full width). let lineInset: CGFloat = isPull ? 0.0 : (context.fitToWidth ? 12.0 : 20.0) var result: [InstantPageV2LaidOutItem] = [] var contentHeight: CGFloat = verticalInset // V1 line 520/554: starts at verticalInset // Body text style: paragraph + italic (V1 lines 524–526 / 558–560). let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) styleStack.push(.italic) if isPull { // pullQuote: top horizontal rule (plan-specified; matches V1 divider geometry for caption color). // V1 doesn't emit shape ornaments for pullQuote, but the plan calls for them. // Geometry mirrors V1 divider (InstantPageLayout.swift line 362): // lineWidth = boundingWidth - horizontalInset * 2.0, x = horizontalInset, h = 1.0. let lineWidth = boundingWidth - horizontalInset * 2.0 let topLine = InstantPageV2ShapeItem( frame: CGRect(x: horizontalInset, y: contentHeight, width: lineWidth, height: 1.0), kind: .line(thickness: 1.0), color: context.theme.textCategories.caption.color ) result.append(.shape(topLine)) contentHeight += 1.0 + verticalInset // rule + small gap before body text } // Body text (V1 line 528 / 562). let textBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset let textX: CGFloat = horizontalInset + lineInset let textAlignment: NSTextAlignment = isPull ? .center : .natural let attributedBody = attributedStringForRichText(text, styleStack: styleStack) let (_, bodyItems, bodySize) = layoutTextItem( attributedBody, boundingWidth: textBoundingWidth, alignment: textAlignment, offset: CGPoint(x: textX, y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) result.append(contentsOf: bodyItems) contentHeight += bodySize.height // V1 line 530/567 // Optional caption (V1 lines 533–544 / 570–583). if case .empty = caption { // no caption } else { contentHeight += 14.0 // V1 lines 535/572: 14pt gap before caption let captionStyleStack = InstantPageTextStyleStack() setupStyleStack(captionStyleStack, theme: context.theme, category: .caption, link: false) let attributedCaption = attributedStringForRichText(caption, styleStack: captionStyleStack) let (_, captionItems, captionSize) = layoutTextItem( attributedCaption, boundingWidth: textBoundingWidth, alignment: textAlignment, offset: CGPoint(x: textX, y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) result.append(contentsOf: captionItems) contentHeight += captionSize.height // V1 lines 542/582 } contentHeight += verticalInset // V1 lines 545/585 if isPull { // pullQuote: bottom horizontal rule (plan-specified; mirrors top rule). let lineWidth = boundingWidth - horizontalInset * 2.0 let bottomLine = InstantPageV2ShapeItem( frame: CGRect(x: horizontalInset, y: contentHeight - 1.0, width: lineWidth, height: 1.0), kind: .line(thickness: 1.0), color: context.theme.textCategories.caption.color ) // Insert bottom rule before caption trailing space is consumed — append after final verticalInset. result.append(.shape(bottomLine)) } else { // blockQuote: vertical bar on the leading edge (V1 lines 547–549). // V1: frame = CGRect(x: horizontalInset, y: 0.0, width: 3.0, height: contentSize.height) // V1 shape: .roundLine (rounded caps) → cornerRadius = barWidth / 2 = 1.5. let barWidth: CGFloat = 3.0 // V1 line 547 let bar = InstantPageV2BarItem( frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight), color: context.theme.textCategories.paragraph.color, // V1 line 547 cornerRadius: barWidth / 2.0 // V1 .roundLine ≈ half-width rounded caps ) result.append(.blockQuoteBar(bar)) } // Tag this quote's produced text items at quote depth 1 so the markdown // converter renders them with a `> ` prefix. Applies to BOTH block quotes // (single-paragraph fast path) and pull quotes — the whole-message markdown // converter renders both flavors as `> `. Nested quotes are lifted further // by the outer multi-block path's own bumpQuoteDepth(result) call. bumpQuoteDepth(result) return result } // MARK: - List layout (ported from V1 InstantPageLayout.swift lines 365–516) private func layoutList( _ listItems: [InstantPageListItem], ordered: Bool, boundingWidth: CGFloat, horizontalInset: CGFloat, kind: BlockSequenceKind, context: inout LayoutContext ) -> [InstantPageV2LaidOutItem] { // Determine marker characteristics. var maxIndexWidth: CGFloat = 0.0 // hasNums: at least one ordered item carries an explicit `num` — in which case items // without one fall back to a blank " " (preserves the source's numbering gaps) rather // than auto-generated `(i + 1).`. Unordered lists never auto-generate numbers, so this // flag is only meaningful when `ordered` is true. (`hasTaskMarkers` is no longer derived // — the uniform 8pt gap below replaced the per-list `indexSpacing` ternary that consumed // it; column right-alignment handles mixed bullet/checkbox lists without flagging.) var hasNums = false if ordered { for item in listItems { if item.checked == nil, let num = item.num, !num.isEmpty { hasNums = true break } } } // Build per-item marker descriptors and measure their natural widths. struct MarkerInfo { let kind: InstantPageV2ListMarkerKind let naturalWidth: CGFloat // for right-aligning number markers } let checklistMarkerSize = CGSize(width: 18.0, height: 18.0) let checkboxColors = InstantPageV2CheckboxColors( background: context.theme.panelAccentColor, stroke: context.theme.pageBackgroundColor, border: context.theme.controlColor ) // Track maxIndexWidth for ALL marker kinds (ordered + unordered, all three shapes), not // just ordered as V1/older V2 did. With every kind contributing to the marker column width // we can right-align every marker to a single shared column edge — so in a mixed unordered // list (bullets + checkboxes) both right-align flush to the same x, and the same uniform // gap separates them from the text. The column width simply equals the widest marker; for // a pure bullet list `maxIndexWidth == 6` and the bullet sits at `horizontalInset` (visually // identical to the pre-change formula), and for a pure checkbox list `maxIndexWidth == 18` // matches the previous left-aligned placement too. var markerInfos: [MarkerInfo] = [] for (i, item) in listItems.enumerated() { if let checked = item.checked { maxIndexWidth = max(maxIndexWidth, checklistMarkerSize.width) markerInfos.append(MarkerInfo(kind: .checklist(checked: checked, colors: checkboxColors), naturalWidth: checklistMarkerSize.width)) } else if ordered { let value: String if hasNums { if let num = item.num { value = "\(num)." } else { value = " " } } else { value = "\(i + 1)." } // Measure using a UILabel to get the expected label width. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) let attrStr = attributedStringForRichText(.plain(value), styleStack: styleStack) let (textItem, _, _) = layoutTextItem( attrStr, boundingWidth: boundingWidth - horizontalInset * 2.0, offset: .zero, fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) let w: CGFloat if let textItem = textItem, let firstLine = textItem.lines.first { w = firstLine.frame.width } else { w = 0.0 } maxIndexWidth = max(maxIndexWidth, w) markerInfos.append(MarkerInfo(kind: .number(value), naturalWidth: w)) } else { // Bullet: 6×6 ellipse (matches V1 InstantPageShapeItem dimensions). maxIndexWidth = max(maxIndexWidth, 6.0) markerInfos.append(MarkerInfo(kind: .bullet, naturalWidth: 6.0)) } } // Uniform 8pt marker→text gap across all four cases (ordered/unordered × bullet/number/ // checkbox). With markers right-aligned to a shared column of width `maxIndexWidth`, text // starts at `horizontalInset + maxIndexWidth + indexSpacing` — so `indexSpacing` IS the // gap, regardless of marker shape. V1 used 12/16/20/24 (a mix of marker-area-width and // gap-after-marker, depending on alignment); the four gaps came out to 12/16/14/6 — far // from uniform, and a 14pt bullet gap looked especially loose. 8pt is a standard iOS list // gap; it tightens bullets (14→8), numbers (12→8) and ordered-checkbox (16→8), and only // loosens unordered-checkbox very slightly (6→8) so all four kinds match. let indexSpacing: CGFloat = 8.0 // Layout each item. var result: [InstantPageV2LaidOutItem] = [] var contentHeight: CGFloat = 0.0 for (i, item) in listItems.enumerated() { // Inter-item spacing (matches V1: 18pt normal, 12pt fitToWidth). if i != 0 { contentHeight += context.fitToWidth ? 12.0 : 18.0 } let markerInfo = markerInfos[i] // Effective item: if a .blocks item is empty, treat as a single space. var effectiveItem = item if case let .blocks(blocks, num, checked) = effectiveItem, blocks.isEmpty { effectiveItem = .text(.plain(" "), num, checked) } // Derive the markdown marker string and checked state from the original item. let markdownMarker: String switch markerInfo.kind { case let .number(value): markdownMarker = value default: markdownMarker = "-" } let markdownChecked: Bool? = item.checked switch effectiveItem { case let .text(text, _, _): // Layout text content. let styleStack = InstantPageTextStyleStack() setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false) let attrStr = attributedStringForRichText(text, styleStack: styleStack) let textX = horizontalInset + indexSpacing + maxIndexWidth let textWidth = boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth let (textItem, textLaidOutItems, textSize) = layoutTextItem( attrStr, boundingWidth: textWidth, offset: CGPoint(x: textX, y: contentHeight), fitToWidth: context.fitToWidth, computeRevealCharacterRects: context.computeRevealCharacterRects ) // Compute marker vertical position: align to mid of first text line. var lineMidY: CGFloat = contentHeight if let textItem = textItem { if let firstLine = textItem.lines.first { lineMidY = textItem.frame.minY + firstLine.frame.midY } else { lineMidY = textItem.frame.midY } } // Compute marker frame. let markerFrame = markerFrameFor( kind: markerInfo.kind, naturalWidth: markerInfo.naturalWidth, maxIndexWidth: maxIndexWidth, horizontalInset: horizontalInset, checklistMarkerSize: checklistMarkerSize, lineMidY: lineMidY, rtl: context.rtl, boundingWidth: boundingWidth ) result.append(.listMarker(InstantPageV2ListMarkerItem( frame: markerFrame, kind: markerInfo.kind, color: context.theme.textCategories.paragraph.color ))) stampMarkdownContext(textLaidOutItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) result.append(contentsOf: textLaidOutItems) contentHeight += textSize.height case let .blocks(blocks, _, _): // Nested block content (e.g. sub-list, paragraphs). var previousBlock: InstantPageBlock? let originY = contentHeight var firstBlockLineMidY: CGFloat? for (j, subBlock) in blocks.enumerated() { let subItems = layoutBlock( subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, kind: kind, isCover: false, previousItems: result, isLast: j == blocks.count - 1, context: &context ) let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0 let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0 let offsetX = horizontalInset + indexSpacing + maxIndexWidth let offsetY = contentHeight + spacing let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) } if firstBlockLineMidY == nil { // Find the mid-Y of the first text line in the first block. for translated in translatedItems { if case let .text(tv) = translated { if let firstLine = tv.textItem.lines.first { firstBlockLineMidY = tv.frame.minY + firstLine.frame.midY } else { firstBlockLineMidY = tv.frame.midY } break } } } // Compute block height contribution. // offsetY = contentHeight + spacing, so blockMaxY already accounts for spacing. var blockMaxY: CGFloat = offsetY for ti in translatedItems { blockMaxY = max(blockMaxY, ti.frame.maxY) } // Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading) // already stamped by a child block's own layout. So a heading nested inside a // .blocks list item keeps .heading, not .listItem — multi-block list items are // a documented best-effort case for markdown reconstruction. stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked)) result.append(contentsOf: translatedItems) contentHeight = blockMaxY previousBlock = subBlock } // Mirror the .text case above (and what .checklist already does here): use the // first text line's midY for centering. `originY` is the sub-block's TOP, NOT a // line midpoint — `markerFrameFor` then subtracts `size.height / 2`, so feeding // `originY` placed the marker straddling the sub-block boundary, ½·marker-height // ABOVE the first text line. V1 hid the same arithmetic under a 6×12 shape with a // 3pt internal offset (matching ½·fontLineHeight for 17pt paragraph text), which // by coincidence equals `firstBlockLineMidY`. Using firstBlockLineMidY directly // makes the alignment explicit, unifies the three marker kinds, and matches the // .text case exactly. Fallback to `originY` when no text is in the first sub-block // (image-first lists are rare); mirrors the existing .checklist fallback. let markerLineMidY: CGFloat = firstBlockLineMidY ?? originY let markerFrame = markerFrameFor( kind: markerInfo.kind, naturalWidth: markerInfo.naturalWidth, maxIndexWidth: maxIndexWidth, horizontalInset: horizontalInset, checklistMarkerSize: checklistMarkerSize, lineMidY: markerLineMidY, rtl: context.rtl, boundingWidth: boundingWidth ) result.append(.listMarker(InstantPageV2ListMarkerItem( frame: markerFrame, kind: markerInfo.kind, color: context.theme.textCategories.paragraph.color ))) default: break } } return result } /// Computes the frame for a list marker, handling RTL and all three marker kinds. /// /// All marker kinds are right-aligned within the shared `[horizontalInset, horizontalInset + /// maxIndexWidth]` column (LTR) or left-aligned within the mirrored column on the right (RTL). /// For a pure-kind list `maxIndexWidth == markerWidth`, so the marker lands at `horizontalInset` /// exactly as before; for mixed unordered lists, bullets and checkboxes align flush at the /// column's inner edge. Column right-alignment is the single rule across every marker shape /// — no `ordered` / `indexSpacing` split — which is why those parameters dropped. private func markerFrameFor( kind: InstantPageV2ListMarkerKind, naturalWidth: CGFloat, maxIndexWidth: CGFloat, horizontalInset: CGFloat, checklistMarkerSize: CGSize, lineMidY: CGFloat, rtl: Bool, boundingWidth: CGFloat ) -> CGRect { let size: CGSize switch kind { case .bullet: size = CGSize(width: 6.0, height: 6.0) case .number: size = CGSize(width: naturalWidth, height: 20.0) case .checklist: size = checklistMarkerSize } let x: CGFloat if rtl { x = boundingWidth - horizontalInset - maxIndexWidth } else { x = horizontalInset + maxIndexWidth - size.width } return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height) } // MARK: - Style helpers (ported from V1 InstantPageLayout.swift lines 32–88) private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, attributes: InstantPageTextAttributes) { stack.push(.textColor(attributes.color)) stack.push(.markerColor(theme.markerColor)) stack.push(.linkColor(theme.linkColor)) stack.push(.linkMarkerColor(theme.linkHighlightColor)) switch attributes.font.style { case .sans: stack.push(.fontSerif(false)) case .serif: stack.push(.fontSerif(true)) case .monospace: stack.push(.fontFixed(true)) } stack.push(.fontSize(attributes.font.size)) stack.push(.lineSpacingFactor(attributes.font.lineSpacingFactor)) if attributes.underline { stack.push(.underline) } } private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, category: InstantPageTextCategoryType, link: Bool) { setupStyleStack(stack, theme: theme, attributes: theme.textCategories.attributes(type: category, link: link)) } private func instantPageFont(style: InstantPageTextAttributes, bold: Bool = false, italic: Bool = false, fixed: Bool = false) -> UIFont { let size = style.font.size if fixed { if bold && italic { return UIFont(name: "Menlo-BoldItalic", size: size) ?? Font.semiboldItalic(size) } else if bold { return UIFont(name: "Menlo-Bold", size: size) ?? Font.bold(size) } else if italic { return UIFont(name: "Menlo-Italic", size: size) ?? Font.italic(size) } else { return UIFont(name: "Menlo", size: size) ?? Font.regular(size) } } switch style.font.style { case .serif: if bold && italic { return UIFont(name: "Georgia-BoldItalic", size: size) ?? Font.semiboldItalic(size) } else if bold { return UIFont(name: "Georgia-Bold", size: size) ?? Font.bold(size) } else if italic { return UIFont(name: "Georgia-Italic", size: size) ?? Font.italic(size) } else { return UIFont(name: "Georgia", size: size) ?? Font.regular(size) } case .sans: if bold && italic { return Font.semiboldItalic(size) } else if bold { return Font.bold(size) } else if italic { return Font.italic(size) } else { return Font.regular(size) } case .monospace: if bold && italic { return Font.semiboldItalicMonospace(size) } else if bold { return Font.semiboldMonospace(size) } else if italic { return Font.italicMonospace(size) } else { return Font.monospace(size) } } } // MARK: - Preformatted text (ported from V1 InstantPageLayout.swift lines 91–132) private func attributedStringForPreformattedText(_ text: RichText, language: String?, theme: InstantPageTheme, cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?) -> NSAttributedString { let paragraphAttributes = theme.textCategories.attributes(type: .paragraph, link: false) let textValue = text.plainText guard !textValue.isEmpty else { return NSAttributedString( string: "", attributes: [ .font: instantPageFont(style: paragraphAttributes, fixed: true), .foregroundColor: paragraphAttributes.color, NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute): paragraphAttributes.font.lineSpacingFactor as NSNumber ] ) } let attributedString = stringWithAppliedEntities( textValue, entities: [ MessageTextEntity(range: 0 ..< (textValue as NSString).length, type: .Pre(language: language)) ], baseColor: paragraphAttributes.color, linkColor: theme.linkColor, codeBlockTitleColor: paragraphAttributes.color, codeBlockAccentColor: paragraphAttributes.color, codeBlockBackgroundColor: theme.codeBlockBackgroundColor, baseFont: instantPageFont(style: paragraphAttributes), linkFont: instantPageFont(style: paragraphAttributes), boldFont: instantPageFont(style: paragraphAttributes, bold: true), italicFont: instantPageFont(style: paragraphAttributes, italic: true), boldItalicFont: instantPageFont(style: paragraphAttributes, bold: true, italic: true), fixedFont: instantPageFont(style: paragraphAttributes, fixed: true), blockQuoteFont: instantPageFont(style: paragraphAttributes), underlineLinks: false, message: nil, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight ).mutableCopy() as! NSMutableAttributedString attributedString.addAttribute( NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute), value: paragraphAttributes.font.lineSpacingFactor as NSNumber, range: NSRange(location: 0, length: attributedString.length) ) return attributedString } // MARK: - V2 text-item layout (ported from V1 InstantPageTextItem.swift layoutTextItemWithString) // // V0 difference from V1: // * Inline image runs are NOT emitted as items here. They are discovered at view-update time // by `InstantPageV2View.updateInlineImages()`, which walks each text view's `line.imageItems` // and creates `InstantPageV2InlineImageView`s attached to the text view's `imageContainerView` // (the pop-in animation mirrors the inline custom-emoji ownership model). // * Inline formula runs produce `.formula(InstantPageV2FormulaItem(...))` items carrying the // rendered math image (see `InstantPageV2FormulaView`); the line's `formulaItems` field // already provides the attachment + frame. // * No `InstantPageScrollableTextItem` wrapping: even if `requiresScroll` would be true in V1, // V2 takes the non-scroll path (text item kept flat; long preformatted lines simply clip // outside the bubble width). Deferred to a future iteration. // Internal helpers ported from V1 InstantPageTextItem.swift (declared private there; copied here). // `internal` (not private) so that InstantPageRenderer.swift can call them from the same module. func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignment: NSTextAlignment) -> CGRect { var lineFrame = line.frame if alignment == .center { lineFrame.origin.x = floor((boundingWidth - lineFrame.size.width) / 2.0) } else if alignment == .right || (alignment == .natural && line.isRTL) { lineFrame.origin.x = boundingWidth - lineFrame.size.width } return lineFrame } private func v2LocalAttachmentBoundsForRange(_ range: NSRange, imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun]) -> CGRect? { var result: CGRect? for imageItem in imageItems { if NSIntersectionRange(range, imageItem.range).length != 0 { if let current = result { result = current.union(imageItem.frame) } else { result = imageItem.frame } } } for formulaItem in formulaItems { if NSIntersectionRange(range, formulaItem.range).length != 0 { if let current = result { result = current.union(formulaItem.frame) } else { result = formulaItem.frame } } } return result } private struct PendingV2ImageAttachment { let xOffset: CGFloat let range: NSRange let id: Int64 let size: CGSize } private struct PendingV2FormulaAttachment { let xOffset: CGFloat let range: NSRange let attachment: InstantPageMathAttachment let baselineOffset: CGFloat } private struct PendingV2EmojiAttachment { let xOffset: CGFloat let range: NSRange let emoji: ChatTextInputTextCustomEmojiAttribute let size: CGFloat } func layoutTextItem( _ string: NSAttributedString, boundingWidth: CGFloat, horizontalInset: CGFloat = 0.0, alignment: NSTextAlignment = .natural, offset: CGPoint, minimizeWidth: Bool = false, fitToWidth: Bool = false, maxNumberOfLines: Int = 0, opaqueBackground: Bool = false, computeRevealCharacterRects: Bool = false ) -> (InstantPageTextItem?, [InstantPageV2LaidOutItem], CGSize) { if string.length == 0 { return (nil, [], CGSize()) } var lines: [InstantPageTextLine] = [] var imageItems: [InstantPageTextImageItem] = [] var hasFormulaItems: Bool = false var font = string.attribute(NSAttributedString.Key.font, at: 0, effectiveRange: nil) as? UIFont if font == nil { let range = NSMakeRange(0, string.length) string.enumerateAttributes(in: range, options: []) { attributes, range, _ in if font == nil, let furtherFont = attributes[NSAttributedString.Key.font] as? UIFont { font = furtherFont } } } let image = string.attribute(NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute), at: 0, effectiveRange: nil) let formula = string.attribute(NSAttributedString.Key(rawValue: InstantPageFormulaAttribute), at: 0, effectiveRange: nil) guard font != nil || image != nil || formula != nil else { return (nil, [], CGSize()) } var lineSpacingFactor: CGFloat = 1.12 if let lineSpacingFactorAttribute = string.attribute(NSAttributedString.Key(rawValue: InstantPageLineSpacingFactorAttribute), at: 0, effectiveRange: nil) { lineSpacingFactor = CGFloat((lineSpacingFactorAttribute as! NSNumber).floatValue) } let typesetter = CTTypesetterCreateWithAttributedString(string) let fontAscent = font?.ascender ?? 0.0 let fontDescent = font?.descender ?? 0.0 let fontLineHeight = floor(fontAscent + fontDescent) let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) let fontDescentBelowBaseline = max(0.0, -fontDescent) // True font-height line box: shift the whole line stack down by the ascender headroom above // the cap line (A − L) and pad the final height by the descender (D) below the last baseline, // so a single-line item measures exactly A + D. Exact (not pixel-snapped): this is an // intra-item line offset; crispness rides on the item's own pixel-snapped frame origin, and // intra-item line positions may already be fractional (e.g. after a non-integral extraDescent). // Inter-line advance is unchanged. (Named `lineBoxTopInset` to avoid colliding with the // formula-bleed `topInset` local near the end of this function.) let lineBoxTopInset = max(0.0, fontAscent - fontLineHeight) let baselineToNextTopSlack = max(0.0, fontLineSpacing - 4.0) var lastIndex: CFIndex = 0 var currentLineOrigin = CGPoint(x: 0.0, y: lineBoxTopInset) var hasAnchors = false var maxLineWidth: CGFloat = 0.0 var extraDescent: CGFloat = 0.0 let text = string.string var indexOffset: CFIndex? while true { var workingLineOrigin = currentLineOrigin let currentMaxWidth = boundingWidth - workingLineOrigin.x var lineCharacterCount: CFIndex var hadIndexOffset = false if minimizeWidth { var count = 0 for ch in text.suffix(text.count - lastIndex) { count += 1 if ch == " " || ch == "\n" || ch == "\t" { break } } lineCharacterCount = count } else { let suggestedLineBreak = CTTypesetterSuggestLineBreak(typesetter, lastIndex, Double(currentMaxWidth)) if let offset = indexOffset { lineCharacterCount = suggestedLineBreak + offset if lineCharacterCount <= 0 { lineCharacterCount = suggestedLineBreak } indexOffset = nil hadIndexOffset = true } else { lineCharacterCount = suggestedLineBreak } } if lineCharacterCount > 0 { var line = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastIndex, lineCharacterCount), 100.0) var lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) let lineRange = NSMakeRange(lastIndex, lineCharacterCount) var stop = false if maxNumberOfLines > 0 && lines.count == maxNumberOfLines - 1 && lastIndex + lineCharacterCount < string.length { let attributes = string.attributes(at: lastIndex + lineCharacterCount - 1, effectiveRange: nil) if let truncateString = CFAttributedStringCreate(nil, "\u{2026}" as CFString, attributes as CFDictionary) { let truncateToken = CTLineCreateWithAttributedString(truncateString) let tokenWidth = CGFloat(CTLineGetTypographicBounds(truncateToken, nil, nil, nil) + 3.0) if let truncatedLine = CTLineCreateTruncatedLine(line, Double(lineWidth - tokenWidth), .end, truncateToken) { lineWidth += tokenWidth line = truncatedLine } } stop = true } let hadExtraDescent = extraDescent > 0.0 extraDescent = 0.0 var lineImageItems: [InstantPageTextImageItem] = [] var lineFormulaItems: [InstantPageTextFormulaRun] = [] var pendingImages: [PendingV2ImageAttachment] = [] var pendingFormulas: [PendingV2FormulaAttachment] = [] var lineEmojiItems: [InstantPageTextEmojiItem] = [] var pendingEmoji: [PendingV2EmojiAttachment] = [] var isRTL = false if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], !glyphRuns.isEmpty { if let run = glyphRuns.first, CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { isRTL = true } for run in glyphRuns { let cfRunRange = CTRunGetStringRange(run) let runRange = NSMakeRange(cfRunRange.location == kCFNotFound ? NSNotFound : cfRunRange.location, cfRunRange.length) string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions { let imageSize = dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth)) let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil) pendingImages.append(PendingV2ImageAttachment(xOffset: xOffset, range: range, id: id, size: imageSize)) } else if let attachment = attributes[NSAttributedString.Key(rawValue: InstantPageFormulaAttribute)] as? InstantPageMathAttachment { let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil) let baselineOffset = (attributes[NSAttributedString.Key.baselineOffset] as? CGFloat) ?? 0.0 pendingFormulas.append(PendingV2FormulaAttachment(xOffset: xOffset, range: range, attachment: attachment, baselineOffset: baselineOffset)) } else if let emoji = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute { let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil) let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0) let itemSize = font.pointSize * 24.0 / 17.0 pendingEmoji.append(PendingV2EmojiAttachment(xOffset: xOffset, range: range, emoji: emoji, size: itemSize)) } } } } // Inline emoji and images do NOT inflate the line: they are centered on the font // line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString` // and the chat `InteractiveTextComponent`). Their run delegates already report the // font's own ascent/descent, so CoreText lays the line out at the normal height — the // old `lineAscent = emoji.size` inflation both doubled the line height and (because the // baseline sits at the bottom of the box) shoved the text baseline down. Only formulas, // which carry their own typographic metrics, are allowed to grow the line. var lineAscent: CGFloat = fontLineHeight var lineDescent: CGFloat = fontDescentBelowBaseline for formula in pendingFormulas { let formulaAscent = formula.attachment.rendered.size.height - formula.attachment.rendered.descent if formulaAscent > lineAscent { lineAscent = formulaAscent } if formula.attachment.rendered.descent > lineDescent { lineDescent = formula.attachment.rendered.descent } } let baselineY = workingLineOrigin.y + lineAscent for image in pendingImages { // Center on the font line box (baseline − fontLineHeight/2), matching V1's // `(fontLineHeight - imageHeight) / 2` offset, instead of bottom-aligning on the // baseline. Keeps the text baseline put and lets the image bleed symmetrically. let imageFrame = CGRect( x: workingLineOrigin.x + image.xOffset, y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - image.size.height / 2.0), width: image.size.width, height: image.size.height ) lineImageItems.append(InstantPageTextImageItem(frame: imageFrame, range: image.range, id: EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: image.id))) } for formula in pendingFormulas { let attachment = formula.attachment let formulaAscent = attachment.rendered.size.height - attachment.rendered.descent let formulaFrame = CGRect( x: workingLineOrigin.x + formula.xOffset, y: baselineY - formulaAscent + formula.baselineOffset, width: attachment.rendered.size.width, height: attachment.rendered.size.height ) lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: formula.range, attachment: attachment)) } for emoji in pendingEmoji { // Center on the font line box (baseline − fontLineHeight/2) so a 24pt emoji on a // ~17pt line bleeds symmetrically rather than forcing the line taller and pushing // the text baseline down. Matches the chat `InteractiveTextComponent` placement. let emojiFrame = CGRect( x: workingLineOrigin.x + emoji.xOffset, y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - emoji.size / 2.0), width: emoji.size, height: emoji.size ) lineEmojiItems.append(InstantPageTextEmojiItem(frame: emojiFrame, range: emoji.range, emoji: emoji.emoji)) } extraDescent = max(0.0, lineDescent - baselineToNextTopSlack) // A centered attachment taller than the line bleeds below the baseline; grow the // descent so the following line isn't overlapped (mirrors V1's extraDescent handling). // Emoji at the default 24/17 ratio stay within the line slack and contribute nothing. for imageItem in lineImageItems { extraDescent = max(extraDescent, imageItem.frame.maxY - (baselineY + baselineToNextTopSlack)) } for emojiItem in lineEmojiItems { extraDescent = max(extraDescent, emojiItem.frame.maxY - (baselineY + baselineToNextTopSlack)) } if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0 { if let imageItem = lineImageItems.last { indexOffset = -(lastIndex + lineCharacterCount - imageItem.range.lowerBound) continue } if let formulaItem = lineFormulaItems.last { indexOffset = -(lastIndex + lineCharacterCount - formulaItem.range.lowerBound) continue } } var strikethroughItems: [InstantPageTextStrikethroughItem] = [] var underlineItems: [InstantPageTextUnderlineItem] = [] var markedItems: [InstantPageTextMarkedItem] = [] var spoilerItems: [InstantPageTextSpoilerItem] = [] var anchorItems: [InstantPageTextAnchorItem] = [] string.enumerateAttributes(in: lineRange, options: []) { attributes, range, _ in if let _ = attributes[NSAttributedString.Key.strikethroughStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight))) } if let _ = attributes[NSAttributedString.Key.underlineStyle] { let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX underlineItems.append(InstantPageTextUnderlineItem( frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight), range: range, color: attributes[NSAttributedString.Key.underlineColor] as? UIColor )) } if let color = attributes[NSAttributedString.Key.init(rawValue: InstantPageMarkerColorAttribute)] as? UIColor { var lineHeight = fontLineHeight var delta: CGFloat = 0.0 if let offset = attributes[NSAttributedString.Key.baselineOffset] as? CGFloat { lineHeight = floorToScreenPixels(lineHeight * 0.85) delta = offset * 0.6 } let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX markedItems.append(InstantPageTextMarkedItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight) + delta, width: abs(upperX - lowerX), height: lineHeight), color: color, range: range)) } if attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] != nil { let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX spoilerItems.append(InstantPageTextSpoilerItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight), range: range)) } if let item = attributes[NSAttributedString.Key.init(rawValue: InstantPageAnchorAttribute)] as? Dictionary, let name = item["name"] as? String, let empty = item["empty"] as? Bool { anchorItems.append(InstantPageTextAnchorItem(name: name, anchorText: item["text"] as? NSAttributedString, empty: empty)) } } if !anchorItems.isEmpty { hasAnchors = true } if hadExtraDescent && extraDescent > 0 { workingLineOrigin.y += fontLineSpacing } let height = lineAscent if !markedItems.isEmpty { markedItems = markedItems.map { item in if let attachmentBounds = v2LocalAttachmentBoundsForRange(item.range, imageItems: lineImageItems, formulaItems: lineFormulaItems) { return InstantPageTextMarkedItem(frame: attachmentBounds, color: item.color, range: item.range) } else { return item } } } // Per-character rects use each glyph's actual ink bounds via // CTFontGetBoundingRectsForGlyphs — caret-position advance-width // math (CTLineGetOffsetForStringIndex) is too tight for italics, // accented marks, and any glyph with side bearings, which causes // the reveal mask to visibly clip the glyph edges. Mirrors // InteractiveTextComponent.computeCharacterRectsForLine. // // For ligatures (one glyph for multiple chars), only the first // char's slot is populated; the rest stay CGRect.zero and the // consumer's `rect.isEmpty` guard skips them. let lineCharacterRects: [CGRect]? if computeRevealCharacterRects { var rects = [CGRect](repeating: CGRect.zero, count: lineRange.length) let glyphRuns = CTLineGetGlyphRuns(line) as NSArray for run in glyphRuns { let run = run as! CTRun let glyphCount = CTRunGetGlyphCount(run) if glyphCount == 0 { continue } var glyphs = [CGGlyph](repeating: 0, count: glyphCount) CTRunGetGlyphs(run, CFRangeMake(0, glyphCount), &glyphs) var positions = [CGPoint](repeating: CGPoint.zero, count: glyphCount) CTRunGetPositions(run, CFRangeMake(0, glyphCount), &positions) var stringIndices = [CFIndex](repeating: 0, count: glyphCount) CTRunGetStringIndices(run, CFRangeMake(0, glyphCount), &stringIndices) let attributes = CTRunGetAttributes(run) as NSDictionary guard let font = attributes[kCTFontAttributeName] as! CTFont? else { continue } var boundingRects = [CGRect](repeating: CGRect.zero, count: glyphCount) CTFontGetBoundingRectsForGlyphs(font, .default, &glyphs, &boundingRects, glyphCount) for i in 0 ..< glyphCount { let charIndex = stringIndices[i] - lineRange.location if charIndex >= 0 && charIndex < lineRange.length { let pos = positions[i] let bbox = boundingRects[i] rects[charIndex] = CGRect( x: pos.x + bbox.origin.x, y: pos.y + bbox.origin.y, width: bbox.width, height: bbox.height ) } } } for emoji in pendingEmoji { let localIndex = emoji.range.location - lineRange.location if localIndex >= 0 && localIndex < rects.count { let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil) // characterRects are baseline-relative (positive-up). The emoji cell is now // centered on the font line box (see frame loop), so in baseline-relative // coords it spans [fontLineHeight/2 − size/2, fontLineHeight/2 + size/2]. // Width feeds the reveal cost map; maxY feeds the reveal-mask y conversion in // the renderer (lineAscent − maxY), keeping the mask tracking the centered cell. rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - emoji.size / 2.0, width: emoji.size, height: emoji.size) } } for image in pendingImages { let localIndex = image.range.location - lineRange.location if localIndex >= 0 && localIndex < rects.count { let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil) // Image cell is centered on the font line box (see frame loop). Baseline-relative // cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full // width feeds the reveal cost map so the streaming cursor is charged the image's // width when crossing it — same as an emoji cell. rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height) } } lineCharacterRects = rects } else { lineCharacterRects = nil } let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, spoilerItems: spoilerItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, emojiItems: lineEmojiItems, anchorItems: anchorItems, isRTL: isRTL, characterRects: lineCharacterRects) lines.append(textLine) imageItems.append(contentsOf: lineImageItems) if !lineFormulaItems.isEmpty { hasFormulaItems = true } if lineWidth > maxLineWidth { maxLineWidth = lineWidth } workingLineOrigin.x = 0.0 workingLineOrigin.y += lineAscent + fontLineSpacing + extraDescent currentLineOrigin = workingLineOrigin lastIndex += lineCharacterCount if stop { break } } else { break } } var height: CGFloat = 0.0 if !lines.isEmpty && !(string.string == "\u{200b}" && hasAnchors) { // + fontDescentBelowBaseline: contain the last line's descender below its baseline, so // (with the topInset shift) a single-line item measures exactly A + D = true font height. height = lines.last!.frame.maxY + extraDescent + fontDescentBelowBaseline } var textWidth = boundingWidth if fitToWidth { textWidth = maxLineWidth } if (!imageItems.isEmpty || hasFormulaItems) && maxLineWidth > boundingWidth + 10.0 { textWidth = maxLineWidth } let textItem = InstantPageTextItem(frame: CGRect(x: 0.0, y: 0.0, width: textWidth, height: height), attributedString: string, alignment: alignment, opaqueBackground: opaqueBackground, lines: lines) textItem.frame = textItem.frame.offsetBy(dx: offset.x, dy: offset.y) var items: [InstantPageV2LaidOutItem] = [] if imageItems.isEmpty || string.length > 1 { items.append(.text(InstantPageV2TextItem(frame: textItem.frame, textItem: textItem))) } var topInset: CGFloat = 0.0 var bottomInset: CGFloat = 0.0 var additionalItems: [InstantPageV2LaidOutItem] = [] let effectiveOffset = offset for line in textItem.lines { let lineFrame = v2FrameForLine(line, boundingWidth: boundingWidth, alignment: alignment) // Inline images (RichText.image) are NOT emitted as top-level items here. They are // discovered at view-update time by InstantPageV2View.updateInlineImages(), which // walks each text view's `line.imageItems` and creates an InstantPageV2InlineImageView // attached to the text view's imageContainerView. The pop-in animation reuses the // emoji-style ownership model. See the inline-image design doc: // docs/superpowers/specs/2026-05-28-instantpage-v2-inline-image-design.md. for formulaItem in line.formulaItems { let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y) let item = InstantPageV2FormulaItem( frame: formulaFrame, attachment: formulaItem.attachment, isScrollable: false, imageFrame: CGRect(origin: .zero, size: formulaFrame.size), scrollContentSize: formulaFrame.size ) additionalItems.append(.formula(item)) if formulaFrame.minY < topInset { topInset = formulaFrame.minY } if formulaFrame.maxY > height { bottomInset = max(bottomInset, formulaFrame.maxY - height) } } } let _ = topInset let _ = bottomInset items.append(contentsOf: additionalItems) return (textItem, items, textItem.frame.size) }