gitbay/Views/Shared/TeXMath.swift
183 lines · 6669 bytes
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}