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 @@
603 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git"; 603 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
604 requirement = { 604 requirement = {
605 kind = upToNextMinorVersion; 605 kind = upToNextMinorVersion;
606 minimumVersion = 0.7.1; 606 minimumVersion = 0.8.0;
607 }; 607 };
608 }; 608 };
609 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = { 609 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +2 −2
@@ -15,8 +15,8 @@
15 "kind" : "remoteSourceControl", 15 "kind" : "remoteSourceControl",
16 "location" : "ssh://git@gitbay.org/krz/org-swift.git", 16 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
17 "state" : { 17 "state" : {
18 "revision" : "fa46c3f1577091385a95ac5f5680409f09701004", 18 "revision" : "49b729cc966ddb8270e0184b0e4dd02a4e1956e8",
19 "version" : "0.7.1" 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 var repo: String? 13 var repo: String?
14 14
15 @Environment(\.colorScheme) private var colorScheme 15 @Environment(\.colorScheme) private var colorScheme
16 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
17 @State private var allowance = MathAllowance()
16 18
17 var isOrg: Bool { format == "org" } 19 var isOrg: Bool { format == "org" }
18 20
19 var body: some View { 21 var body: some View {
20 if isOrg { 22 if isOrg {
23 let _ = allowance.reset(for: text)
21 // Qualified: `OrgView` here is org-mode, not the organization view. 24 // Qualified: `OrgView` here is org-mode, not the organization view.
22 OrgSwiftUI.OrgView( 25 OrgSwiftUI.OrgView(
23 text, 26 text,
24 styler: OrgSyntaxStyler(colorScheme: colorScheme), 27 styler: OrgSyntaxStyler(colorScheme: colorScheme),
25 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone), 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 } else { 32 } else {
29 MarkdownView(markdown: text, referenceRepo: repo) 33 MarkdownView(markdown: text, referenceRepo: repo)
gitbay/Views/Repos/ReadmeView.swift +5 −1
@@ -14,12 +14,15 @@ struct ReadmeView: View {
14 var repoPath: String = "" 14 var repoPath: String = ""
15 15
16 @Environment(\.colorScheme) private var colorScheme 16 @Environment(\.colorScheme) private var colorScheme
17 @Environment(\.dynamicTypeSize) private var dynamicTypeSize
18 @State private var allowance = MathAllowance()
17 19
18 /// Format follows the file name, never a guess at the content. 20 /// Format follows the file name, never a guess at the content.
19 var isOrg: Bool { name.lowercased().hasSuffix(".org") } 21 var isOrg: Bool { name.lowercased().hasSuffix(".org") }
20 22
21 var body: some View { 23 var body: some View {
22 if isOrg { 24 if isOrg {
25 let _ = allowance.reset(for: content)
23 // Qualified: `OrgView` here is org-mode, not the organization view. 26 // Qualified: `OrgView` here is org-mode, not the organization view.
24 OrgSwiftUI.OrgView( 27 OrgSwiftUI.OrgView(
25 Self.preferringPNGBadges(content, host: host), 28 Self.preferringPNGBadges(content, host: host),
@@ -27,7 +30,8 @@ struct ReadmeView: View {
27 styler: OrgSyntaxStyler(colorScheme: colorScheme), 30 styler: OrgSyntaxStyler(colorScheme: colorScheme),
28 // org's TODO/DONE are the same open/done distinction the forge already 31 // org's TODO/DONE are the same open/done distinction the forge already
29 // colors, so they reuse those tokens rather than a third treatment. 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 } else { 36 } else {
33 MarkdownView(markdown: content, linkOptions: renderOptions) 37 MarkdownView(markdown: content, linkOptions: renderOptions)
gitbay/Views/Shared/MathRendering.swift +44 −4
@@ -1,3 +1,5 @@
1import OrgSwift
2import OrgSwiftUI
1import SwiftMath 3import SwiftMath
2import SwiftUI 4import SwiftUI
3import UIKit 5import UIKit
@@ -159,13 +161,51 @@ enum MathRenderer {
159 /// The expression as text: the image, set on the baseline and tinted 161 /// 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. 162 /// like the text around it, or the source where it is refused.
161 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text { 163 static func text(_ math: MathExpression, pointSize: CGFloat) -> Text {
162 guard let rendered = render(math.tex, display: math.display, pointSize: pointSize) else { 164 drawn(math.tex, display: math.display, pointSize: pointSize) ?? Text(verbatim: math.source)
163 return Text(verbatim: math.source) 165 }
164 } 166
165 return Text(Image(rendered.image, scale: rendered.scale, label: Text(verbatim: math.tex)) 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 .renderingMode(.template)) 170 .renderingMode(.template))
167 .baselineOffset(-rendered.descent) 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.
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 }
169} 209}
170 210
171/// A display block: centred, scrolling sideways when wider than the screen, 211/// A display block: centred, scrolling sideways when wider than the screen,
gitbayTests/MathTests.swift +34
@@ -1,6 +1,7 @@
1import Foundation 1import Foundation
2import Testing 2import Testing
3@testable import gitbay 3@testable import gitbay
4import OrgSwift
4 5
5/// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so 6/// The web's delimiter rules (internal/httpd/math.go): pandoc's `$…$` so
6/// prices stay prose, and `$$…$$` inline or as a block. 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
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}