gitbay/Views/Shared/MathRendering.swift

v1.13.0
gitbay-ios/gitbay/Views/Shared/MathRendering.swift history · blame · raw

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}