Commit f08e9a3415
Verified · cmc
Layout: unified · split
gitbay/Views/Repos/MarkdownView.swift +43 −10
| @@ -55,7 +55,7 @@ struct MarkdownView: View { | ||
| 55 | 55 | |
| 56 | 56 | private var parsedBlocks: [Block] { |
| 57 | 57 | var blocks: [Block] = [] |
| 58 | var lines = markdown.components(separatedBy: "\n")[...] | |
| 58 | var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...] | |
| 59 | 59 | |
| 60 | 60 | while let line = lines.first { |
| 61 | 61 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| @@ -163,10 +163,14 @@ struct MarkdownView: View { | ||
| 163 | 163 | return blocks |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | /// The blocks with math past the document's budget left as source, in | |
| 167 | /// reading order as the web counts it. | |
| 168 | private static func budgeted(_ blocks: [Block]) -> [Block] { | |
| 169 | var budget = MathBudget.document | |
| 166 | /// The blocks with math past the document's budget left as source. The | |
| 167 | /// web parses blocks before inlines, so display blocks are counted first. | |
| 168 | static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] { | |
| 169 | var budget = budget | |
| 170 | let blocks = blocks.map { block -> Block in | |
| 171 | guard case .math(let math) = block else { return block } | |
| 172 | return budget.take(math.tex) ? block : .code(math.source) | |
| 173 | } | |
| 170 | 174 | return blocks.map { block -> Block in |
| 171 | 175 | switch block { |
| 172 | 176 | case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) |
| @@ -178,8 +182,7 @@ struct MarkdownView: View { | ||
| 178 | 182 | table.header = table.header.map { budget.limit($0) } |
| 179 | 183 | table.rows = table.rows.map { $0.map { budget.limit($0) } } |
| 180 | 184 | return .table(table) |
| 181 | case .math(let math): return budget.take(math.tex) ? .math(math) : .code(math.source) | |
| 182 | case .code, .rule: return block | |
| 185 | case .math, .code, .rule: return block | |
| 183 | 186 | } |
| 184 | 187 | } |
| 185 | 188 | } |
| @@ -359,15 +362,45 @@ struct MarkdownView: View { | ||
| 359 | 362 | let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, |
| 360 | 363 | referenceRepo: referenceRepo) |
| 361 | 364 | |
| 365 | // Display math sits on a line of its own, as the web's block-level | |
| 366 | // <math> does. A mark compares by its first scalar, since a combining | |
| 367 | // character after it joins the same Character. | |
| 362 | 368 | var result = Text(verbatim: "") |
| 363 | 369 | var next = maths.makeIterator() |
| 364 | 370 | var run = rendered.startIndex |
| 365 | for index in rendered.characters.indices where rendered.characters[index] == Character(mark) { | |
| 371 | var afterDisplay = false | |
| 372 | func prose(_ upper: AttributedString.Index, trimEnd: Bool) -> Text { | |
| 373 | var piece = AttributedString(rendered[run..<upper]) | |
| 374 | if afterDisplay, let first = piece.characters.firstIndex(where: { !$0.isWhitespace }) { | |
| 375 | piece = AttributedString(piece[first...]) | |
| 376 | } | |
| 377 | if trimEnd, let last = piece.characters.lastIndex(where: { !$0.isWhitespace }) { | |
| 378 | piece = AttributedString(piece[...last]) | |
| 379 | } | |
| 380 | return Text(piece) | |
| 381 | } | |
| 382 | for index in rendered.characters.indices | |
| 383 | where rendered.characters[index].unicodeScalars.first == mark { | |
| 366 | 384 | let math = next.next() |
| 367 | result = Text("\(result)\(Text(AttributedString(rendered[run..<index])))\(math.map { MathRenderer.text($0, pointSize: mathSize) } ?? Text(verbatim: ""))") | |
| 385 | let display = math?.display == true | |
| 386 | let before = prose(index, trimEnd: display) | |
| 368 | 387 | run = rendered.characters.index(after: index) |
| 388 | guard let math else { | |
| 389 | result = Text("\(result)\(before)") | |
| 390 | afterDisplay = false | |
| 391 | continue | |
| 392 | } | |
| 393 | let drawn = MathRenderer.text(math, pointSize: mathSize) | |
| 394 | if display { | |
| 395 | let lead = index == rendered.startIndex ? "" : "\n" | |
| 396 | let trail = run == rendered.endIndex ? "" : "\n" | |
| 397 | result = Text("\(result)\(before)\(lead)\(drawn)\(trail)") | |
| 398 | } else { | |
| 399 | result = Text("\(result)\(before)\(drawn)") | |
| 400 | } | |
| 401 | afterDisplay = display | |
| 369 | 402 | } |
| 370 | return Text("\(result)\(Text(AttributedString(rendered[run...])))") | |
| 403 | return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))") | |
| 371 | 404 | } |
| 372 | 405 | |
| 373 | 406 | static func attributed( |
gitbay/Views/Shared/MathRendering.swift +88 −6
| @@ -16,6 +16,11 @@ struct RenderedMath { | ||
| 16 | 16 | enum MathRenderer { |
| 17 | 17 | static let maxInput = 8 << 10 |
| 18 | 18 | static let maxDepth = 64 |
| 19 | /// SwiftMath recurses once per command that takes an argument, so the | |
| 20 | /// count bounds nesting that braces do not show. | |
| 21 | static let maxCommands = 200 | |
| 22 | /// The longest side of an image, in points. | |
| 23 | static let maxSide: CGFloat = 4096 | |
| 19 | 24 | |
| 20 | 25 | private final class Entry { |
| 21 | 26 | let value: RenderedMath? |
| @@ -25,6 +30,7 @@ enum MathRenderer { | ||
| 25 | 30 | private static let cache: NSCache<NSString, Entry> = { |
| 26 | 31 | let cache = NSCache<NSString, Entry>() |
| 27 | 32 | cache.countLimit = 500 |
| 33 | cache.totalCostLimit = 64 << 20 | |
| 28 | 34 | return cache |
| 29 | 35 | }() |
| 30 | 36 | |
| @@ -32,15 +38,25 @@ enum MathRenderer { | ||
| 32 | 38 | let key = "\(display) \(pointSize) \(tex)" as NSString |
| 33 | 39 | if let entry = cache.object(forKey: key) { return entry.value } |
| 34 | 40 | let value = typeset(tex, display: display, pointSize: pointSize) |
| 35 | cache.setObject(Entry(value), forKey: key) | |
| 41 | cache.setObject(Entry(value), forKey: key, cost: value.map { $0.image.bytesPerRow * $0.image.height } ?? 0) | |
| 36 | 42 | return value |
| 37 | 43 | } |
| 38 | 44 | |
| 39 | 45 | private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { |
| 40 | 46 | guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
| 41 | 47 | tex.utf8.count <= maxInput, admits(tex) else { return nil } |
| 42 | var math = MathImage(latex: tex, fontSize: pointSize, textColor: .black, | |
| 43 | labelMode: display ? .display : .text, textAlignment: .left) | |
| 48 | let latex = swiftMath(tex) | |
| 49 | let mode: MTMathUILabelMode = display ? .display : .text | |
| 50 | // Measured before it is drawn, so an expression too large to show never | |
| 51 | // allocates its bitmap. | |
| 52 | let label = MTMathUILabel() | |
| 53 | label.labelMode = mode | |
| 54 | label.fontSize = pointSize | |
| 55 | label.latex = latex | |
| 56 | let size = label.intrinsicContentSize | |
| 57 | guard label.error == nil, size.width <= maxSide, size.height <= maxSide else { return nil } | |
| 58 | var math = MathImage(latex: latex, fontSize: pointSize, textColor: .black, | |
| 59 | labelMode: mode, textAlignment: .left) | |
| 44 | 60 | let (error, image, layout) = math.asImage() |
| 45 | 61 | guard error == nil, let image, let cgImage = image.cgImage, let layout, |
| 46 | 62 | image.size.width > 0, image.size.height > 0 else { return nil } |
| @@ -48,10 +64,15 @@ enum MathRenderer { | ||
| 48 | 64 | } |
| 49 | 65 | |
| 50 | 66 | /// Whether `tex` nests groups, `\left…\right` and environments no |
| 51 | /// deeper than `maxDepth` and has no unescaped `$` or `#`, which the | |
| 52 | /// server's converter refuses and SwiftMath would set as symbols. | |
| 67 | /// deeper than `maxDepth`, has at most `maxCommands` commands, and has no | |
| 68 | /// unescaped `$`, `#` or `%`, which the server's converter refuses, and no | |
| 69 | /// character outside printable ASCII, which SwiftMath leaves out without | |
| 70 | /// an error. | |
| 53 | 71 | static func admits(_ tex: String) -> Bool { |
| 72 | guard tex.unicodeScalars.allSatisfy({ (0x20...0x7E).contains($0.value) || $0 == "\n" || $0 == "\t" }) | |
| 73 | else { return false } | |
| 54 | 74 | var depth = 0 |
| 75 | var commands = 0 | |
| 55 | 76 | var chars = tex[...] |
| 56 | 77 | while let c = chars.popFirst() { |
| 57 | 78 | switch c { |
| @@ -62,11 +83,13 @@ enum MathRenderer { | ||
| 62 | 83 | continue |
| 63 | 84 | } |
| 64 | 85 | chars = chars.dropFirst(name.count) |
| 86 | commands += 1 | |
| 87 | if commands > maxCommands { return false } | |
| 65 | 88 | if name == "left" || name == "begin" { depth += 1 } |
| 66 | 89 | if name == "right" || name == "end" { depth -= 1 } |
| 67 | 90 | case "{": depth += 1 |
| 68 | 91 | case "}": depth -= 1 |
| 69 | case "$", "#": return false | |
| 92 | case "$", "#", "%": return false | |
| 70 | 93 | default: continue |
| 71 | 94 | } |
| 72 | 95 | if depth > maxDepth { return false } |
| @@ -74,6 +97,65 @@ enum MathRenderer { | ||
| 74 | 97 | return true |
| 75 | 98 | } |
| 76 | 99 | |
| 100 | private static let commands: [Substring: String] = [ | |
| 101 | "dfrac": "frac", "tfrac": "frac", "cfrac": "frac", | |
| 102 | "dbinom": "binom", "tbinom": "binom", | |
| 103 | "operatorname": "mathrm", "boldsymbol": "mathbf", "bm": "mathbf", "mathscr": "mathcal", | |
| 104 | "textrm": "text", "textnormal": "text", "mbox": "text", "middle": "", | |
| 105 | ] | |
| 106 | ||
| 107 | private static let environments: [Substring: String] = [ | |
| 108 | "align": "aligned", "align*": "aligned", "gathered": "gather", "gather*": "gather", | |
| 109 | "smallmatrix": "matrix", "equation": "", "equation*": "", "displaymath": "", | |
| 110 | ] | |
| 111 | ||
| 112 | /// The web's spellings in SwiftMath's: the `\frac` and `\binom` variants, | |
| 113 | /// font and operator names, primes, `~`, and the environments it names | |
| 114 | /// differently. An environment that only displays its body is dropped. | |
| 115 | static func swiftMath(_ tex: String) -> String { | |
| 116 | var out = "" | |
| 117 | var chars = tex[...] | |
| 118 | while let c = chars.popFirst() { | |
| 119 | switch c { | |
| 120 | case "\\": | |
| 121 | let name = chars.prefix(while: \.isLetter) | |
| 122 | guard !name.isEmpty else { | |
| 123 | out.append(c) | |
| 124 | if let next = chars.popFirst() { out.append(next) } | |
| 125 | continue | |
| 126 | } | |
| 127 | chars = chars.dropFirst(name.count) | |
| 128 | if name == "operatorname", chars.first == "*" { chars = chars.dropFirst() } | |
| 129 | // An operator name is upright with a thin space after it. | |
| 130 | if name == "operatorname", chars.first == "{", let close = chars.firstIndex(of: "}") { | |
| 131 | out += "\\mathrm" + chars[...close] + "\\," | |
| 132 | chars = chars[chars.index(after: close)...] | |
| 133 | continue | |
| 134 | } | |
| 135 | if name == "begin" || name == "end", chars.first == "{", | |
| 136 | let close = chars.firstIndex(of: "}") { | |
| 137 | let env = chars[chars.index(after: chars.startIndex)..<close] | |
| 138 | if let renamed = environments[env] { | |
| 139 | chars = chars[chars.index(after: close)...] | |
| 140 | if !renamed.isEmpty { out += "\\\(name){\(renamed)}" } | |
| 141 | continue | |
| 142 | } | |
| 143 | } | |
| 144 | let renamed = commands[name] ?? String(name) | |
| 145 | if !renamed.isEmpty { out += "\\" + renamed } | |
| 146 | case "'": | |
| 147 | var primes = 1 | |
| 148 | while chars.first == "'" { primes += 1; chars = chars.dropFirst() } | |
| 149 | out += "^{" + String(repeating: "\\prime", count: primes) + "}" | |
| 150 | case "~": | |
| 151 | out += "\\;" | |
| 152 | default: | |
| 153 | out.append(c) | |
| 154 | } | |
| 155 | } | |
| 156 | return out | |
| 157 | } | |
| 158 | ||
| 77 | 159 | /// The expression as text: the image, set on the baseline and tinted |
| 78 | 160 | /// like the text around it, or the source where it is refused. |
| 79 | 161 | static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { |
gitbay/Views/Shared/TeXMath.swift +25 −12
| @@ -65,6 +65,30 @@ nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] { | ||
| 65 | 65 | } |
| 66 | 66 | func isSpace(_ c: Character) -> Bool { c == " " || c == "\t" || c == "\n" || c == "\r" } |
| 67 | 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 | ||
| 68 | 92 | while i < chars.count { |
| 69 | 93 | let c = chars[i] |
| 70 | 94 | switch c { |
| @@ -108,18 +132,7 @@ nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] { | ||
| 108 | 132 | i += 2 |
| 109 | 133 | } |
| 110 | 134 | case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]): |
| 111 | var j = i + 1 | |
| 112 | var close: Int? | |
| 113 | while j < chars.count { | |
| 114 | if chars[j] == "\\" { j += 2; continue } | |
| 115 | if chars[j] == "$", j >= i + 2, !isSpace(chars[j - 1]), | |
| 116 | !(j + 1 < chars.count && chars[j + 1].isASCII && chars[j + 1].isNumber) { | |
| 117 | close = j | |
| 118 | break | |
| 119 | } | |
| 120 | j += 1 | |
| 121 | } | |
| 122 | if let close { | |
| 135 | if let close = firstCloser(atOrAfter: i + 2) { | |
| 123 | 136 | flush() |
| 124 | 137 | let tex = String(chars[(i + 1)..<close]) |
| 125 | 138 | pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$"))) |
gitbayTests/MathTests.swift +47
| @@ -53,6 +53,12 @@ struct MarkdownMathSplitTests { | ||
| 53 | 53 | #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")]) |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | @Test func manyUnclosedOpenersSplitQuickly() { | |
| 57 | let text = String(repeating: "$a ", count: 50_000) | |
| 58 | #expect(splitMarkdownMath(text) == [.text(text)]) | |
| 59 | #expect(splitMarkdownMath(text + "$b$").count == 1, "the first opener closes at the end, as on the web") | |
| 60 | } | |
| 61 | ||
| 56 | 62 | @Test func theDocumentBudgetEscapesWhatIsLeft() { |
| 57 | 63 | var budget = MathBudget(expressions: 1, bytes: 100) |
| 58 | 64 | let text = budget.limit("$a$ and $b\\$c$") |
| @@ -94,6 +100,18 @@ struct MarkdownMathBlockTests { | ||
| 94 | 100 | ]) |
| 95 | 101 | } |
| 96 | 102 | |
| 103 | @Test func crlfBodiesFenceBlocksToo() { | |
| 104 | #expect(blocks("$$\r\nx^2\r\n$$") == [.math(MathExpression( | |
| 105 | tex: "x^2\n", display: true, source: "$$\nx^2\n$$"))]) | |
| 106 | } | |
| 107 | ||
| 108 | @Test func blockMathTakesTheBudgetBeforeInlineMath() { | |
| 109 | let budgeted = MarkdownView.budgeted( | |
| 110 | [.paragraph("$a$"), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))], | |
| 111 | budget: MathBudget(expressions: 1, bytes: 100)) | |
| 112 | #expect(budgeted == [.paragraph(#"\$a\$"#), .math(MathExpression(tex: "b", display: true, source: "$$b$$"))]) | |
| 113 | } | |
| 114 | ||
| 97 | 115 | @Test func aBlockPastTheBudgetIsShownAsSource() { |
| 98 | 116 | let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") |
| 99 | 117 | #expect(blocks(many).last == .code("$$x$$")) |
| @@ -118,6 +136,35 @@ struct MathRenderingTests { | ||
| 118 | 136 | #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) |
| 119 | 137 | } |
| 120 | 138 | |
| 139 | @Test func refusesWhatSwiftMathWouldDropSilently() { | |
| 140 | for tex in ["α + β", "x ≤ y", #"\text{日本語}"#, "x % note", "a\u{7}b"] { | |
| 141 | #expect(MathRenderer.render(tex, display: false, pointSize: 17) == nil, "\(tex)") | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | @Test func translatesTheWebsSpellings() { | |
| 146 | #expect(MathRenderer.swiftMath("f'(x) + g''") == #"f^{\prime}(x) + g^{\prime\prime}"#) | |
| 147 | #expect(MathRenderer.swiftMath("a~b") == #"a\;b"#) | |
| 148 | #expect(MathRenderer.swiftMath(#"\dfrac12 \tbinom{n}{k} \operatorname{rank} \boldsymbol{x}"#) | |
| 149 | == #"\frac12 \binom{n}{k} \mathrm{rank}\, \mathbf{x}"#) | |
| 150 | #expect(MathRenderer.swiftMath(#"\begin{align*} a &= b \end{align*}"#) == #"\begin{aligned} a &= b \end{aligned}"#) | |
| 151 | #expect(MathRenderer.swiftMath(#"\begin{equation} x \end{equation}"#) == #" x "#) | |
| 152 | #expect(MathRenderer.swiftMath(#"\dfracx"#) == #"\dfracx"#, "only whole command names") | |
| 153 | for tex in [#"\dfrac{a}{b}"#, #"\operatorname{rank} A"#, #"\begin{align} a &= b \\ c &= d \end{align}"#, | |
| 154 | #"\begin{equation} e = mc^2 \end{equation}"#, #"\begin{gather*} a \\ b \end{gather*}"#, "f'(x)"] { | |
| 155 | #expect(MathRenderer.render(tex, display: true, pointSize: 17) != nil, "\(tex)") | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | @Test func refusesTooManyCommandsOrTooLargeAnImage() { | |
| 160 | let many = String(repeating: #"\alpha "#, count: MathRenderer.maxCommands) | |
| 161 | #expect(MathRenderer.render(many, display: false, pointSize: 17) != nil) | |
| 162 | #expect(MathRenderer.render(many + #"\beta"#, display: false, pointSize: 17) == nil) | |
| 163 | let nested = String(repeating: #"\sqrt"#, count: MathRenderer.maxCommands) + "x" | |
| 164 | _ = MathRenderer.render(nested, display: true, pointSize: 17) | |
| 165 | #expect(MathRenderer.render(String(repeating: "x", count: 4000), display: false, pointSize: 17) == nil) | |
| 166 | } | |
| 167 | ||
| 121 | 168 | @Test func refusesTooLongOrTooDeepBeforeParsing() { |
| 122 | 169 | #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) |
| 123 | 170 | let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) |