gitbay/Views/Shared/MathRendering.swift
245 lines · 10223 bytes
1import OrgSwift
2import OrgSwiftUI
3import SwiftMath
4import SwiftUI
5import UIKit
6
7/// One typeset expression: an alpha mask, tinted by the text around it, and
8/// how far its baseline sits above the bottom edge.
9struct RenderedMath {
10 let image: CGImage
11 let scale: CGFloat
12 let descent: CGFloat
13}
14
15/// Typesets TeX with SwiftMath. Input is bounded as the server's converter
16/// bounds it (internal/texmath), so a pathological body cannot run the
17/// parser deep or long; anything refused shows as its source.
18enum MathRenderer {
19 static let maxInput = 8 << 10
20 static let maxDepth = 64
21 /// SwiftMath recurses once per command that takes an argument, so the
22 /// count bounds nesting that braces do not show.
23 static let maxCommands = 200
24 /// The longest side of an image, in points.
25 static let maxSide: CGFloat = 4096
26
27 private final class Entry {
28 let value: RenderedMath?
29 init(_ value: RenderedMath?) { self.value = value }
30 }
31
32 private static let cache: NSCache<NSString, Entry> = {
33 let cache = NSCache<NSString, Entry>()
34 cache.countLimit = 500
35 cache.totalCostLimit = 64 << 20
36 return cache
37 }()
38
39 static func render(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
40 let key = "\(display) \(pointSize) \(tex)" as NSString
41 if let entry = cache.object(forKey: key) { return entry.value }
42 let value = typeset(tex, display: display, pointSize: pointSize)
43 cache.setObject(Entry(value), forKey: key, cost: value.map { $0.image.bytesPerRow * $0.image.height } ?? 0)
44 return value
45 }
46
47 private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? {
48 guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
49 tex.utf8.count <= maxInput, admits(tex) else { return nil }
50 let latex = swiftMath(tex)
51 let mode: MTMathUILabelMode = display ? .display : .text
52 // Measured before it is drawn, so an expression too large to show never
53 // allocates its bitmap.
54 let label = MTMathUILabel()
55 label.labelMode = mode
56 label.fontSize = pointSize
57 label.latex = latex
58 let size = label.intrinsicContentSize
59 guard label.error == nil, size.width <= maxSide, size.height <= maxSide else { return nil }
60 var math = MathImage(latex: latex, fontSize: pointSize, textColor: .black,
61 labelMode: mode, textAlignment: .left)
62 let (error, image, layout) = math.asImage()
63 guard error == nil, let image, let cgImage = image.cgImage, let layout,
64 image.size.width > 0, image.size.height > 0 else { return nil }
65 return RenderedMath(image: cgImage, scale: image.scale, descent: layout.descent)
66 }
67
68 /// Whether `tex` nests groups, `\left…\right` and environments no
69 /// deeper than `maxDepth`, has at most `maxCommands` commands, and has no
70 /// unescaped `$`, `#` or `%`, which the server's converter refuses, and no
71 /// character outside printable ASCII, which SwiftMath leaves out without
72 /// an error.
73 static func admits(_ tex: String) -> Bool {
74 guard tex.unicodeScalars.allSatisfy({ (0x20...0x7E).contains($0.value) || $0 == "\n" || $0 == "\t" })
75 else { return false }
76 var depth = 0
77 var commands = 0
78 var chars = tex[...]
79 while let c = chars.popFirst() {
80 switch c {
81 case "\\":
82 let name = chars.prefix(while: \.isLetter)
83 if name.isEmpty {
84 chars = chars.dropFirst()
85 continue
86 }
87 chars = chars.dropFirst(name.count)
88 commands += 1
89 if commands > maxCommands { return false }
90 if name == "left" || name == "begin" { depth += 1 }
91 if name == "right" || name == "end" { depth -= 1 }
92 case "{": depth += 1
93 case "}": depth -= 1
94 case "$", "#", "%": return false
95 default: continue
96 }
97 if depth > maxDepth { return false }
98 }
99 return true
100 }
101
102 private static let commands: [Substring: String] = [
103 "dfrac": "frac", "tfrac": "frac", "cfrac": "frac",
104 "dbinom": "binom", "tbinom": "binom",
105 "operatorname": "mathrm", "boldsymbol": "mathbf", "bm": "mathbf", "mathscr": "mathcal",
106 "textrm": "text", "textnormal": "text", "mbox": "text", "middle": "",
107 ]
108
109 private static let environments: [Substring: String] = [
110 "align": "aligned", "align*": "aligned", "gathered": "gather", "gather*": "gather",
111 "smallmatrix": "matrix", "equation": "", "equation*": "", "displaymath": "",
112 ]
113
114 /// The web's spellings in SwiftMath's: the `\frac` and `\binom` variants,
115 /// font and operator names, primes, `~`, and the environments it names
116 /// differently. An environment that only displays its body is dropped.
117 static func swiftMath(_ tex: String) -> String {
118 var out = ""
119 var chars = tex[...]
120 while let c = chars.popFirst() {
121 switch c {
122 case "\\":
123 let name = chars.prefix(while: \.isLetter)
124 guard !name.isEmpty else {
125 out.append(c)
126 if let next = chars.popFirst() { out.append(next) }
127 continue
128 }
129 chars = chars.dropFirst(name.count)
130 if name == "operatorname", chars.first == "*" { chars = chars.dropFirst() }
131 // An operator name is upright with a thin space after it.
132 if name == "operatorname", chars.first == "{", let close = chars.firstIndex(of: "}") {
133 out += "\\mathrm" + chars[...close] + "\\,"
134 chars = chars[chars.index(after: close)...]
135 continue
136 }
137 if name == "begin" || name == "end", chars.first == "{",
138 let close = chars.firstIndex(of: "}") {
139 let env = chars[chars.index(after: chars.startIndex)..<close]
140 if let renamed = environments[env] {
141 chars = chars[chars.index(after: close)...]
142 if !renamed.isEmpty { out += "\\\(name){\(renamed)}" }
143 continue
144 }
145 }
146 let renamed = commands[name] ?? String(name)
147 if !renamed.isEmpty { out += "\\" + renamed }
148 case "'":
149 var primes = 1
150 while chars.first == "'" { primes += 1; chars = chars.dropFirst() }
151 out += "^{" + String(repeating: "\\prime", count: primes) + "}"
152 case "~":
153 out += "\\;"
154 default:
155 out.append(c)
156 }
157 }
158 return out
159 }
160
161 /// The expression as text: the image, set on the baseline and tinted
162 /// like the text around it, or the source where it is refused.
163 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text {
164 drawn(math.tex, display: math.display, pointSize: pointSize) ?? Text(verbatim: math.source)
165 }
166
167 static func drawn(_ tex: String, display: Bool, pointSize: CGFloat) -> Text? {
168 guard let rendered = render(tex, display: display, pointSize: pointSize) else { return nil }
169 return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: tex))
170 .renderingMode(.template))
171 .baselineOffset(-rendered.descent)
172 }
173
174 /// `OrgView`'s math hook: each expression the allowance admits, drawn at `pointSize`.
175 static func org(pointSize: CGFloat, allowance: MathAllowance) -> OrgMathRenderer {
176 { math in
177 allowance.admits(math) ? drawn(math.tex, display: math.display, pointSize: pointSize) : nil
178 }
179 }
180}
181
182/// The web's per-document budget for org, which is drawn view by view: the first
183/// expressions asked for are admitted, and keep their place on later passes.
184final class MathAllowance {
185 private let start: MathBudget
186 private var budget: MathBudget
187 private var admitted: Set<OrgMath> = []
188 private var text: String?
189
190 init(budget: MathBudget = .document) {
191 start = budget
192 self.budget = budget
193 }
194
195 /// Starts over when the document changes.
196 func reset(for text: String) {
197 guard text != self.text else { return }
198 self.text = text
199 budget = start
200 admitted = []
201 }
202
203 func admits(_ math: OrgMath) -> Bool {
204 if admitted.contains(math) { return true }
205 guard budget.take(math.tex) else { return false }
206 admitted.insert(math)
207 return true
208 }
209}
210
211/// A display block: centred, scrolling sideways when wider than the screen,
212/// or the source as code where it is refused.
213struct MathBlockView: View {
214 let math: MathExpression
215 let pointSize: CGFloat
216
217 var body: some View {
218 if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) {
219 let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
220 .renderingMode(.template)
221 .padding(.vertical, 4)
222 ViewThatFits(in: .horizontal) {
223 image.frame(maxWidth: .infinity)
224 ScrollView(.horizontal, showsIndicators: false) { image }
225 }
226 } else {
227 ScrollView(.horizontal) {
228 Text(math.source)
229 .font(.gbMono(.caption))
230 .padding(10)
231 }
232 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
233 }
234 }
235}
236
237extension UIFont {
238 /// The point size of `style` at a Dynamic Type size, for typesetting
239 /// math beside text set in that style.
240 static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat {
241 preferredFont(forTextStyle: style,
242 compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size)))
243 .pointSize
244 }
245}