From 0a3030deed50cb6bfe3f455faf6d0f9b892dbf2b Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 20 May 2026 00:33:18 +0800 Subject: [PATCH] InstantPage V2: formula display Design, plan, and implementation. Adds .formula item case + InstantPageV2FormulaView, bumps InstantPageMathAttachment fields to public, routes block and inline formulas through .formula items, aligns the scroll view with V1 (alwaysBounceHorizontal = false), and reports narrow block formula natural width so the fitToWidth bubble shrinks. --- .../Sources/InstantPageMath.swift | 28 ++--- .../Sources/InstantPageRenderer.swift | 56 ++++++++++ .../Sources/InstantPageV2Layout.swift | 103 ++++++++++++++++-- 3 files changed, 161 insertions(+), 26 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageMath.swift b/submodules/InstantPageUI/Sources/InstantPageMath.swift index 1c77532019..80acbe8624 100644 --- a/submodules/InstantPageUI/Sources/InstantPageMath.swift +++ b/submodules/InstantPageUI/Sources/InstantPageMath.swift @@ -2,27 +2,27 @@ import Foundation import UIKit import SwiftMath -enum InstantPageMathMode { +public enum InstantPageMathMode { case inline case block } -struct InstantPageMathRenderResult { - let image: UIImage - let size: CGSize - let width: CGFloat - let ascent: CGFloat - let descent: CGFloat +public struct InstantPageMathRenderResult { + public let image: UIImage + public let size: CGSize + public let width: CGFloat + public let ascent: CGFloat + public let descent: CGFloat } -final class InstantPageMathAttachment: NSObject { - let latex: String - let fontSize: CGFloat - let textColor: UIColor - let mode: InstantPageMathMode - let rendered: InstantPageMathRenderResult +public final class InstantPageMathAttachment: NSObject { + public let latex: String + public let fontSize: CGFloat + public let textColor: UIColor + public let mode: InstantPageMathMode + public let rendered: InstantPageMathRenderResult - init(latex: String, fontSize: CGFloat, textColor: UIColor, mode: InstantPageMathMode, rendered: InstantPageMathRenderResult) { + public init(latex: String, fontSize: CGFloat, textColor: UIColor, mode: InstantPageMathMode, rendered: InstantPageMathRenderResult) { self.latex = latex self.fontSize = fontSize self.textColor = textColor diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index 5392fb6d7e..5c0f879eb5 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -79,6 +79,8 @@ public final class InstantPageV2View: UIView { return view case let .table(table): return InstantPageV2TableView(item: table, theme: theme) + case let .formula(formula): + return InstantPageV2FormulaView(item: formula) } } } @@ -723,3 +725,57 @@ private func collectSelectableTextItems( } } } + +// MARK: - Formula view + +/// Renders both block (`InstantPageBlock.formula(latex:)`) and inline (`InstantPageFormulaAttribute`) +/// math, sourcing the pre-rendered Retina image from `InstantPageMathAttachment.rendered`. +/// +/// For block formulas wider than the bubble's available width, the layout sets +/// `isScrollable = true`; this view then wraps the image in a horizontal `UIScrollView` +/// matching V1's `InstantPageScrollableNode` (no bounce on non-overflowing content, +/// scroll indicator hidden — appropriate for content embedded inside a chat bubble). +final class InstantPageV2FormulaView: UIView, InstantPageItemView { + let item: InstantPageV2FormulaItem + var itemFrame: CGRect { return self.item.frame } + + init(item: InstantPageV2FormulaItem) { + self.item = item + super.init(frame: item.frame) + self.backgroundColor = .clear + self.isOpaque = false + self.clipsToBounds = true + + let imageLayer = CALayer() + imageLayer.contents = item.attachment.rendered.image.cgImage + imageLayer.contentsScale = item.attachment.rendered.image.scale + imageLayer.contentsGravity = .resizeAspect + imageLayer.frame = item.imageFrame + + if item.isScrollable { + let scroll = UIScrollView(frame: self.bounds) + scroll.autoresizingMask = [.flexibleWidth, .flexibleHeight] + scroll.contentSize = item.scrollContentSize + scroll.showsHorizontalScrollIndicator = false + scroll.showsVerticalScrollIndicator = false + scroll.alwaysBounceHorizontal = false + scroll.alwaysBounceVertical = false + scroll.contentInsetAdjustmentBehavior = .never + self.addSubview(scroll) + + // Layers don't autoresize with their superview; host the image layer inside a UIView + // so the scroll view's content-size growth keeps the image positioned correctly. + let imageHost = UIView(frame: CGRect(origin: .zero, size: item.scrollContentSize)) + imageHost.layer.addSublayer(imageLayer) + scroll.addSubview(imageHost) + } else { + // Inline and centered-block formulas don't accept touches; the bubble's link/long-press + // recognizers run against the underlying text view instead. + self.isUserInteractionEnabled = false + self.layer.addSublayer(imageLayer) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 7fde5dc7d2..9f2c56ee21 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -33,6 +33,7 @@ public enum InstantPageV2LaidOutItem { case details(InstantPageV2DetailsItem) case table(InstantPageV2TableItem) case anchor(InstantPageV2AnchorItem) + case formula(InstantPageV2FormulaItem) public var frame: CGRect { switch self { @@ -46,6 +47,7 @@ public enum InstantPageV2LaidOutItem { case let .details(item): return item.frame case let .table(item): return item.frame case let .anchor(item): return item.frame + case let .formula(item): return item.frame } } @@ -64,6 +66,7 @@ public enum InstantPageV2LaidOutItem { 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 .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item) } } } @@ -115,6 +118,14 @@ public struct InstantPageV2ShapeItem { 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 @@ -126,7 +137,6 @@ public enum InstantPageV2MediaPlaceholderKind { case channelBanner case map case relatedArticles - case formula } public struct InstantPageV2MediaPlaceholderItem { @@ -495,12 +505,10 @@ private func layoutBlock( horizontalInset: horizontalInset, context: &context) case let .formula(latex): - let _ = latex - return layoutMediaWithCaption(kind: .formula, - naturalSize: CGSize(width: boundingWidth, height: 40.0), - caption: InstantPageCaption(text: .empty, credit: .empty), - isCover: false, cornerRadius: 0.0, boundingWidth: boundingWidth, - horizontalInset: horizontalInset, context: &context) + return layoutFormulaBlock(latex: latex, + boundingWidth: boundingWidth, + horizontalInset: horizontalInset, + context: &context) case let .details(title, blocks, expanded): return layoutDetails(title: title, blocks: blocks, defaultExpanded: expanded, @@ -518,6 +526,73 @@ private func layoutBlock( } } +// 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, + 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, + 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( @@ -1978,7 +2053,9 @@ private func attributedStringForPreformattedText(_ text: RichText, language: Str // V0 difference from V1: // * Inline image runs produce `.mediaPlaceholder(kind: .image, frame:, cornerRadius: 0)` items // instead of `InstantPageImageItem`. -// * Inline formula runs produce `.mediaPlaceholder(kind: .formula, frame:, cornerRadius: 0)` items. +// * 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. @@ -2341,12 +2418,14 @@ func layoutTextItem( } for formulaItem in line.formulaItems { let formulaFrame = formulaItem.frame.offsetBy(dx: lineFrame.minX + effectiveOffset.x, dy: effectiveOffset.y) - let placeholder = InstantPageV2MediaPlaceholderItem( + let item = InstantPageV2FormulaItem( frame: formulaFrame, - kind: .formula, - cornerRadius: 0.0 + attachment: formulaItem.attachment, + isScrollable: false, + imageFrame: CGRect(origin: .zero, size: formulaFrame.size), + scrollContentSize: formulaFrame.size ) - additionalItems.append(.mediaPlaceholder(placeholder)) + additionalItems.append(.formula(item)) if formulaFrame.minY < topInset { topInset = formulaFrame.minY } if formulaFrame.maxY > height { bottomInset = max(bottomInset, formulaFrame.maxY - height) } }