Commit f08e9a3415

f08e9a341561c274c5eaa94052d75414b92234b9

parent: 97c532c9b9

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 01:27 UTC

Math: bound what SwiftMath is given, and match the web's spellings

An expression with a character outside printable ASCII, an unescaped %,
more than 200 commands, or a layout over 4096 points a side shows its
source: SwiftMath drops the first two without an error, recurses per
command, and draws one unbounded bitmap. The cache is costed in bytes.
\dfrac, \tbinom, \operatorname, \boldsymbol, primes, ~ and the align,
gather, equation and displaymath environments are rewritten to what
SwiftMath reads. Inline $ closers are found in one pass, display blocks
take the budget before inline math as goldmark's do, CRLF bodies fence
blocks, and $$…$$ inside a paragraph sits on its own line.

Ref #39

Layout: unified · split

gitbay/Views/Repos/MarkdownView.swift +43 −10
@@ -55,7 +55,7 @@ struct MarkdownView: View {
55 55
56 private var parsedBlocks: [Block] { 56 private var parsedBlocks: [Block] {
57 var blocks: [Block] = [] 57 var blocks: [Block] = []
58 var lines = markdown.components(separatedBy: "\n")[...] 58 var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...]
59 59
60 while let line = lines.first { 60 while let line = lines.first {
61 let trimmed = line.trimmingCharacters(in: .whitespaces) 61 let trimmed = line.trimmingCharacters(in: .whitespaces)
@@ -163,10 +163,14 @@ struct MarkdownView: View {
163 return blocks 163 return blocks
164 } 164 }
165 165
166 /// The blocks with math past the document's budget left as source, in 166 /// The blocks with math past the document's budget left as source. The
167 /// reading order as the web counts it. 167 /// web parses blocks before inlines, so display blocks are counted first.
168 private static func budgeted(_ blocks: [Block]) -> [Block] { 168 static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] {
169 var budget = MathBudget.document 169 var budget = budget
170 let blocks = blocks.map { block -> Block in
171 guard case .math(let math) = block else { return block }
172 return budget.take(math.tex) ? block : .code(math.source)
173 }
170 return blocks.map { block -> Block in 174 return blocks.map { block -> Block in
171 switch block { 175 switch block {
172 case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) 176 case .heading(let level, let text): return .heading(level: level, text: budget.limit(text))
@@ -178,8 +182,7 @@ struct MarkdownView: View {
178 table.header = table.header.map { budget.limit($0) } 182 table.header = table.header.map { budget.limit($0) }
179 table.rows = table.rows.map { $0.map { budget.limit($0) } } 183 table.rows = table.rows.map { $0.map { budget.limit($0) } }
180 return .table(table) 184 return .table(table)
181 case .math(let math): return budget.take(math.tex) ? .math(math) : .code(math.source) 185 case .math, .code, .rule: return block
182 case .code, .rule: return block
183 } 186 }
184 } 187 }
185 } 188 }
@@ -359,15 +362,45 @@ struct MarkdownView: View {
359 let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, 362 let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions,
360 referenceRepo: referenceRepo) 363 referenceRepo: referenceRepo)
361 364
365 // Display math sits on a line of its own, as the web's block-level
366 // <math> does. A mark compares by its first scalar, since a combining
367 // character after it joins the same Character.
362 var result = Text(verbatim: "") 368 var result = Text(verbatim: "")
363 var next = maths.makeIterator() 369 var next = maths.makeIterator()
364 var run = rendered.startIndex 370 var run = rendered.startIndex
365 for index in rendered.characters.indices where rendered.characters[index] == Character(mark) { 371 var afterDisplay = false
372 func prose(_ upper: AttributedString.Index, trimEnd: Bool) -> Text {
373 var piece = AttributedString(rendered[run..<upper])
374 if afterDisplay, let first = piece.characters.firstIndex(where: { !$0.isWhitespace }) {
375 piece = AttributedString(piece[first...])
376 }
377 if trimEnd, let last = piece.characters.lastIndex(where: { !$0.isWhitespace }) {
378 piece = AttributedString(piece[...last])
379 }
380 return Text(piece)
381 }
382 for index in rendered.characters.indices
383 where rendered.characters[index].unicodeScalars.first == mark {
366 let math = next.next() 384 let math = next.next()
367 result = Text("\(result)\(Text(AttributedString(rendered[run..<index])))\(math.map { MathRenderer.text($0, pointSize: mathSize) } ?? Text(verbatim: ""))") 385 let display = math?.display == true
386 let before = prose(index, trimEnd: display)
368 run = rendered.characters.index(after: index) 387 run = rendered.characters.index(after: index)
388 guard let math else {
389 result = Text("\(result)\(before)")
390 afterDisplay = false
391 continue
392 }
393 let drawn = MathRenderer.text(math, pointSize: mathSize)
394 if display {
395 let lead = index == rendered.startIndex ? "" : "\n"
396 let trail = run == rendered.endIndex ? "" : "\n"
397 result = Text("\(result)\(before)\(lead)\(drawn)\(trail)")
398 } else {
399 result = Text("\(result)\(before)\(drawn)")
400 }
401 afterDisplay = display
369 } 402 }
370 return Text("\(result)\(Text(AttributedString(rendered[run...])))") 403 return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))")
371 } 404 }
372 405
373 static func attributed( 406 static func attributed(
gitbay/Views/Shared/MathRendering.swift +88 −6
@@ -16,6 +16,11 @@ struct RenderedMath {
16enum MathRenderer { 16enum MathRenderer {
17 static let maxInput = 8 << 10 17 static let maxInput = 8 << 10
18 static let maxDepth = 64 18 static let maxDepth = 64
19 /// SwiftMath recurses once per command that takes an argument, so the
20 /// count bounds nesting that braces do not show.
21 static let maxCommands = 200
22 /// The longest side of an image, in points.
23 static let maxSide: CGFloat = 4096
19 24
20 private final class Entry { 25 private final class Entry {
21 let value: RenderedMath? 26 let value: RenderedMath?
@@ -25,6 +30,7 @@ enum MathRenderer {
25 private static let cache: NSCache<NSString, Entry> = { 30 private static let cache: NSCache<NSString, Entry> = {
26 let cache = NSCache<NSString, Entry>() 31 let cache = NSCache<NSString, Entry>()
27 cache.countLimit = 500 32 cache.countLimit = 500
33 cache.totalCostLimit = 64 << 20
28 return cache 34 return cache
29 }() 35 }()
30 36
@@ -32,15 +38,25 @@ enum MathRenderer {
32 let key = "\(display) \(pointSize) \(tex)" as NSString 38 let key = "\(display) \(pointSize) \(tex)" as NSString
33 if let entry = cache.object(forKey: key) { return entry.value } 39 if let entry = cache.object(forKey: key) { return entry.value }
34 let value = typeset(tex, display: display, pointSize: pointSize) 40 let value = typeset(tex, display: display, pointSize: pointSize)
35 cache.setObject(Entry(value), forKey: key) 41 cache.setObject(Entry(value), forKey: key, cost: value.map { $0.image.bytesPerRow * $0.image.height } ?? 0)
36 return value 42 return value
37 } 43 }
38 44
39 private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { 45 private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
40 guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, 46 guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
41 tex.utf8.count <= maxInput, admits(tex) else { return nil } 47 tex.utf8.count <= maxInput, admits(tex) else { return nil }
42 var math = MathImage(latex: tex, fontSize: pointSize, textColor: .black, 48 let latex = swiftMath(tex)
43 labelMode: display ? .display : .text, textAlignment: .left) 49 let mode: MTMathUILabelMode = display ? .display : .text
50 // Measured before it is drawn, so an expression too large to show never
51 // allocates its bitmap.
52 let label = MTMathUILabel()
53 label.labelMode = mode
54 label.fontSize = pointSize
55 label.latex = latex
56 let size = label.intrinsicContentSize
57 guard label.error == nil, size.width <= maxSide, size.height <= maxSide else { return nil }
58 var math = MathImage(latex: latex, fontSize: pointSize, textColor: .black,
59 labelMode: mode, textAlignment: .left)
44 let (error, image, layout) = math.asImage() 60 let (error, image, layout) = math.asImage()
45 guard error == nil, let image, let cgImage = image.cgImage, let layout, 61 guard error == nil, let image, let cgImage = image.cgImage, let layout,
46 image.size.width > 0, image.size.height > 0 else { return nil } 62 image.size.width > 0, image.size.height > 0 else { return nil }
@@ -48,10 +64,15 @@ enum MathRenderer {
48 } 64 }
49 65
50 /// Whether `tex` nests groups, `\left…\right` and environments no 66 /// Whether `tex` nests groups, `\left…\right` and environments no
51 /// deeper than `maxDepth` and has no unescaped `$` or `#`, which the 67 /// deeper than `maxDepth`, has at most `maxCommands` commands, and has no
52 /// server's converter refuses and SwiftMath would set as symbols. 68 /// unescaped `$`, `#` or `%`, which the server's converter refuses, and no
69 /// character outside printable ASCII, which SwiftMath leaves out without
70 /// an error.
53 static func admits(_ tex: String) -> Bool { 71 static func admits(_ tex: String) -> Bool {
72 guard tex.unicodeScalars.allSatisfy({ (0x20...0x7E).contains($0.value) || $0 == "\n" || $0 == "\t" })
73 else { return false }
54 var depth = 0 74 var depth = 0
75 var commands = 0
55 var chars = tex[...] 76 var chars = tex[...]
56 while let c = chars.popFirst() { 77 while let c = chars.popFirst() {
57 switch c { 78 switch c {
@@ -62,11 +83,13 @@ enum MathRenderer {
62 continue 83 continue
63 } 84 }
64 chars = chars.dropFirst(name.count) 85 chars = chars.dropFirst(name.count)
86 commands += 1
87 if commands > maxCommands { return false }
65 if name == "left" || name == "begin" { depth += 1 } 88 if name == "left" || name == "begin" { depth += 1 }
66 if name == "right" || name == "end" { depth -= 1 } 89 if name == "right" || name == "end" { depth -= 1 }
67 case "{": depth += 1 90 case "{": depth += 1
68 case "}": depth -= 1 91 case "}": depth -= 1
69 case "$", "#": return false 92 case "$", "#", "%": return false
70 default: continue 93 default: continue
71 } 94 }
72 if depth > maxDepth { return false } 95 if depth > maxDepth { return false }
@@ -74,6 +97,65 @@ enum MathRenderer {
74 return true 97 return true
75 } 98 }
76 99
100 private static let commands: [Substring: String] = [
101 "dfrac": "frac", "tfrac": "frac", "cfrac": "frac",
102 "dbinom": "binom", "tbinom": "binom",
103 "operatorname": "mathrm", "boldsymbol": "mathbf", "bm": "mathbf", "mathscr": "mathcal",
104 "textrm": "text", "textnormal": "text", "mbox": "text", "middle": "",
105 ]
106
107 private static let environments: [Substring: String] = [
108 "align": "aligned", "align*": "aligned", "gathered": "gather", "gather*": "gather",
109 "smallmatrix": "matrix", "equation": "", "equation*": "", "displaymath": "",
110 ]
111
112 /// The web's spellings in SwiftMath's: the `\frac` and `\binom` variants,
113 /// font and operator names, primes, `~`, and the environments it names
114 /// differently. An environment that only displays its body is dropped.
115 static func swiftMath(_ tex: String) -> String {
116 var out = ""
117 var chars = tex[...]
118 while let c = chars.popFirst() {
119 switch c {
120 case "\\":
121 let name = chars.prefix(while: \.isLetter)
122 guard !name.isEmpty else {
123 out.append(c)
124 if let next = chars.popFirst() { out.append(next) }
125 continue
126 }
127 chars = chars.dropFirst(name.count)
128 if name == "operatorname", chars.first == "*" { chars = chars.dropFirst() }
129 // An operator name is upright with a thin space after it.
130 if name == "operatorname", chars.first == "{", let close = chars.firstIndex(of: "}") {
131 out += "\\mathrm" + chars[...close] + "\\,"
132 chars = chars[chars.index(after: close)...]
133 continue
134 }
135 if name == "begin" || name == "end", chars.first == "{",
136 let close = chars.firstIndex(of: "}") {
137 let env = chars[chars.index(after: chars.startIndex)..<close]
138 if let renamed = environments[env] {
139 chars = chars[chars.index(after: close)...]
140 if !renamed.isEmpty { out += "\\\(name){\(renamed)}" }
141 continue
142 }
143 }
144 let renamed = commands[name] ?? String(name)
145 if !renamed.isEmpty { out += "\\" + renamed }
146 case "'":
147 var primes = 1
148 while chars.first == "'" { primes += 1; chars = chars.dropFirst() }
149 out += "^{" + String(repeating: "\\prime", count: primes) + "}"
150 case "~":
151 out += "\\;"
152 default:
153 out.append(c)
154 }
155 }
156 return out
157 }
158
77 /// The expression as text: the image, set on the baseline and tinted 159 /// The expression as text: the image, set on the baseline and tinted
78 /// like the text around it, or the source where it is refused. 160 /// like the text around it, or the source where it is refused.
79 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { 161 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text {
gitbay/Views/Shared/TeXMath.swift +25 −12
@@ -65,6 +65,30 @@ nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] {
65 } 65 }
66 func isSpace(_ c: Character) -> Bool { c == " " || c == "\t" || c == "\n" || c == "\r" } 66 func isSpace(_ c: Character) -> Bool { c == " " || c == "\t" || c == "\n" || c == "\r" }
67 67
68 // Every `$` that can close inline math, found in one pass so each opener
69 // looks its closer up rather than rescanning the rest of the text.
70 var closers: [Int] = []
71 var k = 1
72 while k < chars.count {
73 if chars[k] == "\\" {
74 k += 2
75 continue
76 }
77 if chars[k] == "$", !isSpace(chars[k - 1]),
78 !(k + 1 < chars.count && chars[k + 1].isASCII && chars[k + 1].isNumber) {
79 closers.append(k)
80 }
81 k += 1
82 }
83 func firstCloser(atOrAfter offset: Int) -> Int? {
84 var low = 0, high = closers.count
85 while low < high {
86 let mid = (low + high) / 2
87 if closers[mid] < offset { low = mid + 1 } else { high = mid }
88 }
89 return low < closers.count ? closers[low] : nil
90 }
91
68 while i < chars.count { 92 while i < chars.count {
69 let c = chars[i] 93 let c = chars[i]
70 switch c { 94 switch c {
@@ -108,18 +132,7 @@ nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] {
108 i += 2 132 i += 2
109 } 133 }
110 case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]): 134 case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]):
111 var j = i + 1 135 if let close = firstCloser(atOrAfter: i + 2) {
112 var close: Int?
113 while j < chars.count {
114 if chars[j] == "\\" { j += 2; continue }
115 if chars[j] == "$", j >= i + 2, !isSpace(chars[j - 1]),
116 !(j + 1 < chars.count && chars[j + 1].isASCII && chars[j + 1].isNumber) {
117 close = j
118 break
119 }
120 j += 1
121 }
122 if let close {
123 flush() 136 flush()
124 let tex = String(chars[(i + 1)..<close]) 137 let tex = String(chars[(i + 1)..<close])
125 pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$"))) 138 pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$")))
gitbayTests/MathTests.swift +47
@@ -53,6 +53,12 @@ struct MarkdownMathSplitTests {
53 #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")]) 53 #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")])
54 } 54 }
55 55
56 @Test func manyUnclosedOpenersSplitQuickly() {
57 let text = String(repeating: "$a ", count: 50_000)
58 #expect(splitMarkdownMath(text) == [.text(text)])
59 #expect(splitMarkdownMath(text + "$b$").count == 1, "the first opener closes at the end, as on the web")
60 }
61
56 @Test func theDocumentBudgetEscapesWhatIsLeft() { 62 @Test func theDocumentBudgetEscapesWhatIsLeft() {
57 var budget = MathBudget(expressions: 1, bytes: 100) 63 var budget = MathBudget(expressions: 1, bytes: 100)
58 let text = budget.limit("$a$ and $b\\$c$") 64 let text = budget.limit("$a$ and $b\\$c$")
@@ -94,6 +100,18 @@ struct MarkdownMathBlockTests {
94 ]) 100 ])
95 } 101 }
96 102
103 @Test func crlfBodiesFenceBlocksToo() {
104 #expect(blocks("$$\r\nx^2\r\n$$") == [.math(MathExpression(
105 tex: "x^2\n", display: true, source: "$$\nx^2\n$$"))])
106 }
107
108 @Test func blockMathTakesTheBudgetBeforeInlineMath() {
109 let budgeted = MarkdownView.budgeted(
110 [.paragraph("$a$"), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))],
111 budget: MathBudget(expressions: 1, bytes: 100))
112 #expect(budgeted == [.paragraph(#"\$a\$"#), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))])
113 }
114
97 @Test func aBlockPastTheBudgetIsShownAsSource() { 115 @Test func aBlockPastTheBudgetIsShownAsSource() {
98 let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") 116 let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n")
99 #expect(blocks(many).last == .code("$$x$$")) 117 #expect(blocks(many).last == .code("$$x$$"))
@@ -118,6 +136,35 @@ struct MathRenderingTests {
118 #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) 136 #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil)
119 } 137 }
120 138
139 @Test func refusesWhatSwiftMathWouldDropSilently() {
140 for tex in ["α + β", "x ≤ y", #"\text{日本語}"#, "x % note", "a\u{7}b"] {
141 #expect(MathRenderer.render(tex, display: false, pointSize: 17) == nil, "\(tex)")
142 }
143 }
144
145 @Test func translatesTheWebsSpellings() {
146 #expect(MathRenderer.swiftMath("f'(x) + g''") == #"f^{\prime}(x) + g^{\prime\prime}"#)
147 #expect(MathRenderer.swiftMath("a~b") == #"a\;b"#)
148 #expect(MathRenderer.swiftMath(#"\dfrac12 \tbinom{n}{k} \operatorname{rank} \boldsymbol{x}"#)
149 == #"\frac12 \binom{n}{k} \mathrm{rank}\, \mathbf{x}"#)
150 #expect(MathRenderer.swiftMath(#"\begin{align*} a &= b \end{align*}"#) == #"\begin{aligned} a &= b \end{aligned}"#)
151 #expect(MathRenderer.swiftMath(#"\begin{equation} x \end{equation}"#) == #" x "#)
152 #expect(MathRenderer.swiftMath(#"\dfracx"#) == #"\dfracx"#, "only whole command names")
153 for tex in [#"\dfrac{a}{b}"#, #"\operatorname{rank} A"#, #"\begin{align} a &= b \\ c &= d \end{align}"#,
154 #"\begin{equation} e = mc^2 \end{equation}"#, #"\begin{gather*} a \\ b \end{gather*}"#, "f'(x)"] {
155 #expect(MathRenderer.render(tex, display: true, pointSize: 17) != nil, "\(tex)")
156 }
157 }
158
159 @Test func refusesTooManyCommandsOrTooLargeAnImage() {
160 let many = String(repeating: #"\alpha "#, count: MathRenderer.maxCommands)
161 #expect(MathRenderer.render(many, display: false, pointSize: 17) != nil)
162 #expect(MathRenderer.render(many + #"\beta"#, display: false, pointSize: 17) == nil)
163 let nested = String(repeating: #"\sqrt"#, count: MathRenderer.maxCommands) + "x"
164 _ = MathRenderer.render(nested, display: true, pointSize: 17)
165 #expect(MathRenderer.render(String(repeating: "x", count: 4000), display: false, pointSize: 17) == nil)
166 }
167
121 @Test func refusesTooLongOrTooDeepBeforeParsing() { 168 @Test func refusesTooLongOrTooDeepBeforeParsing() {
122 #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) 169 #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil)
123 let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) 170 let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65)