Commit 97c532c9b9

97c532c9b99df1140be3897de9ed9c6afd6c8d23

parent: fded44b0b2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 00:54 UTC

TeX math in markdown bodies

$…$ and $$…$$ in markdown, inline or as a block, are typeset with
SwiftMath (1.7.3, MIT) by the web's delimiter rules: pandoc's dollars,
so prices stay prose; code spans, link destinations and escapes are
never math; a block is $$ alone closed by a line ending in $$ before a
blank line, or $$…$$ on a line of its own. Input is bounded as
internal/texmath bounds it (8 KiB, nesting 64, no unescaped $ or #),
and the document budget is the web's (1000 expressions, 256 KiB);
anything refused shows its source. Math is tinted like the text around
it, sized to its block's text style at the current Dynamic Type size,
and VoiceOver reads its TeX.

Ref #39

Layout: unified · split

gitbay.xcodeproj/project.pbxproj +17
@@ -10,6 +10,7 @@
1010 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; };
1111 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */; };
1212 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; };
13 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */; };
1314/* End PBXBuildFile section */
1415
1516/* Begin PBXContainerItemProxy section */
@@ -61,6 +62,7 @@
6162 8B289850303FF914001BF44A /* Highlightr in Frameworks */,
6263 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */,
6364 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */,
65 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */,
6466 );
6567 runOnlyForDeploymentPostprocessing = 0;
6668 };
@@ -124,6 +126,7 @@
124126 8B2898B1303FF3E5001BF44A /* Highlightr */,
125127 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */,
126128 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */,
129 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */,
127130 );
128131 productName = gitbay;
129132 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
@@ -210,6 +213,7 @@
210213 packageReferences = (
211214 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */,
212215 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */,
216 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */,
213217 );
214218 preferredProjectObjectVersion = 77;
215219 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
@@ -610,6 +614,14 @@
610614 minimumVersion = 2.1.0;
611615 };
612616 };
617 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */ = {
618 isa = XCRemoteSwiftPackageReference;
619 repositoryURL = "https://github.com/mgriebling/SwiftMath";
620 requirement = {
621 kind = upToNextMinorVersion;
622 minimumVersion = 1.7.3;
623 };
624 };
613625/* End XCRemoteSwiftPackageReference section */
614626
615627/* Begin XCSwiftPackageProductDependency section */
@@ -628,6 +640,11 @@
628640 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */;
629641 productName = Highlightr;
630642 };
643 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */ = {
644 isa = XCSwiftPackageProductDependency;
645 package = 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */;
646 productName = SwiftMath;
647 };
631648/* End XCSwiftPackageProductDependency section */
632649 };
633650 rootObject = 8B28980A303FF3E5001BF44A /* Project object */;
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,5 +1,5 @@
11{
2 "originHash" : "ab8cee97c943a8273a6ba915c015907ce1f3559ff83687bf97758c491bc4a149",
2 "originHash" : "6c6c1aa243e0c1fd1b3c665718715a08bea6414406a12f38863948fe4c9065d6",
33 "pins" : [
44 {
55 "identity" : "highlightr",
@@ -18,6 +18,15 @@
1818 "revision" : "fa46c3f1577091385a95ac5f5680409f09701004",
1919 "version" : "0.7.1"
2020 }
21 },
22 {
23 "identity" : "swiftmath",
24 "kind" : "remoteSourceControl",
25 "location" : "https://github.com/mgriebling/SwiftMath",
26 "state" : {
27 "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7",
28 "version" : "1.7.3"
29 }
2130 }
2231 ],
2332 "version" : 3
gitbay/Views/Repos/MarkdownView.swift +97 −11
@@ -13,6 +13,8 @@ struct MarkdownView: View {
1313 /// The repository `#N` and `!N` refer to. Nil links no references.
1414 var referenceRepo: String?
1515
16 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
17
1618 var body: some View {
1719 VStack(alignment: .leading, spacing: 12) {
1820 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
@@ -32,6 +34,7 @@ struct MarkdownView: View {
3234 case table(Table)
3335 case rule
3436 case paragraph(String)
37 case math(MathExpression)
3538 }
3639
3740 /// A GitHub-flavored table: a header row, a body, and one alignment per
@@ -47,6 +50,10 @@ struct MarkdownView: View {
4750 }
4851
4952 var blocks: [Block] {
53 Self.budgeted(parsedBlocks)
54 }
55
56 private var parsedBlocks: [Block] {
5057 var blocks: [Block] = []
5158 var lines = markdown.components(separatedBy: "\n")[...]
5259
@@ -65,6 +72,11 @@ struct MarkdownView: View {
6572 blocks.append(.code(code.joined(separator: "\n")))
6673 continue
6774 }
75 if let (math, count) = markdownMathBlock(lines) {
76 blocks.append(.math(math))
77 lines = lines.dropFirst(count)
78 continue
79 }
6880 if let (level, text) = Self.atxHeading(trimmed) {
6981 blocks.append(.heading(level: min(level, 4), text: text))
7082 lines = lines.dropFirst()
@@ -139,7 +151,8 @@ struct MarkdownView: View {
139151 while let next = lines.first {
140152 let t = next.trimmingCharacters(in: .whitespaces)
141153 if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">")
142 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil {
154 || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil
155 || markdownMathBlock(lines) != nil {
143156 break
144157 }
145158 paragraph.append(t)
@@ -150,6 +163,27 @@ struct MarkdownView: View {
150163 return blocks
151164 }
152165
166 /// The blocks with math past the document's budget left as source, in
167 /// reading order as the web counts it.
168 private static func budgeted(_ blocks: [Block]) -> [Block] {
169 var budget = MathBudget.document
170 return blocks.map { block -> Block in
171 switch block {
172 case .heading(let level, let text): return .heading(level: level, text: budget.limit(text))
173 case .quote(let text): return .quote(budget.limit(text))
174 case .bullet(let items): return .bullet(items.map { budget.limit($0) })
175 case .ordered(let items): return .ordered(items.map { budget.limit($0) })
176 case .paragraph(let text): return .paragraph(budget.limit(text))
177 case .table(var table):
178 table.header = table.header.map { budget.limit($0) }
179 table.rows = table.rows.map { $0.map { budget.limit($0) } }
180 return .table(table)
181 case .math(let math): return budget.take(math.tex) ? .math(math) : .code(math.source)
182 case .code, .rule: return block
183 }
184 }
185 }
186
153187 /// An ATX heading: one to six `#` followed by a space, a tab or the end
154188 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
155189 private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
@@ -234,7 +268,7 @@ struct MarkdownView: View {
234268 switch block {
235269 case .heading(let level, let text):
236270 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
237 referenceRepo: referenceRepo)
271 referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level)))
238272 .font(headingFont(level))
239273 .padding(.top, level <= 2 ? 8 : 4)
240274 case .code(let code):
@@ -249,7 +283,8 @@ struct MarkdownView: View {
249283 RoundedRectangle(cornerRadius: 2)
250284 .fill(.tertiary)
251285 .frame(width: 3)
252 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
286 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
287 .foregroundStyle(.secondary)
253288 }
254289 .fixedSize(horizontal: false, vertical: true)
255290 case .bullet(let items):
@@ -257,7 +292,8 @@ struct MarkdownView: View {
257292 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
258293 HStack(alignment: .firstTextBaseline, spacing: 8) {
259294 Text("•")
260 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
295 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
296 mathSize: mathSize())
261297 .fixedSize(horizontal: false, vertical: true)
262298 }
263299 }
@@ -267,32 +303,71 @@ struct MarkdownView: View {
267303 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
268304 HStack(alignment: .firstTextBaseline, spacing: 8) {
269305 Text("\(index + 1).").monospacedDigit()
270 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
306 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
307 mathSize: mathSize())
271308 .fixedSize(horizontal: false, vertical: true)
272309 }
273310 }
274311 }
275312 case .table(let table):
276 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
313 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo,
314 mathSize: mathSize())
277315 case .rule:
278316 Divider()
279317 case .paragraph(let text):
280 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
318 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
319 case .math(let math):
320 MathBlockView(math: math, pointSize: mathSize())
281321 }
282322 }
283323
324 private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat {
325 UIFont.pointSize(style, dynamicTypeSize)
326 }
327
284328 /// Inline code is set explicitly rather than left to the substitution
285329 /// AttributedString makes for a custom face, which comes out optically larger
286330 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
287331 /// the ratio the web uses (`--fs-1` against `--fs-3`).
332 ///
333 /// With `mathSize`, TeX is typeset at that size in place of its source.
334 /// Each expression stands in the markdown as a character the text does
335 /// not contain, so emphasis and links around it parse as they would
336 /// around a word.
288337 static func inline(
289338 _ text: String,
290339 codeStyle: Font.TextStyle = .subheadline,
291340 linkOptions: OrgRenderOptions? = nil,
292 referenceRepo: String? = nil
341 referenceRepo: String? = nil,
342 mathSize: CGFloat? = nil
293343 ) -> Text {
294 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
295 referenceRepo: referenceRepo))
344 let pieces = mathSize == nil ? [] : splitMarkdownMath(text)
345 let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } }
346 guard let mathSize, !maths.isEmpty,
347 let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init)
348 .first(where: { !text.unicodeScalars.contains($0) })
349 else {
350 return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
351 referenceRepo: referenceRepo))
352 }
353 let marked = pieces.map { piece in
354 switch piece {
355 case .text(let text): text
356 case .math: String(mark)
357 }
358 }.joined()
359 let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions,
360 referenceRepo: referenceRepo)
361
362 var result = Text(verbatim: "")
363 var next = maths.makeIterator()
364 var run = rendered.startIndex
365 for index in rendered.characters.indices where rendered.characters[index] == Character(mark) {
366 let math = next.next()
367 result = Text("\(result)\(Text(AttributedString(rendered[run..<index])))\(math.map { MathRenderer.text($0, pointSize: mathSize) } ?? Text(verbatim: ""))")
368 run = rendered.characters.index(after: index)
369 }
370 return Text("\(result)\(Text(AttributedString(rendered[run...])))")
296371 }
297372
298373 static func attributed(
@@ -336,6 +411,15 @@ struct MarkdownView: View {
336411 }
337412
338413 /// One step below the heading's own size, so code in a heading tracks it.
414 private func headingStyle(_ level: Int) -> UIFont.TextStyle {
415 switch level {
416 case 1: .title2
417 case 2: .title3
418 case 3: .headline
419 default: .subheadline
420 }
421 }
422
339423 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
340424 switch level {
341425 case 1: .title3
@@ -356,6 +440,7 @@ private struct MarkdownTableView: View {
356440 let table: MarkdownView.Table
357441 var linkOptions: OrgRenderOptions?
358442 var referenceRepo: String?
443 var mathSize: CGFloat?
359444
360445 private static let spacing: CGFloat = 14
361446
@@ -390,7 +475,8 @@ private struct MarkdownTableView: View {
390475
391476 private func cell(_ text: String, _ column: Int) -> some View {
392477 let alignment = table.alignments[column]
393 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
478 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo,
479 mathSize: mathSize)
394480 .multilineTextAlignment(Self.textAlignment(alignment))
395481 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
396482 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Shared/MathRendering.swift added +123
@@ -0,0 +1,123 @@
1import SwiftMath
2import SwiftUI
3import UIKit
4
5/// One typeset expression: an alpha mask, tinted by the text around it, and
6/// how far its baseline sits above the bottom edge.
7struct RenderedMath {
8 let image: CGImage
9 let scale: CGFloat
10 let descent: CGFloat
11}
12
13/// Typesets TeX with SwiftMath. Input is bounded as the server's converter
14/// bounds it (internal/texmath), so a pathological body cannot run the
15/// parser deep or long; anything refused shows as its source.
16enum MathRenderer {
17 static let maxInput = 8 << 10
18 static let maxDepth = 64
19
20 private final class Entry {
21 let value: RenderedMath?
22 init(_ value: RenderedMath?) { self.value = value }
23 }
24
25 private static let cache: NSCache<NSString, Entry> = {
26 let cache = NSCache<NSString, Entry>()
27 cache.countLimit = 500
28 return cache
29 }()
30
31 static func render(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
32 let key = "\(display) \(pointSize) \(tex)" as NSString
33 if let entry = cache.object(forKey: key) { return entry.value }
34 let value = typeset(tex, display: display, pointSize: pointSize)
35 cache.setObject(Entry(value), forKey: key)
36 return value
37 }
38
39 private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
40 guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
41 tex.utf8.count <= maxInput, admits(tex) else { return nil }
42 var math = MathImage(latex: tex, fontSize: pointSize, textColor: .black,
43 labelMode: display ? .display : .text, textAlignment: .left)
44 let (error, image, layout) = math.asImage()
45 guard error == nil, let image, let cgImage = image.cgImage, let layout,
46 image.size.width > 0, image.size.height > 0 else { return nil }
47 return RenderedMath(image: cgImage, scale: image.scale, descent: layout.descent)
48 }
49
50 /// Whether `tex` nests groups, `\left…\right` and environments no
51 /// deeper than `maxDepth` and has no unescaped `$` or `#`, which the
52 /// server's converter refuses and SwiftMath would set as symbols.
53 static func admits(_ tex: String) -> Bool {
54 var depth = 0
55 var chars = tex[...]
56 while let c = chars.popFirst() {
57 switch c {
58 case "\\":
59 let name = chars.prefix(while: \.isLetter)
60 if name.isEmpty {
61 chars = chars.dropFirst()
62 continue
63 }
64 chars = chars.dropFirst(name.count)
65 if name == "left" || name == "begin" { depth += 1 }
66 if name == "right" || name == "end" { depth -= 1 }
67 case "{": depth += 1
68 case "}": depth -= 1
69 case "$", "#": return false
70 default: continue
71 }
72 if depth > maxDepth { return false }
73 }
74 return true
75 }
76
77 /// 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.
79 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text {
80 guard let rendered = render(math.tex, display: math.display, pointSize: pointSize) else {
81 return Text(verbatim: math.source)
82 }
83 return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
84 .renderingMode(.template))
85 .baselineOffset(-rendered.descent)
86 }
87}
88
89/// A display block: centred, scrolling sideways when wider than the screen,
90/// or the source as code where it is refused.
91struct MathBlockView: View {
92 let math: MathExpression
93 let pointSize: CGFloat
94
95 var body: some View {
96 if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) {
97 let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
98 .renderingMode(.template)
99 .padding(.vertical, 4)
100 ViewThatFits(in: .horizontal) {
101 image.frame(maxWidth: .infinity)
102 ScrollView(.horizontal, showsIndicators: false) { image }
103 }
104 } else {
105 ScrollView(.horizontal) {
106 Text(math.source)
107 .font(.gbMono(.caption))
108 .padding(10)
109 }
110 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
111 }
112 }
113}
114
115extension UIFont {
116 /// The point size of `style` at a Dynamic Type size, for typesetting
117 /// math beside text set in that style.
118 static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat {
119 preferredFont(forTextStyle: style,
120 compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size)))
121 .pointSize
122 }
123}
gitbay/Views/Shared/TeXMath.swift added +170
@@ -0,0 +1,170 @@
1import Foundation
2
3/// One TeX expression in a body: what is typeset, whether it is display
4/// math, and the source with its delimiters, shown where it is refused.
5nonisolated struct MathExpression: Equatable, Sendable {
6 let tex: String
7 let display: Bool
8 let source: String
9}
10
11nonisolated enum MathPiece: Equatable, Sendable {
12 case text(String)
13 case math(MathExpression)
14}
15
16/// The web's per-document limits: past them, delimiters stay literal text.
17nonisolated struct MathBudget: Sendable {
18 var expressions: Int
19 var bytes: Int
20
21 static let document = MathBudget(expressions: 1000, bytes: 256 << 10)
22
23 mutating func take(_ tex: String) -> Bool {
24 let size = tex.utf8.count
25 guard expressions > 0, size <= bytes else { return false }
26 expressions -= 1
27 bytes -= size
28 return true
29 }
30
31 /// `text` with every expression past the budget escaped, so a later
32 /// split leaves it as prose and the markdown renderer shows its source.
33 mutating func limit(_ text: String) -> String {
34 let pieces = splitMarkdownMath(text)
35 guard pieces.contains(where: { if case .math = $0 { true } else { false } }) else { return text }
36 var out = ""
37 for piece in pieces {
38 switch piece {
39 case .text(let text):
40 out += text
41 case .math(let math) where take(math.tex):
42 out += math.source
43 case .math(let math):
44 out += math.source.replacingOccurrences(of: "$", with: #"\$"#)
45 }
46 }
47 return out
48 }
49}
50
51/// Splits inline markdown into prose and math by the web's rules
52/// (internal/httpd/math.go): `$$…$$` is display math; `$…$` needs a
53/// non-space after the opener, and a non-space before the closer with no
54/// digit after it, so "$5 and $10" is prose. A backslash escapes the next
55/// character, and code spans and link destinations are never math.
56nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] {
57 let chars = Array(text)
58 var pieces: [MathPiece] = []
59 var prose = ""
60 var i = 0
61
62 func flush() {
63 if !prose.isEmpty { pieces.append(.text(prose)) }
64 prose = ""
65 }
66 func isSpace(_ c: Character) -> Bool { c == " " || c == "\t" || c == "\n" || c == "\r" }
67
68 while i < chars.count {
69 let c = chars[i]
70 switch c {
71 case "\\":
72 prose += String(chars[i..<min(i + 2, chars.count)])
73 i += 2
74 case "`":
75 var run = 1
76 while i + run < chars.count, chars[i + run] == "`" { run += 1 }
77 var j = i + run
78 var close: Int?
79 while j < chars.count {
80 guard chars[j] == "`" else { j += 1; continue }
81 var k = 1
82 while j + k < chars.count, chars[j + k] == "`" { k += 1 }
83 if k == run { close = j + k; break }
84 j += k
85 }
86 let end = close ?? i + run
87 prose += String(chars[i..<end])
88 i = end
89 case "]" where i + 1 < chars.count && chars[i + 1] == "(":
90 let end = chars[(i + 2)...].firstIndex(of: ")").map { $0 + 1 } ?? chars.count
91 prose += String(chars[i..<end])
92 i = end
93 case "$" where i + 1 < chars.count && chars[i + 1] == "$":
94 var j = i + 2
95 var close: Int?
96 while j + 1 < chars.count {
97 if chars[j] == "\\" { j += 2; continue }
98 if chars[j] == "$", chars[j + 1] == "$" { close = j; break }
99 j += 1
100 }
101 if let close, close > i + 2 {
102 flush()
103 let tex = String(chars[(i + 2)..<close])
104 pieces.append(.math(MathExpression(tex: tex, display: true, source: "$$" + tex + "$$")))
105 i = close + 2
106 } else {
107 prose += "$$"
108 i += 2
109 }
110 case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]):
111 var j = i + 1
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()
124 let tex = String(chars[(i + 1)..<close])
125 pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$")))
126 i = close + 1
127 } else {
128 prose.append(c)
129 i += 1
130 }
131 default:
132 prose.append(c)
133 i += 1
134 }
135 }
136 flush()
137 return pieces
138}
139
140/// A display block at the head of `lines`, by the web's rules: a line that
141/// is `$$` alone, closed by a line ending in `$$` before a blank line, or a
142/// line that is `$$…$$` whole. Returns the expression and the lines it
143/// spans.
144nonisolated func markdownMathBlock(_ lines: ArraySlice<String>) -> (MathExpression, lines: Int)? {
145 guard let first = lines.first else { return nil }
146 let opener = first.trimmingCharacters(in: .whitespaces)
147 guard opener.hasPrefix("$$") else { return nil }
148 let rest = String(opener.dropFirst(2))
149
150 if rest.isEmpty {
151 var tex = ""
152 var source = "$$\n"
153 for (offset, line) in lines.dropFirst().enumerated() {
154 if line.trimmingCharacters(in: .whitespaces).isEmpty { return nil }
155 let trimmed = String(line.reversed().drop(while: { $0 == " " || $0 == "\t" }).reversed())
156 if trimmed.hasSuffix("$$") {
157 tex += trimmed.dropLast(2)
158 source += trimmed
159 return (MathExpression(tex: tex, display: true, source: source), offset + 2)
160 }
161 tex += line + "\n"
162 source += line + "\n"
163 }
164 return nil
165 }
166 if rest.count > 2, rest.hasSuffix("$$") {
167 return (MathExpression(tex: String(rest.dropLast(2)), display: true, source: opener), 1)
168 }
169 return nil
170}
gitbayTests/MathTests.swift added +142
@@ -0,0 +1,142 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5/// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so
6/// prices stay prose, and `$$…$$` inline or as a block.
7struct MarkdownMathSplitTests {
8
9 private func math(_ tex: String, display: Bool = false) -> MathPiece {
10 let delimiter = display ? "$$" : "$"
11 return .math(MathExpression(tex: tex, display: display, source: delimiter + tex + delimiter))
12 }
13
14 @Test func inlineMathBetweenText() {
15 #expect(splitMarkdownMath("area $x^2$ here") == [.text("area "), math("x^2"), .text(" here")])
16 }
17
18 @Test func pricesStayProse() {
19 #expect(splitMarkdownMath("$5 and $10") == [.text("$5 and $10")])
20 }
21
22 @Test func anOpenerFollowedBySpaceIsText() {
23 #expect(splitMarkdownMath("a $ x$ b") == [.text("a $ x$ b")])
24 }
25
26 @Test func aCloserNeedsNoSpaceBeforeAndNoDigitAfter() {
27 #expect(splitMarkdownMath("$a $ and $b$1") == [.text("$a $ and $b$1")])
28 #expect(splitMarkdownMath("$a $b$") == [math("a $b")])
29 }
30
31 @Test func backslashEscapesADollar() {
32 #expect(splitMarkdownMath(#"\$x$ and $y\$z$"#) == [.text(#"\$x$ and "#), math(#"y\$z"#)])
33 }
34
35 @Test func codeSpansAreNotMath() {
36 #expect(splitMarkdownMath("`$x$` and ``a$b$`` c") == [.text("`$x$` and ``a$b$`` c")])
37 }
38
39 @Test func anUnclosedBacktickIsText() {
40 #expect(splitMarkdownMath("` $x$") == [.text("` "), math("x")])
41 }
42
43 @Test func linkDestinationsAreNotMath() {
44 #expect(splitMarkdownMath("[a](https://x.test/$y$) b") == [.text("[a](https://x.test/$y$) b")])
45 }
46
47 @Test func doubleDollarsAreDisplayMath() {
48 #expect(splitMarkdownMath(#"so $$\frac12$$ ok"#) == [.text("so "), math(#"\frac12"#, display: true), .text(" ok")])
49 }
50
51 @Test func emptyOrUnclosedDoubleDollarsAreText() {
52 #expect(splitMarkdownMath("$$$$") == [.text("$$$$")])
53 #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")])
54 }
55
56 @Test func theDocumentBudgetEscapesWhatIsLeft() {
57 var budget = MathBudget(expressions: 1, bytes: 100)
58 let text = budget.limit("$a$ and $b\\$c$")
59 #expect(text == #"$a$ and \$b\\$c\$"#)
60 #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)])
61 }
62}
63
64@MainActor
65struct MarkdownMathBlockTests {
66
67 private func blocks(_ markdown: String) -> [MarkdownView.Block] {
68 MarkdownView(markdown: markdown).blocks
69 }
70
71 @Test func doubleDollarLinesFenceABlock() {
72 #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression(
73 tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))])
74 }
75
76 @Test func theClosingDollarsMayEndALine() {
77 #expect(blocks("$$\nx^2 $$") == [.math(MathExpression(
78 tex: "x^2 ", display: true, source: "$$\nx^2 $$"))])
79 }
80
81 @Test func aWholeLineIsABlock() {
82 #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))])
83 }
84
85 @Test func aBlankLineBeforeTheCloserLeavesProse() {
86 #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")])
87 }
88
89 @Test func aBlockInterruptsAParagraph() {
90 #expect(blocks("text\n$$x$$\nafter") == [
91 .paragraph("text"),
92 .math(MathExpression(tex: "x", display: true, source: "$$x$$")),
93 .paragraph("after"),
94 ])
95 }
96
97 @Test func aBlockPastTheBudgetIsShownAsSource() {
98 let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n")
99 #expect(blocks(many).last == .code("$$x$$"))
100 }
101}
102
103@MainActor
104struct MathRenderingTests {
105
106 @Test func rendersAnExpression() {
107 #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil)
108 #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil)
109 }
110
111 @Test func refusesWhatSwiftMathCannotParse() {
112 #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil)
113 }
114
115 @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() {
116 #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil)
117 #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil)
118 #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil)
119 }
120
121 @Test func refusesTooLongOrTooDeepBeforeParsing() {
122 #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)
124 #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil)
125 let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64)
126 #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil)
127 }
128
129 @Test func malformedInputDoesNotCrash() {
130 let inputs = [
131 "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3",
132 "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\",
133 "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{",
134 "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits",
135 "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}",
136 ]
137 for tex in inputs {
138 _ = MathRenderer.render(tex, display: true, pointSize: 17)
139 _ = MathRenderer.render(tex, display: false, pointSize: 17)
140 }
141 }
142}