gitbay/Views/Shared/TeXMath.swift

183 lines · 6669 bytes

7 symbols in this file
  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}