gitbay/Views/Shared/MathRendering.swift

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

269 lines · 11026 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 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}