TeX math in org bodies !178
merged
merged by cmc on 2026-10-16 02:05 UTC
· krz/gitbay-ios:math-org into main
6 files changed, +91 −9
Layout: unified · split
gitbay.xcodeproj/project.pbxproj
+1 −1
| @@ -603,7 +603,7 @@ |
| 603 | 603 | repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; |
| 604 | 604 | requirement = { |
| 605 | 605 | kind = upToNextMinorVersion; |
| 606 | | minimumVersion = 0.7.1; |
| 606 | minimumVersion = 0.8.0; |
| 607 | 607 | }; |
| 608 | 608 | }; |
| 609 | 609 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { |
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+2 −2
| @@ -15,8 +15,8 @@ |
| 15 | 15 | "kind" : "remoteSourceControl", |
| 16 | 16 | "location" : "ssh://git@gitbay.org/krz/org-swift.git", |
| 17 | 17 | "state" : { |
| 18 | | "revision" : "fa46c3f1577091385a95ac5f5680409f09701004", |
| 19 | | "version" : "0.7.1" |
| 18 | "revision" : "49b729cc966ddb8270e0184b0e4dd02a4e1956e8", |
| 19 | "version" : "0.8.0" |
| 20 | 20 | } |
| 21 | 21 | }, |
| 22 | 22 | { |
gitbay/Views/Repos/FormattedBodyView.swift
+5 −1
| @@ -13,17 +13,21 @@ struct FormattedBodyView: View { |
| 13 | 13 | var repo: String? |
| 14 | 14 | |
| 15 | 15 | @Environment(\.colorScheme) private var colorScheme |
| 16 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize |
| 17 | @State private var allowance = MathAllowance() |
| 16 | 18 | |
| 17 | 19 | var isOrg: Bool { format == "org" } |
| 18 | 20 | |
| 19 | 21 | var body: some View { |
| 20 | 22 | if isOrg { |
| 23 | let _ = allowance.reset(for: text) |
| 21 | 24 | // Qualified: `OrgView` here is org-mode, not the organization view. |
| 22 | 25 | OrgSwiftUI.OrgView( |
| 23 | 26 | text, |
| 24 | 27 | styler: OrgSyntaxStyler(colorScheme: colorScheme), |
| 25 | 28 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone), |
| 26 | | textTransform: repo.map { repo in { linkReferences(in: &$0, repo: repo) } } |
| 29 | textTransform: repo.map { repo in { linkReferences(in: &$0, repo: repo) } }, |
| 30 | math: MathRenderer.org(pointSize: UIFont.pointSize(.body, dynamicTypeSize), allowance: allowance) |
| 27 | 31 | ) |
| 28 | 32 | } else { |
| 29 | 33 | MarkdownView(markdown: text, referenceRepo: repo) |
gitbay/Views/Repos/ReadmeView.swift
+5 −1
| @@ -14,12 +14,15 @@ struct ReadmeView: View { |
| 14 | 14 | var repoPath: String = "" |
| 15 | 15 | |
| 16 | 16 | @Environment(\.colorScheme) private var colorScheme |
| 17 | @Environment(\.dynamicTypeSize) private var dynamicTypeSize |
| 18 | @State private var allowance = MathAllowance() |
| 17 | 19 | |
| 18 | 20 | /// Format follows the file name, never a guess at the content. |
| 19 | 21 | var isOrg: Bool { name.lowercased().hasSuffix(".org") } |
| 20 | 22 | |
| 21 | 23 | var body: some View { |
| 22 | 24 | if isOrg { |
| 25 | let _ = allowance.reset(for: content) |
| 23 | 26 | // Qualified: `OrgView` here is org-mode, not the organization view. |
| 24 | 27 | OrgSwiftUI.OrgView( |
| 25 | 28 | Self.preferringPNGBadges(content, host: host), |
| @@ -27,7 +30,8 @@ struct ReadmeView: View { |
| 27 | 30 | styler: OrgSyntaxStyler(colorScheme: colorScheme), |
| 28 | 31 | // org's TODO/DONE are the same open/done distinction the forge already |
| 29 | 32 | // colors, so they reuse those tokens rather than a third treatment. |
| 30 | | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone) |
| 33 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone), |
| 34 | math: MathRenderer.org(pointSize: UIFont.pointSize(.body, dynamicTypeSize), allowance: allowance) |
| 31 | 35 | ) |
| 32 | 36 | } else { |
| 33 | 37 | MarkdownView(markdown: content, linkOptions: renderOptions) |
gitbay/Views/Shared/MathRendering.swift
+44 −4
| @@ -1,3 +1,5 @@ |
| 1 | import OrgSwift |
| 2 | import OrgSwiftUI |
| 1 | 3 | import SwiftMath |
| 2 | 4 | import SwiftUI |
| 3 | 5 | import UIKit |
| @@ -159,13 +161,51 @@ enum MathRenderer { |
| 159 | 161 | /// The expression as text: the image, set on the baseline and tinted |
| 160 | 162 | /// like the text around it, or the source where it is refused. |
| 161 | 163 | 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)) |
| 164 | drawn(math.tex, display: math.display, pointSize: pointSize) ?? Text(verbatim: math.source) |
| 165 | } |
| 166 | |
| 167 | static func drawn(_ tex: String, display: Bool, pointSize: CGFloat) -> Text? { |
| 168 | guard let rendered = render(tex, display: display, pointSize: pointSize) else { return nil } |
| 169 | return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: tex)) |
| 166 | 170 | .renderingMode(.template)) |
| 167 | 171 | .baselineOffset(-rendered.descent) |
| 168 | 172 | } |
| 173 | |
| 174 | /// `OrgView`'s math hook: each expression the allowance admits, drawn at `pointSize`. |
| 175 | static func org(pointSize: CGFloat, allowance: MathAllowance) -> OrgMathRenderer { |
| 176 | { math in |
| 177 | allowance.admits(math) ? drawn(math.tex, display: math.display, pointSize: pointSize) : nil |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /// The web's per-document budget for org, which is drawn view by view: the first |
| 183 | /// expressions asked for are admitted, and keep their place on later passes. |
| 184 | final class MathAllowance { |
| 185 | private let start: MathBudget |
| 186 | private var budget: MathBudget |
| 187 | private var admitted: Set<OrgMath> = [] |
| 188 | private var text: String? |
| 189 | |
| 190 | init(budget: MathBudget = .document) { |
| 191 | start = budget |
| 192 | self.budget = budget |
| 193 | } |
| 194 | |
| 195 | /// Starts over when the document changes. |
| 196 | func reset(for text: String) { |
| 197 | guard text != self.text else { return } |
| 198 | self.text = text |
| 199 | budget = start |
| 200 | admitted = [] |
| 201 | } |
| 202 | |
| 203 | func admits(_ math: OrgMath) -> Bool { |
| 204 | if admitted.contains(math) { return true } |
| 205 | guard budget.take(math.tex) else { return false } |
| 206 | admitted.insert(math) |
| 207 | return true |
| 208 | } |
| 169 | 209 | } |
| 170 | 210 | |
| 171 | 211 | /// A display block: centred, scrolling sideways when wider than the screen, |
gitbayTests/MathTests.swift
+34
| @@ -1,6 +1,7 @@ |
| 1 | 1 | import Foundation |
| 2 | 2 | import Testing |
| 3 | 3 | @testable import gitbay |
| 4 | import OrgSwift |
| 4 | 5 | |
| 5 | 6 | /// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so |
| 6 | 7 | /// prices stay prose, and `$$…$$` inline or as a block. |
| @@ -187,3 +188,36 @@ struct MathRenderingTests { |
| 187 | 188 | } |
| 188 | 189 | } |
| 189 | 190 | } |
| 191 | |
| 192 | @MainActor |
| 193 | struct OrgMathHookTests { |
| 194 | |
| 195 | private func org(_ tex: String, display: Bool = false) -> OrgMath { |
| 196 | OrgMath(tex: tex, display: display, source: "$" + tex + "$") |
| 197 | } |
| 198 | |
| 199 | @Test func drawsWhatRendersAndKeepsTheSourceOtherwise() { |
| 200 | let draw = MathRenderer.org(pointSize: 17, allowance: MathAllowance()) |
| 201 | #expect(draw(org("x^2")) != nil) |
| 202 | #expect(draw(org(#"\notacommand"#)) == nil) |
| 203 | } |
| 204 | |
| 205 | @Test func theAllowanceIsTheWebsDocumentBudget() { |
| 206 | let allowance = MathAllowance(budget: MathBudget(expressions: 2, bytes: 100)) |
| 207 | #expect(allowance.admits(org("a"))) |
| 208 | #expect(allowance.admits(org("b"))) |
| 209 | #expect(allowance.admits(org("a")), "an expression drawn once is drawn again on the next pass") |
| 210 | #expect(!allowance.admits(org("c"))) |
| 211 | } |
| 212 | |
| 213 | @Test func theAllowanceStartsOverForNewText() { |
| 214 | let allowance = MathAllowance(budget: MathBudget(expressions: 1, bytes: 100)) |
| 215 | allowance.reset(for: "one") |
| 216 | #expect(allowance.admits(org("a"))) |
| 217 | #expect(!allowance.admits(org("b"))) |
| 218 | allowance.reset(for: "one") |
| 219 | #expect(!allowance.admits(org("b")), "the same text keeps its allowance") |
| 220 | allowance.reset(for: "two") |
| 221 | #expect(allowance.admits(org("b"))) |
| 222 | } |
| 223 | } |