gitbay/Views/Shared/MathRendering.swift
269 lines · 11026 bytes
16 symbols in this file
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 the size of
175 /// the text it sits in.
176 static func org(dynamicTypeSize: DynamicTypeSize, allowance: MathAllowance) -> OrgMathRenderer {
177 { math, style in
178 guard allowance.admits(math) else { return nil }
179 return drawn(math.tex, display: math.display, pointSize: UIFont.pointSize(style, dynamicTypeSize))
180 }
181 }
182}
183
184/// The web's per-document budget for org, which is drawn view by view: the first
185/// expressions asked for are admitted, and keep their place on later passes.
186final class MathAllowance {
187 private let start: MathBudget
188 private var budget: MathBudget
189 private var admitted: Set<OrgMath> = []
190 private var text: String?
191
192 init(budget: MathBudget = .document) {
193 start = budget
194 self.budget = budget
195 }
196
197 /// Starts over when the document changes.
198 func reset(for text: String) {
199 guard text != self.text else { return }
200 self.text = text
201 budget = start
202 admitted = []
203 }
204
205 func admits(_ math: OrgMath) -> Bool {
206 if admitted.contains(math) { return true }
207 guard budget.take(math.tex) else { return false }
208 admitted.insert(math)
209 return true
210 }
211}
212
213/// A display block: centred, scrolling sideways when wider than the screen,
214/// or the source as code where it is refused.
215struct MathBlockView: View {
216 let math: MathExpression
217 let pointSize: CGFloat
218
219 var body: some View {
220 if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) {
221 let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex))
222 .renderingMode(.template)
223 .padding(.vertical, 4)
224 ViewThatFits(in: .horizontal) {
225 image.frame(maxWidth: .infinity)
226 ScrollView(.horizontal, showsIndicators: false) { image }
227 }
228 } else {
229 ScrollView(.horizontal) {
230 Text(math.source)
231 .font(.gbMono(.caption))
232 .padding(10)
233 }
234 .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2))
235 }
236 }
237}
238
239extension UIFont {
240 /// The point size of `style` at a Dynamic Type size, for typesetting
241 /// math beside text set in that style.
242 static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat {
243 preferredFont(forTextStyle: style,
244 compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size)))
245 .pointSize
246 }
247
248 static func pointSize(_ style: Font.TextStyle, _ size: DynamicTypeSize) -> CGFloat {
249 pointSize(UIFont.TextStyle(style), size)
250 }
251}
252
253extension UIFont.TextStyle {
254 init(_ style: Font.TextStyle) {
255 switch style {
256 case .largeTitle: self = .largeTitle
257 case .title: self = .title1
258 case .title2: self = .title2
259 case .title3: self = .title3
260 case .headline: self = .headline
261 case .subheadline: self = .subheadline
262 case .callout: self = .callout
263 case .footnote: self = .footnote
264 case .caption: self = .caption1
265 case .caption2: self = .caption2
266 default: self = .body
267 }
268 }
269}