TeX math in markdown bodies !177
6 files changed, +735 −13
Layout: unified · split
gitbay.xcodeproj/project.pbxproj +17
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */; }; |
| 11 | 11 | 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */; }; |
| 12 | 12 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; |
| 13 | 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */; }; | |
| 13 | 14 | /* End PBXBuildFile section */ |
| 14 | 15 | |
| 15 | 16 | /* Begin PBXContainerItemProxy section */ |
| @@ -61,6 +62,7 @@ | ||
| 61 | 62 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, |
| 62 | 63 | 0F0F0F0F0F0F0F0F00000003 /* OrgSwift in Frameworks */, |
| 63 | 64 | 0F0F0F0F0F0F0F0F00000005 /* OrgSwiftUI in Frameworks */, |
| 65 | 0F0F0F0F0F0F0F0F00000008 /* SwiftMath in Frameworks */, | |
| 64 | 66 | ); |
| 65 | 67 | runOnlyForDeploymentPostprocessing = 0; |
| 66 | 68 | }; |
| @@ -124,6 +126,7 @@ | ||
| 124 | 126 | 8B2898B1303FF3E5001BF44A /* Highlightr */, |
| 125 | 127 | 0F0F0F0F0F0F0F0F00000002 /* OrgSwift */, |
| 126 | 128 | 0F0F0F0F0F0F0F0F00000004 /* OrgSwiftUI */, |
| 129 | 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */, | |
| 127 | 130 | ); |
| 128 | 131 | productName = gitbay; |
| 129 | 132 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; |
| @@ -210,6 +213,7 @@ | ||
| 210 | 213 | packageReferences = ( |
| 211 | 214 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */, |
| 212 | 215 | 0F0F0F0F0F0F0F0F00000001 /* XCRemoteSwiftPackageReference "org-swift" */, |
| 216 | 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */, | |
| 213 | 217 | ); |
| 214 | 218 | preferredProjectObjectVersion = 77; |
| 215 | 219 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; |
| @@ -610,6 +614,14 @@ | ||
| 610 | 614 | minimumVersion = 2.1.0; |
| 611 | 615 | }; |
| 612 | 616 | }; |
| 617 | 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */ = { | |
| 618 | isa = XCRemoteSwiftPackageReference; | |
| 619 | repositoryURL = "https://github.com/mgriebling/SwiftMath"; | |
| 620 | requirement = { | |
| 621 | kind = upToNextMinorVersion; | |
| 622 | minimumVersion = 1.7.3; | |
| 623 | }; | |
| 624 | }; | |
| 613 | 625 | /* End XCRemoteSwiftPackageReference section */ |
| 614 | 626 | |
| 615 | 627 | /* Begin XCSwiftPackageProductDependency section */ |
| @@ -628,6 +640,11 @@ | ||
| 628 | 640 | package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */; |
| 629 | 641 | productName = Highlightr; |
| 630 | 642 | }; |
| 643 | 0F0F0F0F0F0F0F0F00000007 /* SwiftMath */ = { | |
| 644 | isa = XCSwiftPackageProductDependency; | |
| 645 | package = 0F0F0F0F0F0F0F0F00000006 /* XCRemoteSwiftPackageReference "SwiftMath" */; | |
| 646 | productName = SwiftMath; | |
| 647 | }; | |
| 631 | 648 | /* End XCSwiftPackageProductDependency section */ |
| 632 | 649 | }; |
| 633 | 650 | rootObject = 8B28980A303FF3E5001BF44A /* Project object */; |
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "originHash" : "ab8cee97c943a8273a6ba915c015907ce1f3559ff83687bf97758c491bc4a149", | |
| 2 | "originHash" : "6c6c1aa243e0c1fd1b3c665718715a08bea6414406a12f38863948fe4c9065d6", | |
| 3 | 3 | "pins" : [ |
| 4 | 4 | { |
| 5 | 5 | "identity" : "highlightr", |
| @@ -18,6 +18,15 @@ | ||
| 18 | 18 | "revision" : "fa46c3f1577091385a95ac5f5680409f09701004", |
| 19 | 19 | "version" : "0.7.1" |
| 20 | 20 | } |
| 21 | }, | |
| 22 | { | |
| 23 | "identity" : "swiftmath", | |
| 24 | "kind" : "remoteSourceControl", | |
| 25 | "location" : "https://github.com/mgriebling/SwiftMath", | |
| 26 | "state" : { | |
| 27 | "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7", | |
| 28 | "version" : "1.7.3" | |
| 29 | } | |
| 21 | 30 | } |
| 22 | 31 | ], |
| 23 | 32 | "version" : 3 |
gitbay/Views/Repos/MarkdownView.swift +131 −12
| @@ -13,6 +13,8 @@ struct MarkdownView: View { | ||
| 13 | 13 | /// The repository `#N` and `!N` refer to. Nil links no references. |
| 14 | 14 | var referenceRepo: String? |
| 15 | 15 | |
| 16 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize | |
| 17 | ||
| 16 | 18 | var body: some View { |
| 17 | 19 | VStack(alignment: .leading, spacing: 12) { |
| 18 | 20 | ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in |
| @@ -32,6 +34,7 @@ struct MarkdownView: View { | ||
| 32 | 34 | case table(Table) |
| 33 | 35 | case rule |
| 34 | 36 | case paragraph(String) |
| 37 | case math(MathExpression) | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | /// A GitHub-flavored table: a header row, a body, and one alignment per |
| @@ -47,8 +50,12 @@ struct MarkdownView: View { | ||
| 47 | 50 | } |
| 48 | 51 | |
| 49 | 52 | var blocks: [Block] { |
| 53 | Self.budgeted(parsedBlocks) | |
| 54 | } | |
| 55 | ||
| 56 | private var parsedBlocks: [Block] { | |
| 50 | 57 | var blocks: [Block] = [] |
| 51 | var lines = markdown.components(separatedBy: "\n")[...] | |
| 58 | var lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")[...] | |
| 52 | 59 | |
| 53 | 60 | while let line = lines.first { |
| 54 | 61 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| @@ -65,6 +72,11 @@ struct MarkdownView: View { | ||
| 65 | 72 | blocks.append(.code(code.joined(separator: "\n"))) |
| 66 | 73 | continue |
| 67 | 74 | } |
| 75 | if let (math, count) = markdownMathBlock(lines) { | |
| 76 | blocks.append(.math(math)) | |
| 77 | lines = lines.dropFirst(count) | |
| 78 | continue | |
| 79 | } | |
| 68 | 80 | if let (level, text) = Self.atxHeading(trimmed) { |
| 69 | 81 | blocks.append(.heading(level: min(level, 4), text: text)) |
| 70 | 82 | lines = lines.dropFirst() |
| @@ -139,7 +151,8 @@ struct MarkdownView: View { | ||
| 139 | 151 | while let next = lines.first { |
| 140 | 152 | let t = next.trimmingCharacters(in: .whitespaces) |
| 141 | 153 | if t.isEmpty || Self.atxHeading(t) != nil || t.hasPrefix("```") || t.hasPrefix(">") |
| 142 | || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil { | |
| 154 | || t.hasPrefix("- ") || t.hasPrefix("* ") || Self.tableStart(lines) != nil | |
| 155 | || markdownMathBlock(lines) != nil { | |
| 143 | 156 | break |
| 144 | 157 | } |
| 145 | 158 | paragraph.append(t) |
| @@ -150,6 +163,30 @@ struct MarkdownView: View { | ||
| 150 | 163 | return blocks |
| 151 | 164 | } |
| 152 | 165 | |
| 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 | } | |
| 174 | return blocks.map { block -> Block in | |
| 175 | switch block { | |
| 176 | case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) | |
| 177 | case .quote(let text): return .quote(budget.limit(text)) | |
| 178 | case .bullet(let items): return .bullet(items.map { budget.limit($0) }) | |
| 179 | case .ordered(let items): return .ordered(items.map { budget.limit($0) }) | |
| 180 | case .paragraph(let text): return .paragraph(budget.limit(text)) | |
| 181 | case .table(var table): | |
| 182 | table.header = table.header.map { budget.limit($0) } | |
| 183 | table.rows = table.rows.map { $0.map { budget.limit($0) } } | |
| 184 | return .table(table) | |
| 185 | case .math, .code, .rule: return block | |
| 186 | } | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 153 | 190 | /// An ATX heading: one to six `#` followed by a space, a tab or the end |
| 154 | 191 | /// of the line, as in CommonMark. `#12 fixed this` is a paragraph. |
| 155 | 192 | private static func atxHeading(_ line: String) -> (level: Int, text: String)? { |
| @@ -234,7 +271,7 @@ struct MarkdownView: View { | ||
| 234 | 271 | switch block { |
| 235 | 272 | case .heading(let level, let text): |
| 236 | 273 | Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions, |
| 237 | referenceRepo: referenceRepo) | |
| 274 | referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level))) | |
| 238 | 275 | .font(headingFont(level)) |
| 239 | 276 | .padding(.top, level <= 2 ? 8 : 4) |
| 240 | 277 | case .code(let code): |
| @@ -249,7 +286,8 @@ struct MarkdownView: View { | ||
| 249 | 286 | RoundedRectangle(cornerRadius: 2) |
| 250 | 287 | .fill(.tertiary) |
| 251 | 288 | .frame(width: 3) |
| 252 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary) | |
| 289 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) | |
| 290 | .foregroundStyle(.secondary) | |
| 253 | 291 | } |
| 254 | 292 | .fixedSize(horizontal: false, vertical: true) |
| 255 | 293 | case .bullet(let items): |
| @@ -257,7 +295,8 @@ struct MarkdownView: View { | ||
| 257 | 295 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 258 | 296 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 259 | 297 | Text("•") |
| 260 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 298 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 299 | mathSize: mathSize()) | |
| 261 | 300 | .fixedSize(horizontal: false, vertical: true) |
| 262 | 301 | } |
| 263 | 302 | } |
| @@ -267,32 +306,101 @@ struct MarkdownView: View { | ||
| 267 | 306 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 268 | 307 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 269 | 308 | Text("\(index + 1).").monospacedDigit() |
| 270 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 309 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 310 | mathSize: mathSize()) | |
| 271 | 311 | .fixedSize(horizontal: false, vertical: true) |
| 272 | 312 | } |
| 273 | 313 | } |
| 274 | 314 | } |
| 275 | 315 | case .table(let table): |
| 276 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 316 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 317 | mathSize: mathSize()) | |
| 277 | 318 | case .rule: |
| 278 | 319 | Divider() |
| 279 | 320 | case .paragraph(let text): |
| 280 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 321 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) | |
| 322 | case .math(let math): | |
| 323 | MathBlockView(math: math, pointSize: mathSize()) | |
| 281 | 324 | } |
| 282 | 325 | } |
| 283 | 326 | |
| 327 | private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat { | |
| 328 | UIFont.pointSize(style, dynamicTypeSize) | |
| 329 | } | |
| 330 | ||
| 284 | 331 | /// Inline code is set explicitly rather than left to the substitution |
| 285 | 332 | /// AttributedString makes for a custom face, which comes out optically larger |
| 286 | 333 | /// than the surrounding sans. `codeStyle` is a step below the block's own style, |
| 287 | 334 | /// the ratio the web uses (`--fs-1` against `--fs-3`). |
| 335 | /// | |
| 336 | /// With `mathSize`, TeX is typeset at that size in place of its source. | |
| 337 | /// Each expression stands in the markdown as a character the text does | |
| 338 | /// not contain, so emphasis and links around it parse as they would | |
| 339 | /// around a word. | |
| 288 | 340 | static func inline( |
| 289 | 341 | _ text: String, |
| 290 | 342 | codeStyle: Font.TextStyle = .subheadline, |
| 291 | 343 | linkOptions: OrgRenderOptions? = nil, |
| 292 | referenceRepo: String? = nil | |
| 344 | referenceRepo: String? = nil, | |
| 345 | mathSize: CGFloat? = nil | |
| 293 | 346 | ) -> Text { |
| 294 | Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 295 | referenceRepo: referenceRepo)) | |
| 347 | let pieces = mathSize == nil ? [] : splitMarkdownMath(text) | |
| 348 | let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } } | |
| 349 | guard let mathSize, !maths.isEmpty, | |
| 350 | let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init) | |
| 351 | .first(where: { !text.unicodeScalars.contains($0) }) | |
| 352 | else { | |
| 353 | return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 354 | referenceRepo: referenceRepo)) | |
| 355 | } | |
| 356 | let marked = pieces.map { piece in | |
| 357 | switch piece { | |
| 358 | case .text(let text): text | |
| 359 | case .math: String(mark) | |
| 360 | } | |
| 361 | }.joined() | |
| 362 | let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 363 | referenceRepo: referenceRepo) | |
| 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. | |
| 368 | var result = Text(verbatim: "") | |
| 369 | var next = maths.makeIterator() | |
| 370 | var run = rendered.startIndex | |
| 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 { | |
| 384 | let math = next.next() | |
| 385 | let display = math?.display == true | |
| 386 | let before = prose(index, trimEnd: display) | |
| 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 | |
| 402 | } | |
| 403 | return Text("\(result)\(prose(rendered.endIndex, trimEnd: false))") | |
| 296 | 404 | } |
| 297 | 405 | |
| 298 | 406 | static func attributed( |
| @@ -336,6 +444,15 @@ struct MarkdownView: View { | ||
| 336 | 444 | } |
| 337 | 445 | |
| 338 | 446 | /// One step below the heading's own size, so code in a heading tracks it. |
| 447 | private func headingStyle(_ level: Int) -> UIFont.TextStyle { | |
| 448 | switch level { | |
| 449 | case 1: .title2 | |
| 450 | case 2: .title3 | |
| 451 | case 3: .headline | |
| 452 | default: .subheadline | |
| 453 | } | |
| 454 | } | |
| 455 | ||
| 339 | 456 | private func headingCodeStyle(_ level: Int) -> Font.TextStyle { |
| 340 | 457 | switch level { |
| 341 | 458 | case 1: .title3 |
| @@ -356,6 +473,7 @@ private struct MarkdownTableView: View { | ||
| 356 | 473 | let table: MarkdownView.Table |
| 357 | 474 | var linkOptions: OrgRenderOptions? |
| 358 | 475 | var referenceRepo: String? |
| 476 | var mathSize: CGFloat? | |
| 359 | 477 | |
| 360 | 478 | private static let spacing: CGFloat = 14 |
| 361 | 479 | |
| @@ -390,7 +508,8 @@ private struct MarkdownTableView: View { | ||
| 390 | 508 | |
| 391 | 509 | private func cell(_ text: String, _ column: Int) -> some View { |
| 392 | 510 | let alignment = table.alignments[column] |
| 393 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 511 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 512 | mathSize: mathSize) | |
| 394 | 513 | .multilineTextAlignment(Self.textAlignment(alignment)) |
| 395 | 514 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) |
| 396 | 515 | .fixedSize(horizontal: cellWidth == nil, vertical: true) |
gitbay/Views/Shared/MathRendering.swift added +205
| @@ -0,0 +1,205 @@ | ||
| 1 | import SwiftMath | |
| 2 | import SwiftUI | |
| 3 | import UIKit | |
| 4 | ||
| 5 | /// One typeset expression: an alpha mask, tinted by the text around it, and | |
| 6 | /// how far its baseline sits above the bottom edge. | |
| 7 | struct RenderedMath { | |
| 8 | let image: CGImage | |
| 9 | let scale: CGFloat | |
| 10 | let descent: CGFloat | |
| 11 | } | |
| 12 | ||
| 13 | /// Typesets TeX with SwiftMath. Input is bounded as the server's converter | |
| 14 | /// bounds it (internal/texmath), so a pathological body cannot run the | |
| 15 | /// parser deep or long; anything refused shows as its source. | |
| 16 | enum MathRenderer { | |
| 17 | static let maxInput = 8 << 10 | |
| 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 | |
| 24 | ||
| 25 | private final class Entry { | |
| 26 | let value: RenderedMath? | |
| 27 | init(_ value: RenderedMath?) { self.value = value } | |
| 28 | } | |
| 29 | ||
| 30 | private static let cache: NSCache<NSString, Entry> = { | |
| 31 | let cache = NSCache<NSString, Entry>() | |
| 32 | cache.countLimit = 500 | |
| 33 | cache.totalCostLimit = 64 << 20 | |
| 34 | return cache | |
| 35 | }() | |
| 36 | ||
| 37 | static func render(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { | |
| 38 | let key = "\(display) \(pointSize) \(tex)" as NSString | |
| 39 | if let entry = cache.object(forKey: key) { return entry.value } | |
| 40 | let value = typeset(tex, display: display, pointSize: pointSize) | |
| 41 | cache.setObject(Entry(value), forKey: key, cost: value.map { $0.image.bytesPerRow * $0.image.height } ?? 0) | |
| 42 | return value | |
| 43 | } | |
| 44 | ||
| 45 | private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { | |
| 46 | guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, | |
| 47 | tex.utf8.count <= maxInput, admits(tex) else { return nil } | |
| 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) | |
| 60 | let (error, image, layout) = math.asImage() | |
| 61 | guard error == nil, let image, let cgImage = image.cgImage, let layout, | |
| 62 | image.size.width > 0, image.size.height > 0 else { return nil } | |
| 63 | return RenderedMath(image: cgImage, scale: image.scale, descent: layout.descent) | |
| 64 | } | |
| 65 | ||
| 66 | /// Whether `tex` nests groups, `\left…\right` and environments no | |
| 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. | |
| 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 } | |
| 74 | var depth = 0 | |
| 75 | var commands = 0 | |
| 76 | var chars = tex[...] | |
| 77 | while let c = chars.popFirst() { | |
| 78 | switch c { | |
| 79 | case "\\": | |
| 80 | let name = chars.prefix(while: \.isLetter) | |
| 81 | if name.isEmpty { | |
| 82 | chars = chars.dropFirst() | |
| 83 | continue | |
| 84 | } | |
| 85 | chars = chars.dropFirst(name.count) | |
| 86 | commands += 1 | |
| 87 | if commands > maxCommands { return false } | |
| 88 | if name == "left" || name == "begin" { depth += 1 } | |
| 89 | if name == "right" || name == "end" { depth -= 1 } | |
| 90 | case "{": depth += 1 | |
| 91 | case "}": depth -= 1 | |
| 92 | case "$", "#", "%": return false | |
| 93 | default: continue | |
| 94 | } | |
| 95 | if depth > maxDepth { return false } | |
| 96 | } | |
| 97 | return true | |
| 98 | } | |
| 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 | ||
| 159 | /// The expression as text: the image, set on the baseline and tinted | |
| 160 | /// like the text around it, or the source where it is refused. | |
| 161 | static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { | |
| 162 | guard let rendered = render(math.tex, display: math.display, pointSize: pointSize) else { | |
| 163 | return Text(verbatim: math.source) | |
| 164 | } | |
| 165 | return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex)) | |
| 166 | .renderingMode(.template)) | |
| 167 | .baselineOffset(-rendered.descent) | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | /// A display block: centred, scrolling sideways when wider than the screen, | |
| 172 | /// or the source as code where it is refused. | |
| 173 | struct MathBlockView: View { | |
| 174 | let math: MathExpression | |
| 175 | let pointSize: CGFloat | |
| 176 | ||
| 177 | var body: some View { | |
| 178 | if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) { | |
| 179 | let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex)) | |
| 180 | .renderingMode(.template) | |
| 181 | .padding(.vertical, 4) | |
| 182 | ViewThatFits(in: .horizontal) { | |
| 183 | image.frame(maxWidth: .infinity) | |
| 184 | ScrollView(.horizontal, showsIndicators: false) { image } | |
| 185 | } | |
| 186 | } else { | |
| 187 | ScrollView(.horizontal) { | |
| 188 | Text(math.source) | |
| 189 | .font(.gbMono(.caption)) | |
| 190 | .padding(10) | |
| 191 | } | |
| 192 | .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) | |
| 193 | } | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | extension UIFont { | |
| 198 | /// The point size of `style` at a Dynamic Type size, for typesetting | |
| 199 | /// math beside text set in that style. | |
| 200 | static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat { | |
| 201 | preferredFont(forTextStyle: style, | |
| 202 | compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size))) | |
| 203 | .pointSize | |
| 204 | } | |
| 205 | } | |
gitbay/Views/Shared/TeXMath.swift added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | import 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. | |
| 5 | nonisolated struct MathExpression: Equatable, Sendable { | |
| 6 | let tex: String | |
| 7 | let display: Bool | |
| 8 | let source: String | |
| 9 | } | |
| 10 | ||
| 11 | nonisolated 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. | |
| 17 | nonisolated 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. | |
| 56 | nonisolated 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. | |
| 157 | nonisolated 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 | } | |
gitbayTests/MathTests.swift added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so | |
| 6 | /// prices stay prose, and `$$…$$` inline or as a block. | |
| 7 | struct MarkdownMathSplitTests { | |
| 8 | ||
| 9 | private func math(_ tex: String, display: Bool = false) -> MathPiece { | |
| 10 | let delimiter = display ? "$$" : "$" | |
| 11 | return .math(MathExpression(tex: tex, display: display, source: delimiter + tex + delimiter)) | |
| 12 | } | |
| 13 | ||
| 14 | @Test func inlineMathBetweenText() { | |
| 15 | #expect(splitMarkdownMath("area $x^2$ here") == [.text("area "), math("x^2"), .text(" here")]) | |
| 16 | } | |
| 17 | ||
| 18 | @Test func pricesStayProse() { | |
| 19 | #expect(splitMarkdownMath("$5 and $10") == [.text("$5 and $10")]) | |
| 20 | } | |
| 21 | ||
| 22 | @Test func anOpenerFollowedBySpaceIsText() { | |
| 23 | #expect(splitMarkdownMath("a $ x$ b") == [.text("a $ x$ b")]) | |
| 24 | } | |
| 25 | ||
| 26 | @Test func aCloserNeedsNoSpaceBeforeAndNoDigitAfter() { | |
| 27 | #expect(splitMarkdownMath("$a $ and $b$1") == [.text("$a $ and $b$1")]) | |
| 28 | #expect(splitMarkdownMath("$a $b$") == [math("a $b")]) | |
| 29 | } | |
| 30 | ||
| 31 | @Test func backslashEscapesADollar() { | |
| 32 | #expect(splitMarkdownMath(#"\$x$ and $y\$z$"#) == [.text(#"\$x$ and "#), math(#"y\$z"#)]) | |
| 33 | } | |
| 34 | ||
| 35 | @Test func codeSpansAreNotMath() { | |
| 36 | #expect(splitMarkdownMath("`$x$` and ``a$b$`` c") == [.text("`$x$` and ``a$b$`` c")]) | |
| 37 | } | |
| 38 | ||
| 39 | @Test func anUnclosedBacktickIsText() { | |
| 40 | #expect(splitMarkdownMath("` $x$") == [.text("` "), math("x")]) | |
| 41 | } | |
| 42 | ||
| 43 | @Test func linkDestinationsAreNotMath() { | |
| 44 | #expect(splitMarkdownMath("[a](https://x.test/$y$) b") == [.text("[a](https://x.test/$y$) b")]) | |
| 45 | } | |
| 46 | ||
| 47 | @Test func doubleDollarsAreDisplayMath() { | |
| 48 | #expect(splitMarkdownMath(#"so $$\frac12$$ ok"#) == [.text("so "), math(#"\frac12"#, display: true), .text(" ok")]) | |
| 49 | } | |
| 50 | ||
| 51 | @Test func emptyOrUnclosedDoubleDollarsAreText() { | |
| 52 | #expect(splitMarkdownMath("$$$$") == [.text("$$$$")]) | |
| 53 | #expect(splitMarkdownMath("$$x and $y$") == [.text("$$x and "), math("y")]) | |
| 54 | } | |
| 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 | ||
| 62 | @Test func theDocumentBudgetEscapesWhatIsLeft() { | |
| 63 | var budget = MathBudget(expressions: 1, bytes: 100) | |
| 64 | let text = budget.limit("$a$ and $b\\$c$") | |
| 65 | #expect(text == #"$a$ and \$b\\$c\$"#) | |
| 66 | #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)]) | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | @MainActor | |
| 71 | struct MarkdownMathBlockTests { | |
| 72 | ||
| 73 | private func blocks(_ markdown: String) -> [MarkdownView.Block] { | |
| 74 | MarkdownView(markdown: markdown).blocks | |
| 75 | } | |
| 76 | ||
| 77 | @Test func doubleDollarLinesFenceABlock() { | |
| 78 | #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression( | |
| 79 | tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))]) | |
| 80 | } | |
| 81 | ||
| 82 | @Test func theClosingDollarsMayEndALine() { | |
| 83 | #expect(blocks("$$\nx^2 $$") == [.math(MathExpression( | |
| 84 | tex: "x^2 ", display: true, source: "$$\nx^2 $$"))]) | |
| 85 | } | |
| 86 | ||
| 87 | @Test func aWholeLineIsABlock() { | |
| 88 | #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))]) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func aBlankLineBeforeTheCloserLeavesProse() { | |
| 92 | #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")]) | |
| 93 | } | |
| 94 | ||
| 95 | @Test func aBlockInterruptsAParagraph() { | |
| 96 | #expect(blocks("text\n$$x$$\nafter") == [ | |
| 97 | .paragraph("text"), | |
| 98 | .math(MathExpression(tex: "x", display: true, source: "$$x$$")), | |
| 99 | .paragraph("after"), | |
| 100 | ]) | |
| 101 | } | |
| 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 | ||
| 115 | @Test func aBlockPastTheBudgetIsShownAsSource() { | |
| 116 | let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") | |
| 117 | #expect(blocks(many).last == .code("$$x$$")) | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | @MainActor | |
| 122 | struct MathRenderingTests { | |
| 123 | ||
| 124 | @Test func rendersAnExpression() { | |
| 125 | #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil) | |
| 126 | #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil) | |
| 127 | } | |
| 128 | ||
| 129 | @Test func refusesWhatSwiftMathCannotParse() { | |
| 130 | #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil) | |
| 131 | } | |
| 132 | ||
| 133 | @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() { | |
| 134 | #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil) | |
| 135 | #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil) | |
| 136 | #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) | |
| 137 | } | |
| 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 | ||
| 168 | @Test func refusesTooLongOrTooDeepBeforeParsing() { | |
| 169 | #expect(MathRenderer.render(String(repeating: "x", count: 8 << 10 + 1), display: false, pointSize: 17) == nil) | |
| 170 | let deep = String(repeating: "{", count: 65) + "x" + String(repeating: "}", count: 65) | |
| 171 | #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil) | |
| 172 | let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64) | |
| 173 | #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil) | |
| 174 | } | |
| 175 | ||
| 176 | @Test func malformedInputDoesNotCrash() { | |
| 177 | let inputs = [ | |
| 178 | "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3", | |
| 179 | "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\", | |
| 180 | "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{", | |
| 181 | "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits", | |
| 182 | "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}", | |
| 183 | ] | |
| 184 | for tex in inputs { | |
| 185 | _ = MathRenderer.render(tex, display: true, pointSize: 17) | |
| 186 | _ = MathRenderer.render(tex, display: false, pointSize: 17) | |
| 187 | } | |
| 188 | } | |
| 189 | } | |