feat(ios): render LaTeX display math in chat via SwiftMath (#100829)

This commit is contained in:
Peter Steinberger
2026-07-06 12:14:38 +01:00
committed by GitHub
parent 827402243d
commit 2a741bebb5
9 changed files with 505 additions and 22 deletions

View File

@@ -0,0 +1,26 @@
SwiftMath
MIT License
Copyright (c) 2023 Computer Inspirations
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SwiftMath bundles math fonts licensed separately under the GUST Font License
and the SIL Open Font License.

View File

@@ -3,6 +3,7 @@ import SwiftUI
import Testing
import UIKit
@testable import OpenClaw
@testable import OpenClawChatUI
struct SwiftUIRenderSmokeTests {
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
@@ -111,6 +112,34 @@ struct SwiftUIRenderSmokeTests {
}
}
@Test @MainActor func `display math builds valid and fallback view hierarchies`() {
for typeSize in [DynamicTypeSize.large, .accessibility2] {
let root = VStack {
ChatMathBlockView(block: ChatMathBlock(
latex: #"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"#,
isComplete: true), textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: #"\notARealCommand{"#,
isComplete: true), textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: "α + β = γ",
isComplete: true), textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: String(repeating: "{", count: 65) + "x",
isComplete: true), textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: String(repeating: #"\bar"#, count: 129) + "x",
isComplete: true), textColor: OpenClawChatTheme.assistantText)
ChatMathBlockView(block: ChatMathBlock(
latex: #"x\textcolor{#fff}{}"#,
isComplete: true), textColor: OpenClawChatTheme.assistantText)
}
.environment(\.dynamicTypeSize, typeSize)
_ = Self.host(root, size: CGSize(width: 393, height: 240))
}
}
@Test @MainActor func `root tabs builds device orientation shell matrix`() {
for scenario in Self.rootTabsShellScenarios() {
let appModel = NodeAppModel()

View File

@@ -126,6 +126,15 @@
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mgriebling/SwiftMath",
"state" : {
"revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
"version" : "1.7.3"
}
}
],
"version" : 3

View File

@@ -19,6 +19,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"),
.package(url: "https://github.com/mgriebling/SwiftMath", exact: "1.7.3"),
.package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.8.0"),
],
targets: [
@@ -46,6 +47,7 @@ let package = Package(
dependencies: [
"OpenClawKit",
.product(name: "Markdown", package: "swift-markdown"),
.product(name: "SwiftMath", package: "SwiftMath"),
],
path: "Sources/OpenClawChatUI",
swiftSettings: [

View File

@@ -2,10 +2,11 @@ import Foundation
import Markdown
/// One renderable block of a chat message. Prose stays on the
/// AttributedString pipeline; fenced code and GFM tables get dedicated views.
/// AttributedString pipeline; fenced code, display math, and GFM tables get dedicated views.
enum ChatMarkdownBlock: Equatable {
case prose(String)
case code(ChatCodeBlock)
case math(ChatMathBlock)
case table(ChatMarkdownTable)
}
@@ -17,6 +18,12 @@ struct ChatCodeBlock: Equatable {
let isComplete: Bool
}
struct ChatMathBlock: Equatable {
let latex: String
/// True when the delimiter was closed or the message finished streaming.
let isComplete: Bool
}
struct ChatMarkdownTable: Equatable {
enum ColumnAlignment: Equatable {
case leading
@@ -50,45 +57,45 @@ enum ChatMarkdownBlockSyntax {
}
enum ChatMarkdownBlockSegmenter {
static let maxMathBytes = 5000
static let maxTableBytes = 20000
static let maxTableRows = 100
static let maxTableColumns = 12
static let maxTableCells = 600
/// Extracts only top-level fenced code and GFM tables. The parser owns
/// Extracts only top-level fenced code, display math, and GFM tables. The parser owns
/// CommonMark container and reference semantics; nested blocks stay in the
/// surrounding prose range unchanged.
static func segments(markdown: String, isComplete: Bool) -> [ChatMarkdownBlock] {
let source = SourceBuffer(markdown)
let document = Document(parsing: source.markdown)
var blocks: [ChatMarkdownBlock] = []
var proseStart = 0
func appendProse(until end: Int) {
guard proseStart < end else { return }
blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..<end])))
}
let mathResult = self.mathExtractions(
source: source,
document: document,
isComplete: isComplete)
var extractions = mathResult.extractions
for child in document.children {
guard let lineRange = source.lineRange(for: child.range), lineRange.lowerBound >= proseStart else {
guard let lineRange = source.lineRange(for: child.range) else { continue }
if mathResult.protectedRanges.contains(where: { $0.contains(lineRange.lowerBound) }) {
continue
}
if let code = child as? Markdown.CodeBlock,
let opener = FenceOpener.parse(source.lines[lineRange.lowerBound])
{
appendProse(until: lineRange.lowerBound)
let language = code.language?
.split(whereSeparator: \.isWhitespace)
.first
.map { $0.lowercased() }
let closed = lineRange.count > 1
&& opener.isClose(source.lines[lineRange.index(before: lineRange.endIndex)])
blocks.append(.code(ChatCodeBlock(
language: language,
code: self.dropStructuralCodeNewline(code.code),
isComplete: closed || isComplete)))
proseStart = lineRange.upperBound
extractions.append(Extraction(
lineRange: lineRange,
block: .code(ChatCodeBlock(
language: language,
code: self.dropStructuralCodeNewline(code.code),
isComplete: closed || isComplete))))
continue
}
@@ -105,12 +112,31 @@ enum ChatMarkdownBlockSegmenter {
{
continue
}
appendProse(until: tableRange.lowerBound)
blocks.append(.table(rendered))
proseStart = tableRange.upperBound
extractions.append(Extraction(lineRange: tableRange, block: .table(rendered)))
}
}
extractions.sort { left, right in
if left.lineRange.lowerBound != right.lineRange.lowerBound {
return left.lineRange.lowerBound < right.lineRange.lowerBound
}
return left.lineRange.upperBound > right.lineRange.upperBound
}
var blocks: [ChatMarkdownBlock] = []
var proseStart = 0
func appendProse(until end: Int) {
guard proseStart < end else { return }
blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..<end])))
}
for extraction in extractions where extraction.lineRange.lowerBound >= proseStart {
appendProse(until: extraction.lineRange.lowerBound)
blocks.append(extraction.block)
proseStart = extraction.lineRange.upperBound
}
appendProse(until: source.lines.count)
if blocks.count > 1, self.containsReferenceLink(document, source: source) {
return self.proseOnly(source.lines)
@@ -118,6 +144,89 @@ enum ChatMarkdownBlockSegmenter {
return blocks
}
private static func mathExtractions(
source: SourceBuffer,
document: Document,
isComplete: Bool) -> MathExtractionResult
{
guard source.markdown.contains("$$") || source.markdown.contains(#"\["#) else {
return MathExtractionResult(extractions: [], protectedRanges: [])
}
var topLevelParagraphLines = Set<Int>()
var inlineCodeLines = Set<Int>()
func collectInlineCodeLines(from markup: any Markup) {
if markup is Markdown.InlineCode, let lineRange = source.lineRange(for: markup.range) {
inlineCodeLines.formUnion(lineRange)
}
for child in markup.children {
collectInlineCodeLines(from: child)
}
}
for child in document.children where child is Markdown.Paragraph {
if let lineRange = source.lineRange(for: child.range) {
topLevelParagraphLines.formUnion(lineRange)
}
collectInlineCodeLines(from: child)
}
var extractions: [Extraction] = []
var protectedRanges: [Range<Int>] = []
var lineIndex = 0
while lineIndex < source.lines.count {
guard topLevelParagraphLines.contains(lineIndex),
!inlineCodeLines.contains(lineIndex),
let opener = MathDelimiter.parse(source.lines[lineIndex])
else {
lineIndex += 1
continue
}
if let sameLineLatex = opener.sameLineLatex {
let lineRange = lineIndex..<(lineIndex + 1)
if sameLineLatex.utf8.count <= self.maxMathBytes {
extractions.append(Extraction(
lineRange: lineRange,
block: .math(ChatMathBlock(latex: sameLineLatex, isComplete: true))))
} else {
protectedRanges.append(lineRange)
}
lineIndex += 1
continue
}
let contentStart = lineIndex + 1
var closeIndex = contentStart
while closeIndex < source.lines.count,
!opener.isClose(source.lines[closeIndex])
{
closeIndex += 1
}
let closed = closeIndex < source.lines.count
guard closed || isComplete else {
// The first unmatched opener owns the remaining stream. Stop
// here so later opener-looking lines do not trigger rescans.
protectedRanges.append(lineIndex..<source.lines.count)
return MathExtractionResult(extractions: extractions, protectedRanges: protectedRanges)
}
let contentEnd = closed ? closeIndex : source.lines.count
let lineRange = lineIndex..<(closed ? closeIndex + 1 : source.lines.count)
let latex = source.lines[contentStart..<contentEnd]
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
if latex.utf8.count <= self.maxMathBytes {
extractions.append(Extraction(
lineRange: lineRange,
block: .math(ChatMathBlock(latex: latex, isComplete: closed || isComplete))))
} else {
protectedRanges.append(lineRange)
}
lineIndex = lineRange.upperBound
}
return MathExtractionResult(extractions: extractions, protectedRanges: protectedRanges)
}
private static func proseOnly(_ lines: [String]) -> [ChatMarkdownBlock] {
// Boundary blank lines only separate extracted blocks; the rendered
// VStack provides that spacing. Interior blanks remain paragraphs.
@@ -150,6 +259,52 @@ enum ChatMarkdownBlockSegmenter {
code.hasSuffix("\n") ? String(code.dropLast()) : code
}
private struct Extraction {
let lineRange: Range<Int>
let block: ChatMarkdownBlock
}
private struct MathExtractionResult {
let extractions: [Extraction]
/// Rejected math stays prose and owns any block-looking syntax inside its span.
let protectedRanges: [Range<Int>]
}
private struct MathDelimiter {
let close: String
let sameLineLatex: String?
static func parse(_ line: String) -> MathDelimiter? {
let (indent, afterIndent) = FenceOpener.leadingSpaces(of: line)
guard indent <= 3, afterIndent < line.endIndex else { return nil }
let suffix = line[afterIndent...]
let pair: (open: String, close: String)
if suffix.hasPrefix("$$") {
pair = ("$$", "$$")
} else if suffix.hasPrefix(#"\["#) {
pair = (#"\["#, #"\]"#)
} else {
return nil
}
let contentStart = suffix.index(suffix.startIndex, offsetBy: pair.open.count)
let remainder = suffix[contentStart...]
if remainder.trimmingCharacters(in: .whitespaces).isEmpty {
return MathDelimiter(close: pair.close, sameLineLatex: nil)
}
guard let closeRange = remainder.range(of: pair.close, options: .backwards),
remainder[closeRange.upperBound...].trimmingCharacters(in: .whitespaces).isEmpty
else { return nil }
let latex = remainder[..<closeRange.lowerBound]
.trimmingCharacters(in: .whitespacesAndNewlines)
return MathDelimiter(close: pair.close, sameLineLatex: latex)
}
func isClose(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespaces) == self.close
}
}
private static func table(
_ table: Markdown.Table,
source: SourceBuffer,
@@ -324,7 +479,7 @@ enum ChatMarkdownBlockSegmenter {
return count >= self.count && line[cursor...].allSatisfy(\.isWhitespace)
}
private static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) {
fileprivate static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) {
var count = 0
var cursor = line.startIndex
while cursor < line.endIndex, line[cursor] == " " {

View File

@@ -1,4 +1,10 @@
import SwiftMath
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
@MainActor
struct ChatCodeBlockView: View {
@@ -39,6 +45,157 @@ struct ChatCodeBlockView: View {
}
}
@MainActor
struct ChatMathBlockView: View {
let block: ChatMathBlock
let textColor: Color
@ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = OpenClawChatTypography.bodySize
var body: some View {
if self.block.isComplete,
ChatMathParseCache.mathList(latex: self.block.latex) != nil
{
ScrollView(.horizontal, showsIndicators: false) {
ChatMathPlatformView(
latex: self.block.latex,
fontSize: self.fontSize,
textColor: self.textColor)
.fixedSize()
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(self.block.latex))
}
.defaultScrollAnchor(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
} else {
ChatCodeBlockView(block: ChatCodeBlock(
language: nil,
code: self.block.latex,
isComplete: false))
}
}
}
/// 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 {
let latex: String
let fontSize: CGFloat
let textColor: Color
func makeNSView(context: Context) -> MTMathUILabel {
MTMathUILabel()
}
func updateNSView(_ view: MTMathUILabel, context: Context) {
self.configure(view)
}
private func configure(_ view: MTMathUILabel) {
view.displayErrorInline = false
view.labelMode = .display
view.textAlignment = .center
view.fontSize = self.fontSize
view.textColor = NSColor(self.textColor)
if view.latex != self.latex {
view.latex = self.latex
}
}
}
#else
@MainActor
private struct ChatMathPlatformView: UIViewRepresentable {
let latex: String
let fontSize: CGFloat
let textColor: Color
func makeUIView(context: Context) -> MTMathUILabel {
MTMathUILabel()
}
func updateUIView(_ view: MTMathUILabel, context: Context) {
self.configure(view)
}
private func configure(_ view: MTMathUILabel) {
view.displayErrorInline = false
view.labelMode = .display
view.textAlignment = .center
view.fontSize = self.fontSize
view.textColor = UIColor(self.textColor)
if view.latex != self.latex {
view.latex = self.latex
}
}
}
#endif
@MainActor
struct ChatMarkdownTableView: View {
let table: ChatMarkdownTable

View File

@@ -51,6 +51,8 @@ struct ChatMarkdownRenderer: View {
.lineSpacing(self.variant == .compact ? 2 : 4)
case let .code(code):
ChatCodeBlockView(block: code)
case let .math(math):
ChatMathBlockView(block: math, textColor: self.textColor)
case let .table(table):
ChatMarkdownTableView(table: table)
}
@@ -68,7 +70,7 @@ struct ChatMarkdownRenderer: View {
}
}
/// Fenced code and GFM tables are split out by `ChatMarkdownBlockSegmenter`
/// 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 {
static func preserveChatSoftBreaks(in markdown: String) -> String {

View File

@@ -5,6 +5,8 @@ import AppKit
#endif
enum OpenClawChatTypography {
static let bodySize: CGFloat = 17
static var title3: Font {
display(size: 22, weight: .bold, relativeTo: .title2)
}
@@ -22,7 +24,7 @@ enum OpenClawChatTypography {
}
static var body: Font {
body(size: 17, weight: .regular, relativeTo: .body)
body(size: self.bodySize, weight: .regular, relativeTo: .body)
}
static var footnote: Font {

View File

@@ -115,6 +115,107 @@ struct ChatMarkdownBlockSegmenterTests {
])
}
// MARK: - Display math
@Test func `same line dollar delimiters extract display math`() {
let blocks = self.segments("before\n$$ x^2 + y^2 $$\nafter")
#expect(blocks == [
.prose("before"),
.math(ChatMathBlock(latex: "x^2 + y^2", isComplete: true)),
.prose("after"),
])
}
@Test func `own line dollar delimiters extract multiline display math`() {
let blocks = self.segments("""
$$
\\begin{aligned}
x &= 1 \\\\
y &= 2
\\end{aligned}
$$
""")
#expect(blocks == [
.math(ChatMathBlock(
latex: "\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}",
isComplete: true)),
])
}
@Test func `bracket delimiters extract display math`() {
let blocks = self.segments(#"\[\frac{a}{b}\]"#)
#expect(blocks == [
.math(ChatMathBlock(latex: #"\frac{a}{b}"#, isComplete: true)),
])
}
@Test func `inline math delimiters stay prose`() {
let markdown = #"single $x$, parenthesized \(y\), and prose around $$z$$"#
#expect(self.segments(markdown) == [.prose(markdown)])
}
@Test func `unclosed math while streaming stays prose`() {
let markdown = "$$\nx + y"
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
}
@Test func `unclosed math in complete message renders as math`() {
let blocks = self.segments("$$\nx + y")
#expect(blocks == [
.math(ChatMathBlock(latex: "x + y", isComplete: true)),
])
}
@Test func `math composes with prose and fenced code blocks`() {
let blocks = self.segments("before\n$$E = mc^2$$\n```swift\nlet value = 1\n```\nafter")
#expect(blocks == [
.prose("before"),
.math(ChatMathBlock(latex: "E = mc^2", isComplete: true)),
.code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)),
.prose("after"),
])
}
@Test func `oversized math stays raw prose`() {
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
let markdown = "$$\n\(latex)\n$$"
#expect(self.segments(markdown) == [.prose(markdown)])
}
@Test func `oversized math keeps nested code fence in prose`() {
let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1)
let markdown = "$$\n\(latex)\n```swift\nlet value = 1\n```\n$$"
#expect(self.segments(markdown) == [.prose(markdown)])
}
@Test func `math delimiters inside code fences stay code`() {
let code = "$x$\n$$\nx + y\n$$\n\\[z\\]"
let blocks = self.segments("```tex\n\(code)\n```")
#expect(blocks == [
.code(ChatCodeBlock(language: "tex", code: code, isComplete: true)),
])
}
@Test func `math delimiters inside multiline code span stay prose`() {
let markdown = "`literal\n$$ x + y $$\nend`"
#expect(self.segments(markdown) == [.prose(markdown)])
}
@Test func `math delimiters inside list stay prose`() {
let markdown = "- item\n $$x + y$$"
#expect(self.segments(markdown) == [.prose(markdown)])
}
@Test func `unclosed streaming math leaves later opener lines as prose`() {
let markdown = "\\[\nfirst\n\\[\nsecond"
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
}
@Test func `unclosed streaming math keeps later code fence in prose`() {
let markdown = "before\n$$\nx + y\n```swift\nlet value = 1\n```"
#expect(self.segments(markdown, isComplete: false) == [.prose(markdown)])
}
// MARK: - Streaming fallbacks
@Test func `unclosed fence while streaming stays plain`() {