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 @@
10 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; }; 10 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; };
11 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */; }; 11 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */; };
12 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; 12 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; };
13 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */; };
13/* End PBXBuildFile section */ 14/* End PBXBuildFile section */
14 15
15/* Begin PBXContainerItemProxy section */ 16/* Begin PBXContainerItemProxy section */
@@ -61,6 +62,7 @@
61 8B289850303FF914001BF44A /* Highlightr in Frameworks */, 62 8B289850303FF914001BF44A /* Highlightr in Frameworks */,
62 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */, 63 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */,
63 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */, 64 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */,
65 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */,
64 ); 66 );
65 runOnlyForDeploymentPostprocessing = 0; 67 runOnlyForDeploymentPostprocessing = 0;
66 }; 68 };
@@ -124,6 +126,7 @@
124 8B2898B1303FF3E5001BF44A /* Highlightr */, 126 8B2898B1303FF3E5001BF44A /* Highlightr */,
125 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */, 127 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */,
126 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */, 128 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */,
129 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */,
127 ); 130 );
128 productName = gitbay; 131 productName = gitbay;
129 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; 132 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
@@ -210,6 +213,7 @@
210 packageReferences = ( 213 packageReferences = (
211 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */, 214 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */,
212 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */, 215 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */,
216 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */,
213 ); 217 );
214 preferredProjectObjectVersion = 77; 218 preferredProjectObjectVersion = 77;
215 productRefGroup = 8B289813303FF3E5001BF44A /* Products */; 219 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
@@ -610,6 +614,14 @@
610 minimumVersion = 2.1.0; 614 minimumVersion = 2.1.0;
611 }; 615 };
612 }; 616 };
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 };
613/* End XCRemoteSwiftPackageReference section */ 625/* End XCRemoteSwiftPackageReference section */
614 626
615/* Begin XCSwiftPackageProductDependency section */ 627/* Begin XCSwiftPackageProductDependency section */
@@ -628,6 +640,11 @@
628 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */; 640 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */;
629 productName = Highlightr; 641 productName = Highlightr;
630 }; 642 };
643 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */ = {
644 isa = XCSwiftPackageProductDependency;
645 package = 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */;
646 productName = SwiftMath;
647 };
631/* End XCSwiftPackageProductDependency section */ 648/* End XCSwiftPackageProductDependency section */
632 }; 649 };
633 rootObject = 8B28980A303FF3E5001BF44A /* Project object */; 650 rootObject = 8B28980A303FF3E5001BF44A /* Project object */;
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,5 +1,5 @@
1{ 1{
2 "originHash" : "ab8cee97c943a8273a6ba915c015907ce1f3559ff83687bf97758c491bc4a149", 2 "originHash" : "6c6c1aa243e0c1fd1b3c665718715a08bea6414406a12f38863948fe4c9065d6",
3 "pins" : [ 3 "pins" : [
4 { 4 {
5 "identity" : "highlightr", 5 "identity" : "highlightr",
@@ -18,6 +18,15 @@
18 "revision" : "fa46c3f1577091385a95ac5f5680409f09701004", 18 "revision" : "fa46c3f1577091385a95ac5f5680409f09701004",
19 "version" : "0.7.1" 19 "version" : "0.7.1"
20 } 20 }
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 }
21 } 30 }
22 ], 31 ],
23 "version" : 3 32 "version" : 3
gitbay/Views/Repos/MarkdownView.swift +97 −11
@@ -13,6 +13,8 @@ struct MarkdownView: View {
13 /// The repository `#N` and `!N` refer to. Nil links no references. 13 /// The repository `#N` and `!N` refer to. Nil links no references.
14 var referenceRepo: String? 14 var referenceRepo: String?
15 15
16 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
17
16 var body: some View { 18 var body: some View {
17 VStack(alignment: .leading, spacing: 12) { 19 VStack(alignment: .leading, spacing: 12) {
18 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in 20 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
@@ -32,6 +34,7 @@ struct MarkdownView: View {
32 case table(Table) 34 case table(Table)
33 case rule 35 case rule
34 case paragraph(String) 36 case paragraph(String)
37 case math(MathExpression)
35 } 38 }
36 39
37 /// A GitHub-flavored table: a header row, a body, and one alignment per 40 /// A GitHub-flavored table: a header row, a body, and one alignment per
@@ -47,6 +50,10 @@ struct MarkdownView: View {
47 } 50 }
48 51
49 var blocks: [Block] { 52 var blocks: [Block] {
53 Self.budgeted(parsedBlocks)
54 }
55
56 private var parsedBlocks: [Block] {
50 var blocks: [Block] = [] 57 var blocks: [Block] = []
51 var lines = markdown.components(separatedBy: "\n")[...] 58 var lines = markdown.components(separatedBy: "\n")[...]
52 59
@@ -65,6 +72,11 @@ struct MarkdownView: View {
65 blocks.append(.code(code.joined(separator: "\n"))) 72 blocks.append(.code(code.joined(separator: "\n")))
66 continue 73 continue
67 } 74 }
75 if let (math, count) = markdownMathBlock(lines) {
76 blocks.append(.math(math))
77 lines = lines.dropFirst(count)
78 continue
79 }
68 if let (level, text) = Self.atxHeading(trimmed) { 80 if let (level, text) = Self.atxHeading(trimmed) {
69 blocks.append(.heading(level: min(level, 4), text: text)) 81 blocks.append(.heading(level: min(level, 4), text: text))
70 lines = lines.dropFirst() 82 lines = lines.dropFirst()
@@ -139,7 +151,8 @@ struct MarkdownView: View {
139 while let next = lines.first { 151 while let next = lines.first {
140 let t = next.trimmingCharacters(in: .whitespaces) 152 let t = next.trimmingCharacters(in: .whitespaces)
141 if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">") 153 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 {
143 break 156 break
144 } 157 }
145 paragraph.append(t) 158 paragraph.append(t)
@@ -150,6 +163,27 @@ struct MarkdownView: View {
150 return blocks 163 return blocks
151 } 164 }
152 165
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
153 /// An ATX heading: one to six `#` followed by a space, a tab or the end 187 /// An ATX heading: one to six `#` followed by a space, a tab or the end
154 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph. 188 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
155 private static func atxHeading(_ line: String) -> (level: Int, text: String)? { 189 private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
@@ -234,7 +268,7 @@ struct MarkdownView: View {
234 switch block { 268 switch block {
235 case .heading(let level, let text): 269 case .heading(let level, let text):
236 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions, 270 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
237 referenceRepo: referenceRepo) 271 referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level)))
238 .font(headingFont(level)) 272 .font(headingFont(level))
239 .padding(.top, level <= 2 ? 8 : 4) 273 .padding(.top, level <= 2 ? 8 : 4)
240 case .code(let code): 274 case .code(let code):
@@ -249,7 +283,8 @@ struct MarkdownView: View {
249 RoundedRectangle(cornerRadius: 2) 283 RoundedRectangle(cornerRadius: 2)
250 .fill(.tertiary) 284 .fill(.tertiary)
251 .frame(width: 3) 285 .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)
253 } 288 }
254 .fixedSize(horizontal: false, vertical: true) 289 .fixedSize(horizontal: false, vertical: true)
255 case .bullet(let items): 290 case .bullet(let items):
@@ -257,7 +292,8 @@ struct MarkdownView: View {
257 ForEach(Array(items.enumerated()), id: \.offset) { _, item in 292 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
258 HStack(alignment: .firstTextBaseline, spacing: 8) { 293 HStack(alignment: .firstTextBaseline, spacing: 8) {
259 Text("•") 294 Text("•")
260 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) 295 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
296 mathSize: mathSize())
261 .fixedSize(horizontal: false, vertical: true) 297 .fixedSize(horizontal: false, vertical: true)
262 } 298 }
263 } 299 }
@@ -267,32 +303,71 @@ struct MarkdownView: View {
267 ForEach(Array(items.enumerated()), id: \.offset) { index, item in 303 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
268 HStack(alignment: .firstTextBaseline, spacing: 8) { 304 HStack(alignment: .firstTextBaseline, spacing: 8) {
269 Text("\(index + 1).").monospacedDigit() 305 Text("\(index + 1).").monospacedDigit()
270 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) 306 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
307 mathSize: mathSize())
271 .fixedSize(horizontal: false, vertical: true) 308 .fixedSize(horizontal: false, vertical: true)
272 } 309 }
273 } 310 }
274 } 311 }
275 case .table(let table): 312 case .table(let table):
276 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo) 313 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo,
314 mathSize: mathSize())
277 case .rule: 315 case .rule:
278 Divider() 316 Divider()
279 case .paragraph(let text): 317 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())
281 } 321 }
282 } 322 }
283 323
324 private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat {
325 UIFont.pointSize(style, dynamicTypeSize)
326 }
327
284 /// Inline code is set explicitly rather than left to the substitution 328 /// Inline code is set explicitly rather than left to the substitution
285 /// AttributedString makes for a custom face, which comes out optically larger 329 /// AttributedString makes for a custom face, which comes out optically larger
286 /// than the surrounding sans. `codeStyle` is a step below the block's own style, 330 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
287 /// the ratio the web uses (`--fs-1` against `--fs-3`). 331 /// 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.
288 static func inline( 337 static func inline(
289 _ text: String, 338 _ text: String,
290 codeStyle: Font.TextStyle = .subheadline, 339 codeStyle: Font.TextStyle = .subheadline,
291 linkOptions: OrgRenderOptions? = nil, 340 linkOptions: OrgRenderOptions? = nil,
292 referenceRepo: String? = nil 341 referenceRepo: String? = nil,
342 mathSize: CGFloat? = nil
293 ) -> Text { 343 ) -> Text {
294 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, 344 let pieces = mathSize == nil ? [] : splitMarkdownMath(text)
295 referenceRepo: referenceRepo)) 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...])))")
296 } 371 }
297 372
298 static func attributed( 373 static func attributed(
@@ -336,6 +411,15 @@ struct MarkdownView: View {
336 } 411 }
337 412
338 /// One step below the heading's own size, so code in a heading tracks it. 413 /// 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
339 private func headingCodeStyle(_ level: Int) -> Font.TextStyle { 423 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
340 switch level { 424 switch level {
341 case 1: .title3 425 case 1: .title3
@@ -356,6 +440,7 @@ private struct MarkdownTableView: View {
356 let table: MarkdownView.Table 440 let table: MarkdownView.Table
357 var linkOptions: OrgRenderOptions? 441 var linkOptions: OrgRenderOptions?
358 var referenceRepo: String? 442 var referenceRepo: String?
443 var mathSize: CGFloat?
359 444
360 private static let spacing: CGFloat = 14 445 private static let spacing: CGFloat = 14
361 446
@@ -390,7 +475,8 @@ private struct MarkdownTableView: View {
390 475
391 private func cell(_ text: String, _ column: Int) -> some View { 476 private func cell(_ text: String, _ column: Int) -> some View {
392 let alignment = table.alignments[column] 477 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)
394 .multilineTextAlignment(Self.textAlignment(alignment)) 480 .multilineTextAlignment(Self.textAlignment(alignment))
395 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) 481 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
396 .fixedSize(horizontal: cellWidth == nil, vertical: true) 482 .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}