feat(ios): render inline LaTeX math in completed chat prose (#101388)

This commit is contained in:
Peter Steinberger 2026-07-07 08:46:31 +01:00 committed by GitHub
parent e72dadbb3b
commit 48e77b6abf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 811 additions and 76 deletions

View file

@ -115,6 +115,12 @@ struct SwiftUIRenderSmokeTests {
@Test @MainActor func `display math builds valid and fallback view hierarchies`() {
for typeSize in [DynamicTypeSize.large, .accessibility2] {
let root = VStack {
ChatMarkdownRenderer(
text: #"Inline math \(E = mc^2\) stays inside prose."#,
context: .assistant,
variant: .standard,
font: OpenClawChatTypography.body,
textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: #"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"#,
isComplete: true), textColor: OpenClawChatTheme.assistantText)

View file

@ -0,0 +1,413 @@
import Foundation
import SwiftMath
import SwiftUI
#if os(macOS)
import AppKit
typealias ChatInlineMathPlatformImage = NSImage
#else
import UIKit
typealias ChatInlineMathPlatformImage = UIImage
#endif
struct ChatInlineMathSpan {
let latex: String
let source: String
}
enum ChatInlineMathScanner {
enum Piece: Equatable {
case markdown(String)
case math(latex: String, source: String)
case literal(String)
}
static let maxSpanCount = 16
static let maxSourceBytes = 200
static func pieces(in markdown: String) -> [Piece] {
guard markdown.contains(#"\("#) else { return [.markdown(markdown)] }
var pieces: [Piece] = []
var textStart = markdown.startIndex
var cursor = markdown.startIndex
var spanCount = 0
let codeSpans = self.confirmedCodeSpans(in: markdown)
var codeSpanIndex = 0
while cursor < markdown.endIndex {
if codeSpanIndex < codeSpans.count,
cursor == codeSpans[codeSpanIndex].lowerBound
{
cursor = codeSpans[codeSpanIndex].upperBound
codeSpanIndex += 1
continue
}
guard markdown[cursor...].hasPrefix(#"\("#),
!self.isEscaped(at: cursor, in: markdown)
else {
cursor = markdown.index(after: cursor)
continue
}
if textStart < cursor {
pieces.append(.markdown(String(markdown[textStart..<cursor])))
}
let opener = cursor
let contentStart = markdown.index(cursor, offsetBy: 2)
guard let candidate = self.candidate(
startingAt: contentStart,
in: markdown,
codeSpans: codeSpans)
else {
pieces.append(.literal(String(markdown[opener...])))
return pieces
}
spanCount += 1
let source = String(markdown[opener..<candidate.end])
let latex = String(markdown[contentStart..<candidate.closeStart])
if spanCount <= self.maxSpanCount,
!candidate.containsNewline,
source.utf8.count <= self.maxSourceBytes
{
pieces.append(.math(latex: latex, source: source))
} else {
pieces.append(.literal(source))
}
cursor = candidate.end
textStart = cursor
}
if textStart < markdown.endIndex {
pieces.append(.markdown(String(markdown[textStart...])))
}
return pieces
}
private struct Candidate {
let closeStart: String.Index
let end: String.Index
let containsNewline: Bool
}
private static func candidate(
startingAt start: String.Index,
in markdown: String,
codeSpans: [Range<String.Index>]) -> Candidate?
{
var cursor = start
var codeSpanIndex = self.firstCodeSpan(endingAfter: start, in: codeSpans)
var containsNewline = false
while cursor < markdown.endIndex {
if codeSpanIndex < codeSpans.count,
cursor == codeSpans[codeSpanIndex].lowerBound
{
cursor = codeSpans[codeSpanIndex].upperBound
codeSpanIndex += 1
continue
}
let character = markdown[cursor]
if character == "\n" || character == "\r" {
containsNewline = true
}
if markdown[cursor...].hasPrefix(#"\)"#),
!self.isEscaped(at: cursor, in: markdown)
{
return Candidate(
closeStart: cursor,
end: markdown.index(cursor, offsetBy: 2),
containsNewline: containsNewline)
}
cursor = markdown.index(after: cursor)
}
return nil
}
private struct BacktickRun {
let start: String.Index
let end: String.Index
let length: Int
let canOpen: Bool
}
private static func confirmedCodeSpans(in markdown: String) -> [Range<String.Index>] {
var runs: [BacktickRun] = []
var cursor = markdown.startIndex
while cursor < markdown.endIndex {
guard markdown[cursor] == "`" else {
cursor = markdown.index(after: cursor)
continue
}
let end = self.endOfBacktickRun(at: cursor, in: markdown)
runs.append(BacktickRun(
start: cursor,
end: end,
length: markdown.distance(from: cursor, to: end),
canOpen: !self.isEscaped(at: cursor, in: markdown)))
cursor = end
}
var nextMatchingRun = [Int?](repeating: nil, count: runs.count)
var nextIndexByLength: [Int: Int] = [:]
for index in runs.indices.reversed() {
nextMatchingRun[index] = nextIndexByLength[runs[index].length]
nextIndexByLength[runs[index].length] = index
}
var spans: [Range<String.Index>] = []
var index = 0
while index < runs.count {
guard runs[index].canOpen,
let closeIndex = nextMatchingRun[index]
else {
index += 1
continue
}
spans.append(runs[index].start..<runs[closeIndex].end)
index = closeIndex + 1
}
return spans
}
private static func firstCodeSpan(
endingAfter index: String.Index,
in spans: [Range<String.Index>]) -> Int
{
var lower = 0
var upper = spans.count
while lower < upper {
let middle = lower + (upper - lower) / 2
if spans[middle].upperBound <= index {
lower = middle + 1
} else {
upper = middle
}
}
return lower
}
private static func endOfBacktickRun(at start: String.Index, in markdown: String) -> String.Index {
var end = start
while end < markdown.endIndex, markdown[end] == "`" {
end = markdown.index(after: end)
}
return end
}
private static func isEscaped(at index: String.Index, in markdown: String) -> Bool {
var cursor = index
var slashCount = 0
while cursor > markdown.startIndex {
let previous = markdown.index(before: cursor)
guard markdown[previous] == "\\" else { break }
slashCount += 1
cursor = previous
}
return slashCount.isMultiple(of: 2) == false
}
}
/// Parsed math is stable after its delimiter closes. A bounded cache avoids
/// repeating SwiftMath parsing as later streaming deltas rerender old blocks.
@MainActor
enum ChatMathParseCache {
private enum Result {
case parsed(MTMathList)
case invalid
}
private static var cache: [String: Result] = [:]
private static let capacity = 80
private static let maxNestingDepth = 64
private static let maxCommandCount = 128
private static let unsafeCommands = [#"\color"#, #"\colorbox"#, #"\textcolor"#]
static func mathList(latex: String) -> MTMathList? {
guard !latex.isEmpty else { return nil }
// SwiftMath silently drops unsupported Unicode instead of reporting a
// parse error. Preserve the source through the raw-text fallback.
guard latex.unicodeScalars.allSatisfy(\.isASCII) else { return nil }
// SwiftMath recursively parses groups. Bound hostile nesting before
// entering the dependency so a short expression cannot exhaust stack.
guard self.isWithinParserLimits(latex) else { return nil }
// SwiftMath 1.7.3 traps while typesetting empty color-command bodies.
// Chat owns the surrounding color, so preserve these as raw source.
guard !self.unsafeCommands.contains(where: latex.contains) else { return nil }
if let hit = self.cache[latex] {
if case let .parsed(mathList) = hit {
return mathList
}
return nil
}
let result = MTMathListBuilder.build(fromString: latex)
.map(Result.parsed) ?? .invalid
if self.cache.count >= self.capacity {
self.cache.removeAll(keepingCapacity: true)
}
self.cache[latex] = result
if case let .parsed(mathList) = result {
return mathList
}
return nil
}
private static func isWithinParserLimits(_ latex: String) -> Bool {
var depth = 0
var commandCount = 0
var escaped = false
for character in latex {
if escaped {
escaped = false
continue
}
if character == "\\" {
commandCount += 1
if commandCount > self.maxCommandCount {
return false
}
escaped = true
} else if character == "{" {
depth += 1
if depth > self.maxNestingDepth {
return false
}
} else if character == "}" {
depth = max(0, depth - 1)
}
}
return true
}
}
@MainActor
enum ChatInlineMathImageCache {
struct RenderedImage {
let image: ChatInlineMathPlatformImage
let baselineOffset: CGFloat
}
private struct Key: Hashable {
let latex: String
let fontSize: CGFloat
let colorHash: Int
}
private static var cache: [Key: RenderedImage] = [:]
private static let capacity = 80
private static let maxRenderedPixelArea: CGFloat = 262_144
private static let maxRenderedPixelDimension: CGFloat = 4096
static func image(
latex: String,
fontSize: CGFloat,
textColor: Color,
colorScheme: ColorScheme) -> RenderedImage?
{
guard fontSize.isFinite, fontSize > 0,
let mathList = ChatMathParseCache.mathList(latex: latex)
else { return nil }
let platformColor = self.platformColor(textColor, colorScheme: colorScheme)
let key = Key(
latex: latex,
fontSize: fontSize,
colorHash: self.colorHash(platformColor))
if let cached = self.cache[key] {
return cached
}
let metricsLabel = MTMathUILabel()
metricsLabel.mathList = mathList
metricsLabel.labelMode = .text
metricsLabel.fontSize = fontSize
metricsLabel.textColor = platformColor
let size: CGSize
#if os(macOS)
size = metricsLabel.fittingSize
metricsLabel.frame = CGRect(origin: .zero, size: size)
metricsLabel.layoutSubtreeIfNeeded()
#else
size = metricsLabel.intrinsicContentSize
metricsLabel.frame = CGRect(origin: .zero, size: size)
metricsLabel.layoutIfNeeded()
#endif
guard self.isSafeImageSize(size, scale: self.imageScale) else { return nil }
// MTMathImage allocates immediately in asImage(). Measure with the same
// text-mode typesetter first so hostile input cannot request an empty or
// oversized backing bitmap.
let imageRenderer = MTMathImage(
latex: latex,
fontSize: fontSize,
textColor: platformColor,
labelMode: .text,
textAlignment: .left)
guard let image = imageRenderer.asImage().1 else { return nil }
// Text(Image) places the image bottom on the surrounding baseline.
// SwiftMath exposes its baseline descent through displayList, so lower
// the image by that descent to align the two typographic baselines.
let rendered = RenderedImage(
image: image,
baselineOffset: -(metricsLabel.displayList?.descent ?? 0))
if self.cache.count >= self.capacity {
self.cache.removeAll(keepingCapacity: true)
}
self.cache[key] = rendered
return rendered
}
static func isSafeImageSize(_ size: CGSize, scale: CGFloat) -> Bool {
guard size.width.isFinite, size.height.isFinite, scale.isFinite,
size.width > 0, size.height > 0, scale > 0
else { return false }
let pixelWidth = size.width * scale
let pixelHeight = size.height * scale
return pixelWidth.isFinite && pixelHeight.isFinite &&
pixelWidth <= self.maxRenderedPixelDimension &&
pixelHeight <= self.maxRenderedPixelDimension &&
pixelWidth * pixelHeight <= self.maxRenderedPixelArea
}
private static var imageScale: CGFloat {
#if os(macOS)
NSScreen.main?.backingScaleFactor ?? 2
#else
UIScreen.main.scale
#endif
}
private static func platformColor(_ color: Color, colorScheme: ColorScheme) -> MTColor {
#if os(macOS)
guard let appearance = NSAppearance(
named: colorScheme == .dark ? .darkAqua : .aqua)
else { return NSColor(color) }
var platformColor = NSColor.clear
appearance.performAsCurrentDrawingAppearance {
let resolved = NSColor(color)
platformColor = resolved.usingColorSpace(.deviceRGB) ?? resolved
}
return platformColor
#else
let style: UIUserInterfaceStyle = colorScheme == .dark ? .dark : .light
return UIColor(color).resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
#endif
}
private static func colorHash(_ color: MTColor) -> Int {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
var hasher = Hasher()
hasher.combine(red)
hasher.combine(green)
hasher.combine(blue)
hasher.combine(alpha)
return hasher.finalize()
}
}

View file

@ -77,71 +77,6 @@ struct ChatMathBlockView: View {
}
}
/// Parsed math is stable after its delimiter closes. A bounded cache avoids
/// repeating SwiftMath parsing as later streaming deltas rerender old blocks.
@MainActor
private enum ChatMathParseCache {
private enum Result {
case parsed(MTMathList)
case invalid
}
private static var cache: [String: Result] = [:]
private static let capacity = 80
private static let maxNestingDepth = 64
private static let maxCommandCount = 128
private static let unsafeCommands = [#"\color"#, #"\colorbox"#, #"\textcolor"#]
static func mathList(latex: String) -> MTMathList? {
guard !latex.isEmpty else { return nil }
// SwiftMath silently drops unsupported Unicode instead of reporting a
// parse error. Preserve the source through the raw-text fallback.
guard latex.unicodeScalars.allSatisfy(\.isASCII) else { return nil }
// SwiftMath recursively parses groups. Bound hostile nesting before
// entering the dependency so a short expression cannot exhaust stack.
guard self.isWithinParserLimits(latex) else { return nil }
// SwiftMath 1.7.3 traps while typesetting empty color-command bodies.
// Chat owns the surrounding color, so preserve these as raw source.
guard !self.unsafeCommands.contains(where: latex.contains) else { return nil }
if let hit = self.cache[latex] {
if case let .parsed(mathList) = hit { return mathList }
return nil
}
let result = MTMathListBuilder.build(fromString: latex)
.map(Result.parsed) ?? .invalid
if self.cache.count >= self.capacity {
self.cache.removeAll(keepingCapacity: true)
}
self.cache[latex] = result
if case let .parsed(mathList) = result { return mathList }
return nil
}
private static func isWithinParserLimits(_ latex: String) -> Bool {
var depth = 0
var commandCount = 0
var escaped = false
for character in latex {
if escaped {
escaped = false
continue
}
if character == "\\" {
commandCount += 1
if commandCount > self.maxCommandCount { return false }
escaped = true
} else if character == "{" {
depth += 1
if depth > self.maxNestingDepth { return false }
} else if character == "}" {
depth = max(0, depth - 1)
}
}
return true
}
}
#if os(macOS)
@MainActor
private struct ChatMathPlatformView: NSViewRepresentable {

View file

@ -14,6 +14,14 @@ struct ChatMarkdownRenderer: View {
case assistant
}
struct InlineMathTypography {
static let body = Self(size: OpenClawChatTypography.bodySize, relativeTo: .body)
static let callout = Self(size: 16, relativeTo: .callout)
let size: CGFloat
let relativeTo: Font.TextStyle
}
let snapshot: ChatMarkdownRenderSnapshot
let context: Context
let variant: ChatMarkdownVariant
@ -21,12 +29,16 @@ struct ChatMarkdownRenderer: View {
let textColor: Color
var reveal: ChatMarkdownProseReveal?
@ScaledMetric private var inlineMathFontSize: CGFloat
@Environment(\.colorScheme) private var colorScheme
init(
text: String,
context: Context,
variant: ChatMarkdownVariant,
font: Font,
textColor: Color,
inlineMathTypography: InlineMathTypography = .body,
isComplete: Bool = true)
{
self.init(
@ -34,7 +46,8 @@ struct ChatMarkdownRenderer: View {
context: context,
variant: variant,
font: font,
textColor: textColor)
textColor: textColor,
inlineMathTypography: inlineMathTypography)
}
init(
@ -43,6 +56,7 @@ struct ChatMarkdownRenderer: View {
variant: ChatMarkdownVariant,
font: Font,
textColor: Color,
inlineMathTypography: InlineMathTypography = .body,
reveal: ChatMarkdownProseReveal? = nil)
{
self.snapshot = snapshot
@ -51,6 +65,9 @@ struct ChatMarkdownRenderer: View {
self.font = font
self.textColor = textColor
self.reveal = reveal
self._inlineMathFontSize = ScaledMetric(
wrappedValue: inlineMathTypography.size,
relativeTo: inlineMathTypography.relativeTo)
}
var body: some View {
@ -75,6 +92,7 @@ struct ChatMarkdownRenderer: View {
.tint(self.linkColor)
.textSelection(.enabled)
.lineSpacing(self.variant == .compact ? 2 : 4)
.modifier(ChatInlineMathAccessibilityModifier(label: prose.inlineAccessibilityText))
case let .code(code):
ChatCodeBlockView(block: code)
case let .math(math):
@ -86,7 +104,10 @@ struct ChatMarkdownRenderer: View {
private func proseText(_ prose: ChatMarkdownProse, index: Int) -> SwiftUI.Text {
guard let reveal = self.reveal, reveal.blockIndex == index else {
return SwiftUI.Text(prose.attributed)
return prose.renderedText(
fontSize: self.inlineMathFontSize,
textColor: self.textColor,
colorScheme: self.colorScheme)
}
return prose.revealedText(
frame: revealedOpacities(state: reveal.state, now: reveal.now),
@ -104,6 +125,7 @@ struct ChatMarkdownProseReveal {
let now: TimeInterval
}
@MainActor
struct ChatMarkdownRenderSnapshot {
let blocks: [ChatMarkdownRenderedBlock]
let images: [ChatMarkdownPreprocessor.InlineImage]
@ -115,7 +137,10 @@ struct ChatMarkdownRenderSnapshot {
isComplete: isComplete).map { block in
switch block {
case let .prose(markdown):
.prose(ChatMarkdownProse(markdown: markdown, preparesReveal: preparesReveal))
.prose(ChatMarkdownProse(
markdown: markdown,
isComplete: isComplete,
preparesReveal: preparesReveal))
case let .code(code):
.code(code)
case let .math(math):
@ -129,7 +154,9 @@ struct ChatMarkdownRenderSnapshot {
var lastProseIndex: Int? {
self.blocks.lastIndex {
if case .prose = $0 { return true }
if case .prose = $0 {
return true
}
return false
}
}
@ -142,6 +169,7 @@ enum ChatMarkdownRenderedBlock {
case table(ChatMarkdownTable)
}
@MainActor
struct ChatMarkdownProse {
struct TailPiece {
let attributed: AttributedString
@ -152,14 +180,23 @@ struct ChatMarkdownProse {
let plainText: String
let prefix: AttributedString
let tail: [TailPiece]
let inlineContent: [InlineContent]?
init(markdown: String, preparesReveal: Bool) {
let displayMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible)
let attributed = (try? AttributedString(markdown: displayMarkdown, options: options))
?? AttributedString(displayMarkdown)
enum InlineContent {
case text(AttributedString)
case math(ChatInlineMathSpan)
}
init(markdown: String, isComplete: Bool, preparesReveal: Bool) {
// preparesReveal belongs only to the streaming snapshot. Keep that path
// on its single AttributedString build; completed snapshots alone split
// prose for inline image interpolation.
let inlineContent = isComplete && !preparesReveal
? Self.makeInlineContent(markdown: markdown)
: nil
let attributed = inlineContent == nil
? Self.parseMarkdown(markdown)
: AttributedString()
let plainText = preparesReveal ? String(attributed.characters) : ""
let wordRanges = preparesReveal
? Array(chatStreamingWordRanges(in: plainText).suffix(24))
@ -168,6 +205,7 @@ struct ChatMarkdownProse {
self.attributed = attributed
self.plainText = plainText
self.inlineContent = inlineContent
if preparesReveal {
self.prefix = Self.slice(attributed, characterRange: 0..<tailStart)
self.tail = Self.tailPieces(
@ -181,6 +219,54 @@ struct ChatMarkdownProse {
}
}
var inlineMathLatex: [String] {
self.inlineContent?.compactMap { content in
if case let .math(span) = content {
return span.latex
}
return nil
} ?? []
}
var inlineAccessibilityText: String? {
guard let inlineContent else { return nil }
return inlineContent.reduce(into: "") { text, content in
switch content {
case let .text(attributed):
text += String(attributed.characters)
case let .math(span):
text += span.latex
}
}
}
func renderedText(
fontSize: CGFloat,
textColor: Color,
colorScheme: ColorScheme) -> SwiftUI.Text
{
guard let inlineContent else { return SwiftUI.Text(self.attributed) }
return inlineContent.reduce(SwiftUI.Text("")) { text, content in
switch content {
case let .text(attributed):
return text + SwiftUI.Text(attributed)
case let .math(span):
guard let rendered = ChatInlineMathImageCache.image(
latex: span.latex,
fontSize: fontSize,
textColor: textColor,
colorScheme: colorScheme)
else { return text + SwiftUI.Text(span.source) }
#if os(macOS)
let image = Image(nsImage: rendered.image)
#else
let image = Image(uiImage: rendered.image)
#endif
return text + SwiftUI.Text(image).baselineOffset(rendered.baselineOffset)
}
}
}
func revealedText(frame: ChatStreamingRevealFrame, textColor: Color) -> SwiftUI.Text {
self.tail.reduce(SwiftUI.Text(self.prefix)) { text, piece in
var attributed = piece.attributed
@ -193,6 +279,118 @@ struct ChatMarkdownProse {
}
}
private static func makeInlineContent(markdown: String) -> [InlineContent]? {
let pieces = ChatInlineMathScanner.pieces(in: markdown)
guard pieces.contains(where: { piece in
if case .markdown = piece {
return false
}
return true
}) else { return nil }
var substitutedMarkdown = ""
var replacements: [InlineReplacement] = []
var markerValue: UInt32 = 0xE000
var occupiedMarkerValues = Set(markdown.unicodeScalars.map(\.value))
for piece in pieces {
switch piece {
case let .markdown(source):
substitutedMarkdown += source
case let .literal(source):
substitutedMarkdown += Self.markdownEscapedLiteral(source)
case let .math(latex, source):
guard ChatMathParseCache.mathList(latex: latex) != nil else {
substitutedMarkdown += Self.markdownEscapedLiteral(source)
continue
}
let marker = Self.nextMarker(
startingAt: &markerValue,
occupiedValues: &occupiedMarkerValues)
substitutedMarkdown.append(marker)
replacements.append(InlineReplacement(
marker: marker,
span: ChatInlineMathSpan(latex: latex, source: source)))
}
}
let attributed = self.parseMarkdown(substitutedMarkdown)
var content: [InlineContent] = []
var cursor = attributed.startIndex
for replacement in replacements {
guard let markerIndex = attributed.characters[cursor...]
.firstIndex(of: replacement.marker)
else { return nil }
if cursor < markerIndex {
content.append(.text(AttributedString(attributed[cursor..<markerIndex])))
}
let markerEnd = attributed.characters.index(after: markerIndex)
let attributes = attributed[markerIndex..<markerEnd].runs.first?.attributes
if attributes?.link != nil {
// Text(Image) cannot carry an AttributedString link. Keep math
// used as a link label literal so the destination remains
// visible and tappable instead of silently dropping the link.
var literal = AttributedString(replacement.span.source)
if let attributes {
literal.mergeAttributes(attributes)
}
content.append(.text(literal))
} else {
content.append(.math(replacement.span))
}
cursor = markerEnd
}
if cursor < attributed.endIndex {
content.append(.text(AttributedString(attributed[cursor...])))
}
return content
}
private struct InlineReplacement {
let marker: Character
let span: ChatInlineMathSpan
}
private static func nextMarker(
startingAt value: inout UInt32,
occupiedValues: inout Set<UInt32>) -> Character
{
while occupiedValues.contains(value) {
value += 1
}
guard let scalar = UnicodeScalar(value) else {
preconditionFailure("inline math marker range exhausted")
}
occupiedValues.insert(value)
let marker = Character(String(scalar))
value += 1
return marker
}
private static func markdownEscapedLiteral(_ source: String) -> String {
source.reduce(into: "") { escaped, character in
if character.unicodeScalars.count == 1,
let scalar = character.unicodeScalars.first,
scalar.isASCII,
(33...47).contains(scalar.value) ||
(58...64).contains(scalar.value) ||
(91...96).contains(scalar.value) ||
(123...126).contains(scalar.value)
{
escaped.append("\\")
}
escaped.append(character)
}
}
private static func parseMarkdown(_ markdown: String) -> AttributedString {
let displayMarkdown = ChatMarkdownDisplayPreprocessor.preserveChatSoftBreaks(in: markdown)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible)
return (try? AttributedString(markdown: displayMarkdown, options: options))
?? AttributedString(displayMarkdown)
}
private static func tailPieces(
attributed: AttributedString,
textLength: Int,
@ -235,6 +433,18 @@ struct ChatMarkdownProse {
}
}
private struct ChatInlineMathAccessibilityModifier: ViewModifier {
let label: String?
func body(content: Content) -> some View {
if let label {
content.accessibilityLabel(Text(label))
} else {
content
}
}
}
/// Fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter`
/// before this runs, so prose only needs chat-style soft-break preservation.
enum ChatMarkdownDisplayPreprocessor {

View file

@ -920,12 +920,16 @@ private struct ChatAssistantTextBody: View {
let font = segment.kind == .thinking
? OpenClawChatTypography.callout.italic()
: OpenClawChatTypography.body
let inlineMathTypography: ChatMarkdownRenderer.InlineMathTypography = segment.kind == .thinking
? .callout
: .body
ChatMarkdownRenderer(
text: segment.text,
context: .assistant,
variant: self.markdownVariant,
font: font,
textColor: OpenClawChatTheme.assistantText,
inlineMathTypography: inlineMathTypography,
isComplete: self.isComplete)
}
}
@ -1003,6 +1007,9 @@ private struct ChatStreamingAssistantTextBody: View {
let font = segment.kind == .thinking
? OpenClawChatTypography.callout.italic()
: OpenClawChatTypography.body
let inlineMathTypography: ChatMarkdownRenderer.InlineMathTypography = segment.kind == .thinking
? .callout
: .body
let reveal = self.reveal(
segmentIndex: entry.offset,
now: now)
@ -1012,6 +1019,7 @@ private struct ChatStreamingAssistantTextBody: View {
variant: self.markdownVariant,
font: font,
textColor: OpenClawChatTheme.assistantText,
inlineMathTypography: inlineMathTypography,
reveal: reveal)
}
}
@ -1060,6 +1068,7 @@ private struct ChatStreamingAssistantTextBody: View {
return deadline
}
@MainActor
private struct Snapshot {
struct Segment {
let kind: AssistantTextSegment.Kind

View file

@ -0,0 +1,161 @@
import CoreGraphics
import Foundation
import Testing
@testable import OpenClawChatUI
struct ChatInlineMathTests {
@Test func `basic span splits from prose`() {
#expect(ChatInlineMathScanner.pieces(in: #"before \(x + 1\) after"#) == [
.markdown("before "),
.math(latex: "x + 1", source: #"\(x + 1\)"#),
.markdown(" after"),
])
}
@Test func `multiple spans split independently`() {
let pieces = ChatInlineMathScanner.pieces(in: #"\(x\), then \(y\)"#)
#expect(pieces.compactMap(\.mathLatex) == ["x", "y"])
}
@Test func `span inside backticks stays markdown`() {
let source = #"use `\(not math\)` here"#
#expect(ChatInlineMathScanner.pieces(in: source) == [.markdown(source)])
}
@Test func `unmatched backtick does not hide later math`() {
#expect(ChatInlineMathScanner.pieces(in: #"stray ` before \(x\)"#) == [
.markdown("stray ` before "),
.math(latex: "x", source: #"\(x\)"#),
])
}
@Test func `escaped delimiters stay markdown`() {
let source = #"escaped \\(x\\)"#
#expect(ChatInlineMathScanner.pieces(in: source) == [.markdown(source)])
}
@Test func `unclosed opener stays literal`() {
#expect(ChatInlineMathScanner.pieces(in: #"before \(x"#) == [
.markdown("before "),
.literal(#"\(x"#),
])
}
@Test func `newline span stays literal`() {
let source = "before \\(x\ny\\) after"
#expect(ChatInlineMathScanner.pieces(in: source) == [
.markdown("before "),
.literal("\\(x\ny\\)"),
.markdown(" after"),
])
}
@Test func `only first sixteen spans are candidates`() {
let source = (0..<17).map { #"\(x\#($0)\)"# }.joined(separator: " ")
let pieces = ChatInlineMathScanner.pieces(in: source)
#expect(pieces.compactMap(\.mathLatex).count == ChatInlineMathScanner.maxSpanCount)
#expect(pieces.contains(.literal(#"\(x16\)"#)))
}
@Test @MainActor func `markdown after excess spans remains parsed`() throws {
let spans = (0..<17).map { #"\(x\#($0)\)"# }.joined(separator: " ")
let snapshot = ChatMarkdownRenderSnapshot(
text: spans + " [docs](https://example.com)",
isComplete: true)
let prose = try self.prose(in: snapshot)
let hasLink = prose.inlineContent?.contains { content in
guard case let .text(attributed) = content else { return false }
return attributed.runs.contains { $0.link != nil }
}
#expect(hasLink == true)
}
@Test @MainActor func `math link label stays literal and linked`() throws {
let snapshot = ChatMarkdownRenderSnapshot(
text: #"[\(x\)](https://example.com)"#,
isComplete: true)
let prose = try self.prose(in: snapshot)
let linkedText = prose.inlineContent?.compactMap { content -> AttributedString? in
guard case let .text(attributed) = content,
attributed.runs.contains(where: { $0.link != nil })
else { return nil }
return attributed
}.first
#expect(prose.inlineMathLatex.isEmpty)
#expect(try String(#require(linkedText).characters) == #"\(x\)"#)
}
@Test func `two hundred byte cap is inclusive`() {
let acceptedLatex = String(repeating: "x", count: ChatInlineMathScanner.maxSourceBytes - 4)
let rejectedLatex = acceptedLatex + "x"
#expect(ChatInlineMathScanner.pieces(in: "\\(\(acceptedLatex)\\)").compactMap(\.mathLatex) == [
acceptedLatex,
])
#expect(ChatInlineMathScanner.pieces(in: "\\(\(rejectedLatex)\\)") == [
.literal("\\(\(rejectedLatex)\\)"),
])
}
@Test @MainActor func `only completed prose prepares inline math`() throws {
let complete = ChatMarkdownRenderSnapshot(text: #"value \(x^2\)"#, isComplete: true)
let streaming = ChatMarkdownRenderSnapshot(
text: #"value \(x^2\)"#,
isComplete: false,
preparesReveal: true)
#expect(try self.prose(in: complete).inlineMathLatex == ["x^2"])
#expect(try self.prose(in: streaming).inlineMathLatex.isEmpty)
}
@Test @MainActor func `display math guards also reject inline math`() throws {
for source in [#"value \(α + β\)"#, #"value \(x\color{red}\)"#] {
let snapshot = ChatMarkdownRenderSnapshot(text: source, isComplete: true)
#expect(try self.prose(in: snapshot).inlineMathLatex.isEmpty)
}
}
@Test @MainActor func `markdown attributes can cross inline math`() throws {
let snapshot = ChatMarkdownRenderSnapshot(text: #"**value \(x\)**"#, isComplete: true)
let prose = try self.prose(in: snapshot)
#expect(prose.inlineMathLatex == ["x"])
#expect(prose.inlineAccessibilityText == "value x")
}
@Test @MainActor func `image sizing rejects unsafe bitmap bounds`() {
#expect(!ChatInlineMathImageCache.isSafeImageSize(.zero, scale: 2))
#expect(!ChatInlineMathImageCache.isSafeImageSize(
CGSize(width: 4097, height: 1),
scale: 1))
#expect(!ChatInlineMathImageCache.isSafeImageSize(
CGSize(width: 1024, height: 1024),
scale: 1))
#expect(ChatInlineMathImageCache.isSafeImageSize(
CGSize(width: 256, height: 256),
scale: 2))
}
@MainActor
private func prose(in snapshot: ChatMarkdownRenderSnapshot) throws -> ChatMarkdownProse {
guard case let .prose(prose) = try #require(snapshot.blocks.first) else {
Issue.record("expected prose block")
throw InlineMathTestError.expectedProse
}
return prose
}
}
private enum InlineMathTestError: Error {
case expectedProse
}
extension ChatInlineMathScanner.Piece {
fileprivate var mathLatex: String? {
if case let .math(latex, _) = self {
return latex
}
return nil
}
}

View file

@ -16,6 +16,7 @@ const iosSourceRoots = [
const sharedSwiftFiles = [
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatCodeHighlighter.swift",
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatInlineMath.swift",
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift",
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift",
"../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift",