Commit 97c532c9b9
Verified · cmc
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 +97 −11
| @@ -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,6 +50,10 @@ 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 | 58 | var lines = markdown.components(separatedBy: "\n")[...] |
| 52 | 59 | |
| @@ -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,27 @@ struct MarkdownView: View { | ||
| 150 | 163 | return blocks |
| 151 | 164 | } |
| 152 | 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 | |
| 170 | return blocks.map { block -> Block in | |
| 171 | switch block { | |
| 172 | case .heading(let level, let text): return .heading(level: level, text: budget.limit(text)) | |
| 173 | case .quote(let text): return .quote(budget.limit(text)) | |
| 174 | case .bullet(let items): return .bullet(items.map { budget.limit($0) }) | |
| 175 | case .ordered(let items): return .ordered(items.map { budget.limit($0) }) | |
| 176 | case .paragraph(let text): return .paragraph(budget.limit(text)) | |
| 177 | case .table(var table): | |
| 178 | table.header = table.header.map { budget.limit($0) } | |
| 179 | table.rows = table.rows.map { $0.map { budget.limit($0) } } | |
| 180 | return .table(table) | |
| 181 | case .math(let math): return budget.take(math.tex) ? .math(math) : .code(math.source) | |
| 182 | case .code, .rule: return block | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 153 | 187 | /// An ATX heading: one to six `#` followed by a space, a tab or the end |
| 154 | 188 | /// of the line, as in CommonMark. `#12 fixed this` is a paragraph. |
| 155 | 189 | private static func atxHeading(_ line: String) -> (level: Int, text: String)? { |
| @@ -234,7 +268,7 @@ struct MarkdownView: View { | ||
| 234 | 268 | switch block { |
| 235 | 269 | case .heading(let level, let text): |
| 236 | 270 | Self.inline(text, codeStyle: headingCodeStyle(level), linkOptions: linkOptions, |
| 237 | referenceRepo: referenceRepo) | |
| 271 | referenceRepo: referenceRepo, mathSize: mathSize(headingStyle(level))) | |
| 238 | 272 | .font(headingFont(level)) |
| 239 | 273 | .padding(.top, level <= 2 ? 8 : 4) |
| 240 | 274 | case .code(let code): |
| @@ -249,7 +283,8 @@ struct MarkdownView: View { | ||
| 249 | 283 | RoundedRectangle(cornerRadius: 2) |
| 250 | 284 | .fill(.tertiary) |
| 251 | 285 | .frame(width: 3) |
| 252 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo).foregroundStyle(.secondary) | |
| 286 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) | |
| 287 | .foregroundStyle(.secondary) | |
| 253 | 288 | } |
| 254 | 289 | .fixedSize(horizontal: false, vertical: true) |
| 255 | 290 | case .bullet(let items): |
| @@ -257,7 +292,8 @@ struct MarkdownView: View { | ||
| 257 | 292 | ForEach(Array(items.enumerated()), id: \.offset) { _, item in |
| 258 | 293 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 259 | 294 | Text("•") |
| 260 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 295 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 296 | mathSize: mathSize()) | |
| 261 | 297 | .fixedSize(horizontal: false, vertical: true) |
| 262 | 298 | } |
| 263 | 299 | } |
| @@ -267,32 +303,71 @@ struct MarkdownView: View { | ||
| 267 | 303 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 268 | 304 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 269 | 305 | Text("\(index + 1).").monospacedDigit() |
| 270 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 306 | Self.inline(item, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 307 | mathSize: mathSize()) | |
| 271 | 308 | .fixedSize(horizontal: false, vertical: true) |
| 272 | 309 | } |
| 273 | 310 | } |
| 274 | 311 | } |
| 275 | 312 | case .table(let table): |
| 276 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 313 | MarkdownTableView(table: table, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 314 | mathSize: mathSize()) | |
| 277 | 315 | case .rule: |
| 278 | 316 | Divider() |
| 279 | 317 | case .paragraph(let text): |
| 280 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 318 | Self.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, mathSize: mathSize()) | |
| 319 | case .math(let math): | |
| 320 | MathBlockView(math: math, pointSize: mathSize()) | |
| 281 | 321 | } |
| 282 | 322 | } |
| 283 | 323 | |
| 324 | private func mathSize(_ style: UIFont.TextStyle = .body) -> CGFloat { | |
| 325 | UIFont.pointSize(style, dynamicTypeSize) | |
| 326 | } | |
| 327 | ||
| 284 | 328 | /// Inline code is set explicitly rather than left to the substitution |
| 285 | 329 | /// AttributedString makes for a custom face, which comes out optically larger |
| 286 | 330 | /// than the surrounding sans. `codeStyle` is a step below the block's own style, |
| 287 | 331 | /// the ratio the web uses (`--fs-1` against `--fs-3`). |
| 332 | /// | |
| 333 | /// With `mathSize`, TeX is typeset at that size in place of its source. | |
| 334 | /// Each expression stands in the markdown as a character the text does | |
| 335 | /// not contain, so emphasis and links around it parse as they would | |
| 336 | /// around a word. | |
| 288 | 337 | static func inline( |
| 289 | 338 | _ text: String, |
| 290 | 339 | codeStyle: Font.TextStyle = .subheadline, |
| 291 | 340 | linkOptions: OrgRenderOptions? = nil, |
| 292 | referenceRepo: String? = nil | |
| 341 | referenceRepo: String? = nil, | |
| 342 | mathSize: CGFloat? = nil | |
| 293 | 343 | ) -> Text { |
| 294 | Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 295 | referenceRepo: referenceRepo)) | |
| 344 | let pieces = mathSize == nil ? [] : splitMarkdownMath(text) | |
| 345 | let maths = pieces.compactMap { if case .math(let math) = $0 { math } else { nil } } | |
| 346 | guard let mathSize, !maths.isEmpty, | |
| 347 | let mark = (0xE000...0xF8FF).lazy.compactMap(Unicode.Scalar.init) | |
| 348 | .first(where: { !text.unicodeScalars.contains($0) }) | |
| 349 | else { | |
| 350 | return Text(attributed(text, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 351 | referenceRepo: referenceRepo)) | |
| 352 | } | |
| 353 | let marked = pieces.map { piece in | |
| 354 | switch piece { | |
| 355 | case .text(let text): text | |
| 356 | case .math: String(mark) | |
| 357 | } | |
| 358 | }.joined() | |
| 359 | let rendered = attributed(marked, codeStyle: codeStyle, linkOptions: linkOptions, | |
| 360 | referenceRepo: referenceRepo) | |
| 361 | ||
| 362 | var result = Text(verbatim: "") | |
| 363 | var next = maths.makeIterator() | |
| 364 | var run = rendered.startIndex | |
| 365 | for index in rendered.characters.indices where rendered.characters[index] == Character(mark) { | |
| 366 | let math = next.next() | |
| 367 | result = Text("\(result)\(Text(AttributedString(rendered[run..<index])))\(math.map { MathRenderer.text($0, pointSize: mathSize) } ?? Text(verbatim: ""))") | |
| 368 | run = rendered.characters.index(after: index) | |
| 369 | } | |
| 370 | return Text("\(result)\(Text(AttributedString(rendered[run...])))") | |
| 296 | 371 | } |
| 297 | 372 | |
| 298 | 373 | static func attributed( |
| @@ -336,6 +411,15 @@ struct MarkdownView: View { | ||
| 336 | 411 | } |
| 337 | 412 | |
| 338 | 413 | /// One step below the heading's own size, so code in a heading tracks it. |
| 414 | private func headingStyle(_ level: Int) -> UIFont.TextStyle { | |
| 415 | switch level { | |
| 416 | case 1: .title2 | |
| 417 | case 2: .title3 | |
| 418 | case 3: .headline | |
| 419 | default: .subheadline | |
| 420 | } | |
| 421 | } | |
| 422 | ||
| 339 | 423 | private func headingCodeStyle(_ level: Int) -> Font.TextStyle { |
| 340 | 424 | switch level { |
| 341 | 425 | case 1: .title3 |
| @@ -356,6 +440,7 @@ private struct MarkdownTableView: View { | ||
| 356 | 440 | let table: MarkdownView.Table |
| 357 | 441 | var linkOptions: OrgRenderOptions? |
| 358 | 442 | var referenceRepo: String? |
| 443 | var mathSize: CGFloat? | |
| 359 | 444 | |
| 360 | 445 | private static let spacing: CGFloat = 14 |
| 361 | 446 | |
| @@ -390,7 +475,8 @@ private struct MarkdownTableView: View { | ||
| 390 | 475 | |
| 391 | 476 | private func cell(_ text: String, _ column: Int) -> some View { |
| 392 | 477 | let alignment = table.alignments[column] |
| 393 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo) | |
| 478 | return MarkdownView.inline(text, linkOptions: linkOptions, referenceRepo: referenceRepo, | |
| 479 | mathSize: mathSize) | |
| 394 | 480 | .multilineTextAlignment(Self.textAlignment(alignment)) |
| 395 | 481 | .frame(width: cellWidth, alignment: Self.frameAlignment(alignment)) |
| 396 | 482 | .fixedSize(horizontal: cellWidth == nil, vertical: true) |
gitbay/Views/Shared/MathRendering.swift added +123
| @@ -0,0 +1,123 @@ | ||
| 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 | ||
| 20 | private final class Entry { | |
| 21 | let value: RenderedMath? | |
| 22 | init(_ value: RenderedMath?) { self.value = value } | |
| 23 | } | |
| 24 | ||
| 25 | private static let cache: NSCache<NSString, Entry> = { | |
| 26 | let cache = NSCache<NSString, Entry>() | |
| 27 | cache.countLimit = 500 | |
| 28 | return cache | |
| 29 | }() | |
| 30 | ||
| 31 | static func render(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { | |
| 32 | let key = "\(display) \(pointSize) \(tex)" as NSString | |
| 33 | if let entry = cache.object(forKey: key) { return entry.value } | |
| 34 | let value = typeset(tex, display: display, pointSize: pointSize) | |
| 35 | cache.setObject(Entry(value), forKey: key) | |
| 36 | return value | |
| 37 | } | |
| 38 | ||
| 39 | private static func typeset(_ tex: String, display: Bool, pointSize: CGFloat) -> RenderedMath? { | |
| 40 | guard !tex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, | |
| 41 | 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) | |
| 44 | let (error, image, layout) = math.asImage() | |
| 45 | guard error == nil, let image, let cgImage = image.cgImage, let layout, | |
| 46 | image.size.width > 0, image.size.height > 0 else { return nil } | |
| 47 | return RenderedMath(image: cgImage, scale: image.scale, descent: layout.descent) | |
| 48 | } | |
| 49 | ||
| 50 | /// 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. | |
| 53 | static func admits(_ tex: String) -> Bool { | |
| 54 | var depth = 0 | |
| 55 | var chars = tex[...] | |
| 56 | while let c = chars.popFirst() { | |
| 57 | switch c { | |
| 58 | case "\\": | |
| 59 | let name = chars.prefix(while: \.isLetter) | |
| 60 | if name.isEmpty { | |
| 61 | chars = chars.dropFirst() | |
| 62 | continue | |
| 63 | } | |
| 64 | chars = chars.dropFirst(name.count) | |
| 65 | if name == "left" || name == "begin" { depth += 1 } | |
| 66 | if name == "right" || name == "end" { depth -= 1 } | |
| 67 | case "{": depth += 1 | |
| 68 | case "}": depth -= 1 | |
| 69 | case "$", "#": return false | |
| 70 | default: continue | |
| 71 | } | |
| 72 | if depth > maxDepth { return false } | |
| 73 | } | |
| 74 | return true | |
| 75 | } | |
| 76 | ||
| 77 | /// 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. | |
| 79 | static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { | |
| 80 | guard let rendered = render(math.tex, display: math.display, pointSize: pointSize) else { | |
| 81 | return Text(verbatim: math.source) | |
| 82 | } | |
| 83 | return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex)) | |
| 84 | .renderingMode(.template)) | |
| 85 | .baselineOffset(-rendered.descent) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | /// A display block: centred, scrolling sideways when wider than the screen, | |
| 90 | /// or the source as code where it is refused. | |
| 91 | struct MathBlockView: View { | |
| 92 | let math: MathExpression | |
| 93 | let pointSize: CGFloat | |
| 94 | ||
| 95 | var body: some View { | |
| 96 | if let rendered = MathRenderer.render(math.tex, display: true, pointSize: pointSize) { | |
| 97 | let image = Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex)) | |
| 98 | .renderingMode(.template) | |
| 99 | .padding(.vertical, 4) | |
| 100 | ViewThatFits(in: .horizontal) { | |
| 101 | image.frame(maxWidth: .infinity) | |
| 102 | ScrollView(.horizontal, showsIndicators: false) { image } | |
| 103 | } | |
| 104 | } else { | |
| 105 | ScrollView(.horizontal) { | |
| 106 | Text(math.source) | |
| 107 | .font(.gbMono(.caption)) | |
| 108 | .padding(10) | |
| 109 | } | |
| 110 | .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | extension UIFont { | |
| 116 | /// The point size of `style` at a Dynamic Type size, for typesetting | |
| 117 | /// math beside text set in that style. | |
| 118 | static func pointSize(_ style: UIFont.TextStyle, _ size: DynamicTypeSize) -> CGFloat { | |
| 119 | preferredFont(forTextStyle: style, | |
| 120 | compatibleWith: UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(size))) | |
| 121 | .pointSize | |
| 122 | } | |
| 123 | } | |
gitbay/Views/Shared/TeXMath.swift added +170
| @@ -0,0 +1,170 @@ | ||
| 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 | while i < chars.count { | |
| 69 | let c = chars[i] | |
| 70 | switch c { | |
| 71 | case "\\": | |
| 72 | prose += String(chars[i..<min(i + 2, chars.count)]) | |
| 73 | i += 2 | |
| 74 | case "`": | |
| 75 | var run = 1 | |
| 76 | while i + run < chars.count, chars[i + run] == "`" { run += 1 } | |
| 77 | var j = i + run | |
| 78 | var close: Int? | |
| 79 | while j < chars.count { | |
| 80 | guard chars[j] == "`" else { j += 1; continue } | |
| 81 | var k = 1 | |
| 82 | while j + k < chars.count, chars[j + k] == "`" { k += 1 } | |
| 83 | if k == run { close = j + k; break } | |
| 84 | j += k | |
| 85 | } | |
| 86 | let end = close ?? i + run | |
| 87 | prose += String(chars[i..<end]) | |
| 88 | i = end | |
| 89 | case "]" where i + 1 < chars.count && chars[i + 1] == "(": | |
| 90 | let end = chars[(i + 2)...].firstIndex(of: ")").map { $0 + 1 } ?? chars.count | |
| 91 | prose += String(chars[i..<end]) | |
| 92 | i = end | |
| 93 | case "$" where i + 1 < chars.count && chars[i + 1] == "$": | |
| 94 | var j = i + 2 | |
| 95 | var close: Int? | |
| 96 | while j + 1 < chars.count { | |
| 97 | if chars[j] == "\\" { j += 2; continue } | |
| 98 | if chars[j] == "$", chars[j + 1] == "$" { close = j; break } | |
| 99 | j += 1 | |
| 100 | } | |
| 101 | if let close, close > i + 2 { | |
| 102 | flush() | |
| 103 | let tex = String(chars[(i + 2)..<close]) | |
| 104 | pieces.append(.math(MathExpression(tex: tex, display: true, source: "$$" + tex + "$$"))) | |
| 105 | i = close + 2 | |
| 106 | } else { | |
| 107 | prose += "$$" | |
| 108 | i += 2 | |
| 109 | } | |
| 110 | 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 { | |
| 123 | flush() | |
| 124 | let tex = String(chars[(i + 1)..<close]) | |
| 125 | pieces.append(.math(MathExpression(tex: tex, display: false, source: "$" + tex + "$"))) | |
| 126 | i = close + 1 | |
| 127 | } else { | |
| 128 | prose.append(c) | |
| 129 | i += 1 | |
| 130 | } | |
| 131 | default: | |
| 132 | prose.append(c) | |
| 133 | i += 1 | |
| 134 | } | |
| 135 | } | |
| 136 | flush() | |
| 137 | return pieces | |
| 138 | } | |
| 139 | ||
| 140 | /// A display block at the head of `lines`, by the web's rules: a line that | |
| 141 | /// is `$$` alone, closed by a line ending in `$$` before a blank line, or a | |
| 142 | /// line that is `$$…$$` whole. Returns the expression and the lines it | |
| 143 | /// spans. | |
| 144 | nonisolated func markdownMathBlock(_ lines: ArraySlice<String>) -> (MathExpression, lines: Int)? { | |
| 145 | guard let first = lines.first else { return nil } | |
| 146 | let opener = first.trimmingCharacters(in: .whitespaces) | |
| 147 | guard opener.hasPrefix("$$") else { return nil } | |
| 148 | let rest = String(opener.dropFirst(2)) | |
| 149 | ||
| 150 | if rest.isEmpty { | |
| 151 | var tex = "" | |
| 152 | var source = "$$\n" | |
| 153 | for (offset, line) in lines.dropFirst().enumerated() { | |
| 154 | if line.trimmingCharacters(in: .whitespaces).isEmpty { return nil } | |
| 155 | let trimmed = String(line.reversed().drop(while: { $0 == " " || $0 == "\t" }).reversed()) | |
| 156 | if trimmed.hasSuffix("$$") { | |
| 157 | tex += trimmed.dropLast(2) | |
| 158 | source += trimmed | |
| 159 | return (MathExpression(tex: tex, display: true, source: source), offset + 2) | |
| 160 | } | |
| 161 | tex += line + "\n" | |
| 162 | source += line + "\n" | |
| 163 | } | |
| 164 | return nil | |
| 165 | } | |
| 166 | if rest.count > 2, rest.hasSuffix("$$") { | |
| 167 | return (MathExpression(tex: String(rest.dropLast(2)), display: true, source: opener), 1) | |
| 168 | } | |
| 169 | return nil | |
| 170 | } | |
gitbayTests/MathTests.swift added +142
| @@ -0,0 +1,142 @@ | ||
| 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 theDocumentBudgetEscapesWhatIsLeft() { | |
| 57 | var budget = MathBudget(expressions: 1, bytes: 100) | |
| 58 | let text = budget.limit("$a$ and $b\\$c$") | |
| 59 | #expect(text == #"$a$ and \$b\\$c\$"#) | |
| 60 | #expect(splitMarkdownMath(text) == [math("a"), .text(#" and \$b\\$c\$"#)]) | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | @MainActor | |
| 65 | struct MarkdownMathBlockTests { | |
| 66 | ||
| 67 | private func blocks(_ markdown: String) -> [MarkdownView.Block] { | |
| 68 | MarkdownView(markdown: markdown).blocks | |
| 69 | } | |
| 70 | ||
| 71 | @Test func doubleDollarLinesFenceABlock() { | |
| 72 | #expect(blocks("$$\nx^2\n+ y\n$$") == [.math(MathExpression( | |
| 73 | tex: "x^2\n+ y\n", display: true, source: "$$\nx^2\n+ y\n$$"))]) | |
| 74 | } | |
| 75 | ||
| 76 | @Test func theClosingDollarsMayEndALine() { | |
| 77 | #expect(blocks("$$\nx^2 $$") == [.math(MathExpression( | |
| 78 | tex: "x^2 ", display: true, source: "$$\nx^2 $$"))]) | |
| 79 | } | |
| 80 | ||
| 81 | @Test func aWholeLineIsABlock() { | |
| 82 | #expect(blocks("$$x^2$$") == [.math(MathExpression(tex: "x^2", display: true, source: "$$x^2$$"))]) | |
| 83 | } | |
| 84 | ||
| 85 | @Test func aBlankLineBeforeTheCloserLeavesProse() { | |
| 86 | #expect(blocks("$$\nx^2\n\nmore") == [.paragraph("$$ x^2"), .paragraph("more")]) | |
| 87 | } | |
| 88 | ||
| 89 | @Test func aBlockInterruptsAParagraph() { | |
| 90 | #expect(blocks("text\n$$x$$\nafter") == [ | |
| 91 | .paragraph("text"), | |
| 92 | .math(MathExpression(tex: "x", display: true, source: "$$x$$")), | |
| 93 | .paragraph("after"), | |
| 94 | ]) | |
| 95 | } | |
| 96 | ||
| 97 | @Test func aBlockPastTheBudgetIsShownAsSource() { | |
| 98 | let many = Array(repeating: "$$x$$", count: MathBudget.document.expressions + 1).joined(separator: "\n") | |
| 99 | #expect(blocks(many).last == .code("$$x$$")) | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | @MainActor | |
| 104 | struct MathRenderingTests { | |
| 105 | ||
| 106 | @Test func rendersAnExpression() { | |
| 107 | #expect(MathRenderer.render(#"\frac{a}{b} + \sqrt{x}"#, display: false, pointSize: 17) != nil) | |
| 108 | #expect(MathRenderer.render(#"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"#, display: true, pointSize: 17) != nil) | |
| 109 | } | |
| 110 | ||
| 111 | @Test func refusesWhatSwiftMathCannotParse() { | |
| 112 | #expect(MathRenderer.render(#"\notacommand{x}"#, display: false, pointSize: 17) == nil) | |
| 113 | } | |
| 114 | ||
| 115 | @Test func refusesAnUnescapedDollarOrHashAsTheWebDoes() { | |
| 116 | #expect(MathRenderer.render("5 and $10 nothing", display: false, pointSize: 17) == nil) | |
| 117 | #expect(MathRenderer.render("x #1", display: false, pointSize: 17) == nil) | |
| 118 | #expect(MathRenderer.render(##"\$5 + \#1"##, display: false, pointSize: 17) != nil) | |
| 119 | } | |
| 120 | ||
| 121 | @Test func refusesTooLongOrTooDeepBeforeParsing() { | |
| 122 | #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) | |
| 124 | #expect(MathRenderer.render(deep, display: false, pointSize: 17) == nil) | |
| 125 | let ok = String(repeating: "{", count: 64) + "x" + String(repeating: "}", count: 64) | |
| 126 | #expect(MathRenderer.render(ok, display: false, pointSize: 17) != nil) | |
| 127 | } | |
| 128 | ||
| 129 | @Test func malformedInputDoesNotCrash() { | |
| 130 | let inputs = [ | |
| 131 | "", "{", "}", "^", "_", "^^", "x^", "\\", "\\frac", "\\frac{a", "\\sqrt[", "\\sqrt[3", | |
| 132 | "\\left(", "\\right)", "\\left( x", "\\begin{matrix}", "\\end{matrix}", "a & b", "\\\\", | |
| 133 | "\\begin{pmatrix} a & & & \\\\ \\\\ \\end{pmatrix}", "\\color{red}{x}", "\\color{", "\\text{", | |
| 134 | "\\operatorname{", "\\mathbb{", "\\overbrace{", "\\left\\", "x_{_{_{}}}", "\\limits", | |
| 135 | "\\begin{cases} x \\end{matrix}", "\\begin{aligned} a &= b \\\\ &= c \\end{aligned}", | |
| 136 | ] | |
| 137 | for tex in inputs { | |
| 138 | _ = MathRenderer.render(tex, display: true, pointSize: 17) | |
| 139 | _ = MathRenderer.render(tex, display: false, pointSize: 17) | |
| 140 | } | |
| 141 | } | |
| 142 | } | |