TeX math in markdown bodies !177

merged merged by cmc on 2026-10-16 02:05 UTC · krz/gitbay-ios:math-markdown into main

6 files changed, +735 −13

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 +131 −12
@@ -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,8 +50,12 @@ struct MarkdownView: View {
4750 }
4851
4952 var blocks: [Block] {
53 Self.budgeted(parsedBlocks)
54 }
55
56 private var parsedBlocks: [Block] {
5057 var blocks: [Block] = []
51 var lines = markdown.components(separatedBy: "\n")[...]
58 var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...]
5259
5360 while let line = lines.first {
5461 let trimmed = line.trimmingCharacters(in: .whitespaces)
@@ -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,30 @@ struct MarkdownView: View {
150163 return blocks
151164 }
152165
166 /// The blocks with math past the document's budget left as source. The
167 /// web parses blocks before inlines, so display blocks are counted first.
168 static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] {
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 }
174 return blocks.map { block -> Block in
175 switch block {
176 case .heading(let level, let text): return .heading(level: level, text: budget.limit(text))
177 case .quote(let text): return .quote(budget.limit(text))
178 case .bullet(let items): return .bullet(items.map { budget.limit($0) })
179 case .ordered(let items): return .ordered(items.map { budget.limit($0) })
180 case .paragraph(let text): return .paragraph(budget.limit(text))
181 case .table(var table):
182 table.header = table.header.map { budget.limit($0) }
183 table.rows = table.rows.map { $0.map { budget.limit($0) } }
184 return .table(table)
185 case .math, .code, .rule: return block
186 }
187 }
188 }
189
153190 /// An ATX heading: one to six `#` followed by a space, a tab or the end
154191 /// of the line, as in CommonMark. `#12 fixed this` is a paragraph.
155192 private static func atxHeading(_ line: String) -> (level: Int, text: String)? {
@@ -234,7 +271,7 @@ struct MarkdownView: View {
234271 switch block {
235272 case .heading(let level, let text):
236273 Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions,
237 referenceRepo: referenceRepo)
274 referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level)))
238275 .font(headingFont(level))
239276 .padding(.top, level <= 2 ? 8 : 4)
240277 case .code(let code):
@@ -249,7 +286,8 @@ struct MarkdownView: View {
249286 RoundedRectangle(cornerRadius: 2)
250287 .fill(.tertiary)
251288 .frame(width: 3)
252 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary)
289 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
290 .foregroundStyle(.secondary)
253291 }
254292 .fixedSize(horizontal: false, vertical: true)
255293 case .bullet(let items):
@@ -257,7 +295,8 @@ struct MarkdownView: View {
257295 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
258296 HStack(alignment: .firstTextBaseline, spacing: 8) {
259297 Text("•")
260 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
298 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
299 mathSize: mathSize())
261300 .fixedSize(horizontal: false, vertical: true)
262301 }
263302 }
@@ -267,32 +306,101 @@ struct MarkdownView: View {
267306 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
268307 HStack(alignment: .firstTextBaseline, spacing: 8) {
269308 Text("\(index + 1).").monospacedDigit()
270 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo)
309 Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo,
310 mathSize: mathSize())
271311 .fixedSize(horizontal: false, vertical: true)
272312 }
273313 }
274314 }
275315 case .table(let table):
276 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo)
316 MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo,
317 mathSize: mathSize())
277318 case .rule:
278319 Divider()
279320 case .paragraph(let text):
280 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
321 Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize())
322 case .math(let math):
323 MathBlockView(math: math, pointSize: mathSize())
281324 }
282325 }
283326
327 private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat {
328 UIFont.pointSize(style, dynamicTypeSize)
329 }
330
284331 /// Inline code is set explicitly rather than left to the substitution
285332 /// AttributedString makes for a custom face, which comes out optically larger
286333 /// than the surrounding sans. `codeStyle` is a step below the block's own style,
287334 /// the ratio the web uses (`--fs-1` against `--fs-3`).
335 ///
336 /// With `mathSize`, TeX is typeset at that size in place of its source.
337 /// Each expression stands in the markdown as a character the text does
338 /// not contain, so emphasis and links around it parse as they would
339 /// around a word.
288340 static func inline(
289341 _ text: String,
290342 codeStyle: Font.TextStyle = .subheadline,
291343 linkOptions: OrgRenderOptions? = nil,
292 referenceRepo: String? = nil
344 referenceRepo: String? = nil,
345 mathSize: CGFloat? = nil
293346 ) -> Text {
294 Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
295 referenceRepo: referenceRepo))
347 let pieces = mathSize == nil ? [] : splitMarkdownMath(text)
348 let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } }
349 guard let mathSize, !maths.isEmpty,
350 let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init)
351 .first(where: { !text.unicodeScalars.contains($0) })
352 else {
353 return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions,
354 referenceRepo: referenceRepo))
355 }
356 let marked = pieces.map { piece in
357 switch piece {
358 case .text(let text): text
359 case .math: String(mark)
360 }
361 }.joined()
362 let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions,
363 referenceRepo: referenceRepo)
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.
368 var result = Text(verbatim: "")
369 var next = maths.makeIterator()
370 var run = rendered.startIndex
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 {
384 let math = next.next()
385 let display = math?.display == true
386 let before = prose(index, trimEnd: display)
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
402 }
403 return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))")
296404 }
297405
298406 static func attributed(
@@ -336,6 +444,15 @@ struct MarkdownView: View {
336444 }
337445
338446 /// One step below the heading's own size, so code in a heading tracks it.
447 private func headingStyle(_ level: Int) -> UIFont.TextStyle {
448 switch level {
449 case 1: .title2
450 case 2: .title3
451 case 3: .headline
452 default: .subheadline
453 }
454 }
455
339456 private func headingCodeStyle(_ level: Int) -> Font.TextStyle {
340457 switch level {
341458 case 1: .title3
@@ -356,6 +473,7 @@ private struct MarkdownTableView: View {
356473 let table: MarkdownView.Table
357474 var linkOptions: OrgRenderOptions?
358475 var referenceRepo: String?
476 var mathSize: CGFloat?
359477
360478 private static let spacing: CGFloat = 14
361479
@@ -390,7 +508,8 @@ private struct MarkdownTableView: View {
390508
391509 private func cell(_ text: String, _ column: Int) -> some View {
392510 let alignment = table.alignments[column]
393 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo)
511 return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo,
512 mathSize: mathSize)
394513 .multilineTextAlignment(Self.textAlignment(alignment))
395514 .frame(width: cellWidth, alignment: Self.frameAlignment(alignment))
396515 .fixedSize(horizontal: cellWidth == nil, vertical: true)
gitbay/Views/Shared/MathRendering.swift added +205
@@ -0,0 +1,205 @@
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 /// 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
24
25 private final class Entry {
26 let value: RenderedMath?
27 init(_ value: RenderedMath?) { self.value = value }
28 }
29
30 private static let cache: NSCache<NSString, Entry> = {
31 let cache = NSCache<NSString, Entry>()
32 cache.countLimit = 500
33 cache.totalCostLimit = 64 << 20
34 return cache
35 }()
36
37 static func render(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
38 let key = "\(display) \(pointSize) \(tex)" as NSString
39 if let entry = cache.object(forKey: key) { return entry.value }
40 let value = typeset(tex, display: display, pointSize: pointSize)
41 cache.setObject(Entry(value), forKey: key, cost: value.map { $0.image.bytesPerRow * $0.image.height } ?? 0)
42 return value
43 }
44
45 private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
46 guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
47 tex.utf8.count <= maxInput, admits(tex) else { return nil }
48 let latex = swiftMath(tex)
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)
60 let (error, image, layout) = math.asImage()
61 guard error == nil, let image, let cgImage = image.cgImage, let layout,
62 image.size.width > 0, image.size.height > 0 else { return nil }
63 return RenderedMath(image: cgImage, scale: image.scale, descent: layout.descent)
64 }
65
66 /// Whether `tex` nests groups, `\left…\right` and environments no
67 /// deeper than `maxDepth`, has at most `maxCommands` commands, and has no
68 /// unescaped `$`, `#` or `%`, which the server's converter refuses, and no
69 /// character outside printable ASCII, which SwiftMath leaves out without
70 /// an error.
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 }
74 var depth = 0
75 var commands = 0
76 var chars = tex[...]
77 while let c = chars.popFirst() {
78 switch c {
79 case "\\":
80 let name = chars.prefix(while: \.isLetter)
81 if name.isEmpty {
82 chars = chars.dropFirst()
83 continue
84 }
85 chars = chars.dropFirst(name.count)
86 commands += 1
87 if commands > maxCommands { return false }
88 if name == "left" || name == "begin" { depth += 1 }
89 if name == "right" || name == "end" { depth -= 1 }
90 case "{": depth += 1
91 case "}": depth -= 1
92 case "$", "#", "%": return false
93 default: continue
94 }
95 if depth > maxDepth { return false }
96 }
97 return true
98 }
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
159 /// The expression as text: the image, set on the baseline and tinted
160 /// like the text around it, or the source where it is refused.
161 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text {
162 guard let rendered = render(math.tex, display: math.display, pointSize: pointSize) else {
163 return Text(verbatim: math.source)
164 }
165 return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
166 .renderingMode(.template))
167 .baselineOffset(-rendered.descent)
168 }
169}
170
171/// A display block: centred, scrolling sideways when wider than the screen,
172/// or the source as code where it is refused.
173struct MathBlockView: View {
174 let math: MathExpression
175 let pointSize: CGFloat
176
177 var body: some View {
178 if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) {
179 let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
180 .renderingMode(.template)
181 .padding(.vertical, 4)
182 ViewThatFits(in: .horizontal) {
183 image.frame(maxWidth: .infinity)
184 ScrollView(.horizontal, showsIndicators: false) { image }
185 }
186 } else {
187 ScrollView(.horizontal) {
188 Text(math.source)
189 .font(.gbMono(.caption))
190 .padding(10)
191 }
192 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
193 }
194 }
195}
196
197extension UIFont {
198 /// The point size of `style` at a Dynamic Type size, for typesetting
199 /// math beside text set in that style.
200 static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat {
201 preferredFont(forTextStyle: style,
202 compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size)))
203 .pointSize
204 }
205}
gitbay/Views/Shared/TeXMath.swift added +183
@@ -0,0 +1,183 @@
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 // 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
92 while i < chars.count {
93 let c = chars[i]
94 switch c {
95 case "\\":
96 prose += String(chars[i..<min(i + 2, chars.count)])
97 i += 2
98 case "`":
99 var run = 1
100 while i + run < chars.count, chars[i + run] == "`" { run += 1 }
101 var j = i + run
102 var close: Int?
103 while j < chars.count {
104 guard chars[j] == "`" else { j += 1; continue }
105 var k = 1
106 while j + k < chars.count, chars[j + k] == "`" { k += 1 }
107 if k == run { close = j + k; break }
108 j += k
109 }
110 let end = close ?? i + run
111 prose += String(chars[i..<end])
112 i = end
113 case "]" where i + 1 < chars.count && chars[i + 1] == "(":
114 let end = chars[(i + 2)...].firstIndex(of: ")").map { $0 + 1 } ?? chars.count
115 prose += String(chars[i..<end])
116 i = end
117 case "$" where i + 1 < chars.count && chars[i + 1] == "$":
118 var j = i + 2
119 var close: Int?
120 while j + 1 < chars.count {
121 if chars[j] == "\\" { j += 2; continue }
122 if chars[j] == "$", chars[j + 1] == "$" { close = j; break }
123 j += 1
124 }
125 if let close, close > i + 2 {
126 flush()
127 let tex = String(chars[(i + 2)..<close])
128 pieces.append(.math(MathExpression(tex: tex, display: true, source: "$$" + tex + "$$")))
129 i = close + 2
130 } else {
131 prose += "$$"
132 i += 2
133 }
134 case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]):
135 if let close = firstCloser(atOrAfter: i + 2) {
136 flush()
137 let tex = String(chars[(i + 1)..<close])
138 pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$")))
139 i = close + 1
140 } else {
141 prose.append(c)
142 i += 1
143 }
144 default:
145 prose.append(c)
146 i += 1
147 }
148 }
149 flush()
150 return pieces
151}
152
153/// A display block at the head of `lines`, by the web's rules: a line that
154/// is `$$` alone, closed by a line ending in `$$` before a blank line, or a
155/// line that is `$$…$$` whole. Returns the expression and the lines it
156/// spans.
157nonisolated func markdownMathBlock(_ lines: ArraySlice<String>) -> (MathExpression, lines: Int)? {
158 guard let first = lines.first else { return nil }
159 let opener = first.trimmingCharacters(in: .whitespaces)
160 guard opener.hasPrefix("$$") else { return nil }
161 let rest = String(opener.dropFirst(2))
162
163 if rest.isEmpty {
164 var tex = ""
165 var source = "$$\n"
166 for (offset, line) in lines.dropFirst().enumerated() {
167 if line.trimmingCharacters(in: .whitespaces).isEmpty { return nil }
168 let trimmed = String(line.reversed().drop(while: { $0 == " " || $0 == "\t" }).reversed())
169 if trimmed.hasSuffix("$$") {
170 tex += trimmed.dropLast(2)
171 source += trimmed
172 return (MathExpression(tex: tex, display: true, source: source), offset + 2)
173 }
174 tex += line + "\n"
175 source += line + "\n"
176 }
177 return nil
178 }
179 if rest.count > 2, rest.hasSuffix("$$") {
180 return (MathExpression(tex: String(rest.dropLast(2)), display: true, source: opener), 1)
181 }
182 return nil
183}
gitbayTests/MathTests.swift added +189
@@ -0,0 +1,189 @@
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 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
62 @Test func theDocumentBudgetEscapesWhatIsLeft() {
63 var budget = MathBudget(expressions: 1, bytes: 100)
64 let text = budget.limit("$a$ and $b\\$c$")
65 #expect(text == #"$a$ and \$b\\$c\$"#)
66 #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)])
67 }
68}
69
70@MainActor
71struct MarkdownMathBlockTests {
72
73 private func blocks(_ markdown: String) -> [MarkdownView.Block] {
74 MarkdownView(markdown: markdown).blocks
75 }
76
77 @Test func doubleDollarLinesFenceABlock() {
78 #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression(
79 tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))])
80 }
81
82 @Test func theClosingDollarsMayEndALine() {
83 #expect(blocks("$$\nx^2 $$") == [.math(MathExpression(
84 tex: "x^2 ", display: true, source: "$$\nx^2 $$"))])
85 }
86
87 @Test func aWholeLineIsABlock() {
88 #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))])
89 }
90
91 @Test func aBlankLineBeforeTheCloserLeavesProse() {
92 #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")])
93 }
94
95 @Test func aBlockInterruptsAParagraph() {
96 #expect(blocks("text\n$$x$$\nafter") == [
97 .paragraph("text"),
98 .math(MathExpression(tex: "x", display: true, source: "$$x$$")),
99 .paragraph("after"),
100 ])
101 }
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
115 @Test func aBlockPastTheBudgetIsShownAsSource() {
116 let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n")
117 #expect(blocks(many).last == .code("$$x$$"))
118 }
119}
120
121@MainActor
122struct MathRenderingTests {
123
124 @Test func rendersAnExpression() {
125 #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil)
126 #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil)
127 }
128
129 @Test func refusesWhatSwiftMathCannotParse() {
130 #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil)
131 }
132
133 @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() {
134 #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil)
135 #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil)
136 #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil)
137 }
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
168 @Test func refusesTooLongOrTooDeepBeforeParsing() {
169 #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil)
170 let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65)
171 #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil)
172 let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64)
173 #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil)
174 }
175
176 @Test func malformedInputDoesNotCrash() {
177 let inputs = [
178 "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3",
179 "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\",
180 "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{",
181 "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits",
182 "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}",
183 ]
184 for tex in inputs {
185 _ = MathRenderer.render(tex, display: true, pointSize: 17)
186 _ = MathRenderer.render(tex, display: false, pointSize: 17)
187 }
188 }
189}