Commit f08e9a3415
Verified · cmc
Layout: unified · split
gitbay/Views/Repos/MarkdownView.swift +43 −10
| @@ -55,7 +55,7 @@ struct MarkdownView: View { | |||
| 55 | 55 | ||
| 56 | private var parsedBlocks: [Block] { | 56 | private var parsedBlocks: [Block] { |
| 57 | var blocks: [Block] = [] | 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 | while let line = lines.first { | 60 | while let line = lines.first { |
| 61 | let trimmed = line.trimmingCharacters(in: .whitespaces) | 61 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| @@ -163,10 +163,14 @@ struct MarkdownView: View { | |||
| 163 | return blocks | 163 | return blocks |
| 164 | } | 164 | } |
| 165 | 165 | ||
| 166 | /// The blocks with math past the document's budget left as source, in | 166 | /// The blocks with math past the document's budget left as source. The |
| 167 | /// reading order as the web counts it. | 167 | /// web parses blocks before inlines, so display blocks are counted first. |
| 168 | private static func budgeted(_ blocks: [Block]) -> [Block] { | 168 | static func budgeted(_ blocks: [Block], budget: MathBudget = .document) -> [Block] { |
| 169 | var budget = MathBudget.document | 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 | return blocks.map { block -> Block in | 174 | return blocks.map { block -> Block in |
| 171 | switch block { | 175 | switch block { |
| 172 | case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) | 176 | case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) |
| @@ -178,8 +182,7 @@ struct MarkdownView: View { | |||
| 178 | table.header = table.header.map { budget.limit($0) } | 182 | table.header = table.header.map { budget.limit($0) } |
| 179 | table.rows = table.rows.map { $0.map { budget.limit($0) } } | 183 | table.rows = table.rows.map { $0.map { budget.limit($0) } } |
| 180 | return .table(table) | 184 | return .table(table) |
| 181 | case .math(let math): return budget.take(math.tex) ? .math(math) : .code(math.source) | 185 | case .math, .code, .rule: return block |
| 182 | case .code, .rule: return block | ||
| 183 | } | 186 | } |
| 184 | } | 187 | } |
| 185 | } | 188 | } |
| @@ -359,15 +362,45 @@ struct MarkdownView: View { | |||
| 359 | let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, | 362 | let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, |
| 360 | referenceRepo: referenceRepo) | 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 | var result = Text(verbatim: "") | 368 | var result = Text(verbatim: "") |
| 363 | var next = maths.makeIterator() | 369 | var next = maths.makeIterator() |
| 364 | var run = rendered.startIndex | 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 | let math = next.next() | 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 | run = rendered.characters.index(after: index) | 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 | static func attributed( | 406 | static func attributed( |
gitbay/Views/Shared/MathRendering.swift +88 −6
| @@ -16,6 +16,11 @@ struct RenderedMath { | |||
| 16 | enum MathRenderer { | 16 | enum MathRenderer { |
| 17 | static let maxInput = 8 << 10 | 17 | static let maxInput = 8 << 10 |
| 18 | static let maxDepth = 64 | 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 | private final class Entry { | 25 | private final class Entry { |
| 21 | let value: RenderedMath? | 26 | let value: RenderedMath? |
| @@ -25,6 +30,7 @@ enum MathRenderer { | |||
| 25 | private static let cache: NSCache<NSString, Entry> = { | 30 | private static let cache: NSCache<NSString, Entry> = { |
| 26 | let cache = NSCache<NSString, Entry>() | 31 | let cache = NSCache<NSString, Entry>() |
| 27 | cache.countLimit = 500 | 32 | cache.countLimit = 500 |
| 33 | cache.totalCostLimit = 64 << 20 | ||
| 28 | return cache | 34 | return cache |
| 29 | }() | 35 | }() |
| 30 | 36 | ||
| @@ -32,15 +38,25 @@ enum MathRenderer { | |||
| 32 | let key = "\(display) \(pointSize) \(tex)" as NSString | 38 | let key = "\(display) \(pointSize) \(tex)" as NSString |
| 33 | if let entry = cache.object(forKey: key) { return entry.value } | 39 | if let entry = cache.object(forKey: key) { return entry.value } |
| 34 | let value = typeset(tex, display: display, pointSize: pointSize) | 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 | return value | 42 | return value |
| 37 | } | 43 | } |
| 38 | 44 | ||
| 39 | private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { | 45 | private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { |
| 40 | guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, | 46 | guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, |
| 41 | tex.utf8.count <= maxInput, admits(tex) else { return nil } | 47 | tex.utf8.count <= maxInput, admits(tex) else { return nil } |
| 42 | var math = MathImage(latex: tex, fontSize: pointSize, textColor: .black, | 48 | let latex = swiftMath(tex) |
| 43 | labelMode: display ? .display : .text, textAlignment: .left) | 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 | let (error, image, layout) = math.asImage() | 60 | let (error, image, layout) = math.asImage() |
| 45 | guard error == nil, let image, let cgImage = image.cgImage, let layout, | 61 | guard error == nil, let image, let cgImage = image.cgImage, let layout, |
| 46 | image.size.width > 0, image.size.height > 0 else { return nil } | 62 | image.size.width > 0, image.size.height > 0 else { return nil } |
| @@ -48,10 +64,15 @@ enum MathRenderer { | |||
| 48 | } | 64 | } |
| 49 | 65 | ||
| 50 | /// Whether `tex` nests groups, `\left…\right` and environments no | 66 | /// Whether `tex` nests groups, `\left…\right` and environments no |
| 51 | /// deeper than `maxDepth` and has no unescaped `$` or `#`, which the | 67 | /// deeper than `maxDepth`, has at most `maxCommands` commands, and has no |
| 52 | /// server's converter refuses and SwiftMath would set as symbols. | 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 | static func admits(_ tex: String) -> Bool { | 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 | var depth = 0 | 74 | var depth = 0 |
| 75 | var commands = 0 | ||
| 55 | var chars = tex[...] | 76 | var chars = tex[...] |
| 56 | while let c = chars.popFirst() { | 77 | while let c = chars.popFirst() { |
| 57 | switch c { | 78 | switch c { |
| @@ -62,11 +83,13 @@ enum MathRenderer { | |||
| 62 | continue | 83 | continue |
| 63 | } | 84 | } |
| 64 | chars = chars.dropFirst(name.count) | 85 | chars = chars.dropFirst(name.count) |
| 86 | commands += 1 | ||
| 87 | if commands > maxCommands { return false } | ||
| 65 | if name == "left" || name == "begin" { depth += 1 } | 88 | if name == "left" || name == "begin" { depth += 1 } |
| 66 | if name == "right" || name == "end" { depth -= 1 } | 89 | if name == "right" || name == "end" { depth -= 1 } |
| 67 | case "{": depth += 1 | 90 | case "{": depth += 1 |
| 68 | case "}": depth -= 1 | 91 | case "}": depth -= 1 |
| 69 | case "$", "#": return false | 92 | case "$", "#", "%": return false |
| 70 | default: continue | 93 | default: continue |
| 71 | } | 94 | } |
| 72 | if depth > maxDepth { return false } | 95 | if depth > maxDepth { return false } |
| @@ -74,6 +97,65 @@ enum MathRenderer { | |||
| 74 | return true | 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 | /// The expression as text: the image, set on the baseline and tinted | 159 | /// The expression as text: the image, set on the baseline and tinted |
| 78 | /// like the text around it, or the source where it is refused. | 160 | /// like the text around it, or the source where it is refused. |
| 79 | static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { | 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 | func isSpace(_ c: Character) -> Bool { c == " " || c == "\t" || c == "\n" || c == "\r" } | 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 | while i < chars.count { | 92 | while i < chars.count { |
| 69 | let c = chars[i] | 93 | let c = chars[i] |
| 70 | switch c { | 94 | switch c { |
| @@ -108,18 +132,7 @@ nonisolated func splitMarkdownMath(_ text: String) -> [MathPiece] { | |||
| 108 | i += 2 | 132 | i += 2 |
| 109 | } | 133 | } |
| 110 | case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]): | 134 | case "$" where i + 1 < chars.count && !isSpace(chars[i + 1]): |
| 111 | var j = i + 1 | 135 | if let close = firstCloser(atOrAfter: i + 2) { |
| 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 { | ||
| 123 | flush() | 136 | flush() |
| 124 | let tex = String(chars[(i + 1)..<close]) | 137 | let tex = String(chars[(i + 1)..<close]) |
| 125 | pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$"))) | 138 | pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$"))) |
gitbayTests/MathTests.swift +47
| @@ -53,6 +53,12 @@ struct MarkdownMathSplitTests { | |||
| 53 | #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")]) | 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 | @Test func theDocumentBudgetEscapesWhatIsLeft() { | 62 | @Test func theDocumentBudgetEscapesWhatIsLeft() { |
| 57 | var budget = MathBudget(expressions: 1, bytes: 100) | 63 | var budget = MathBudget(expressions: 1, bytes: 100) |
| 58 | let text = budget.limit("$a$ and $b\\$c$") | 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 | @Test func aBlockPastTheBudgetIsShownAsSource() { | 115 | @Test func aBlockPastTheBudgetIsShownAsSource() { |
| 98 | let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") | 116 | let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") |
| 99 | #expect(blocks(many).last == .code("$$x$$")) | 117 | #expect(blocks(many).last == .code("$$x$$")) |
| @@ -118,6 +136,35 @@ struct MathRenderingTests { | |||
| 118 | #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) | 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 | @Test func refusesTooLongOrTooDeepBeforeParsing() { | 168 | @Test func refusesTooLongOrTooDeepBeforeParsing() { |
| 122 | #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) | 169 | #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) |
| 123 | let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) | 170 | let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) |