Commit e2c5e4c93a

e2c5e4c93a5f46171e68ea1d474082497f0bc902

parent: f08e9a3415

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 01:21 UTC

TeX math in org bodies

Org bodies and READMEs draw their LaTeX fragments and blocks through
OrgView's math hook (krz/org-swift 0.8.0), typeset by the same
SwiftMath renderer and bounds as markdown. The web's per-document
budget applies to the expressions in the order they are drawn, and
starts over when the text changes. A refused expression shows its
source.

Ref #40

Layout: unified · split

gitbay.xcodeproj/project.pbxproj +1 −1
@@ -603,7 +603,7 @@
603603 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
604604 requirement = {
605605 kind = upToNextMinorVersion;
606 minimumVersion = 0.7.1;
606 minimumVersion = 0.8.0;
607607 };
608608 };
609609 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
@@ -15,8 +15,8 @@
1515 "kind" : "remoteSourceControl",
1616 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
1717 "state" : {
18 "revision" : "fa46c3f1577091385a95ac5f5680409f09701004",
19 "version" : "0.7.1"
18 "revision" : "49b729cc966ddb8270e0184b0e4dd02a4e1956e8",
19 "version" : "0.8.0"
2020 }
2121 },
2222 {
gitbay/Views/Repos/FormattedBodyView.swift +5 −1
@@ -13,17 +13,21 @@ struct FormattedBodyView: View {
1313 var repo: String?
1414
1515 @Environment(\.colorScheme) private var colorScheme
16 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
17 @State private var allowance = MathAllowance()
1618
1719 var isOrg: Bool { format == "org" }
1820
1921 var body: some View {
2022 if isOrg {
23 let _ = allowance.reset(for: text)
2124 // Qualified: `OrgView` here is org-mode, not the organization view.
2225 OrgSwiftUI.OrgView(
2326 text,
2427 styler: OrgSyntaxStyler(colorScheme: colorScheme),
2528 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)
2731 )
2832 } else {
2933 MarkdownView(markdown: text, referenceRepo: repo)
gitbay/Views/Repos/ReadmeView.swift +5 −1
@@ -14,12 +14,15 @@ struct ReadmeView: View {
1414 var repoPath: String = ""
1515
1616 @Environment(\.colorScheme) private var colorScheme
17 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
18 @State private var allowance = MathAllowance()
1719
1820 /// Format follows the file name, never a guess at the content.
1921 var isOrg: Bool { name.lowercased().hasSuffix(".org") }
2022
2123 var body: some View {
2224 if isOrg {
25 let _ = allowance.reset(for: content)
2326 // Qualified: `OrgView` here is org-mode, not the organization view.
2427 OrgSwiftUI.OrgView(
2528 Self.preferringPNGBadges(content, host: host),
@@ -27,7 +30,8 @@ struct ReadmeView: View {
2730 styler: OrgSyntaxStyler(colorScheme: colorScheme),
2831 // org's TODO/DONE are the same open/done distinction the forge already
2932 // 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)
3135 )
3236 } else {
3337 MarkdownView(markdown: content, linkOptions: renderOptions)
gitbay/Views/Shared/MathRendering.swift +44 −4
@@ -1,3 +1,5 @@
1import OrgSwift
2import OrgSwiftUI
13import SwiftMath
24import SwiftUI
35import UIKit
@@ -159,13 +161,51 @@ enum MathRenderer {
159161 /// The expression as text: the image, set on the baseline and tinted
160162 /// like the text around it, or the source where it is refused.
161163 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))
166170 .renderingMode(.template))
167171 .baselineOffset(-rendered.descent)
168172 }
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.
184final 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 }
169209}
170210
171211/// A display block: centred, scrolling sideways when wider than the screen,
gitbayTests/MathTests.swift +34
@@ -1,6 +1,7 @@
11import Foundation
22import Testing
33@testable import gitbay
4import OrgSwift
45
56/// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so
67/// prices stay prose, and `$$…$$` inline or as a block.
@@ -187,3 +188,36 @@ struct MathRenderingTests {
187188 }
188189 }
189190}
191
192@MainActor
193struct 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}