krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit a4008c4a7f

a4008c4a7f1edbc43753f4b835e60004c22dc681

parent: 44019206b2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 23:29 UTC

Export note-style citations as footnotes

note and ft citations become footnotes holding the text-style
citation, as org-cite-wrap-citation does, with the blanks before them
removed and punctuation after them moved in front.

Layout: unified · split

Sources/OrgCore/Export/Citations.swift +27 −5
@@ -3,8 +3,8 @@ import Foundation
33/// Citations as the `basic` processor (oc-basic.el, Org 9.8.7) exports them: author-year,
44/// author, noauthor, text, nocite and numeric styles with the bare and caps variants, from
55/// the BibTeX or CSL-JSON files `#+bibliography:` names, and the bibliography
6/// `#+print_bibliography:` asks for. Note styles, and `#+cite_export:` processors other than
7/// `basic`, leave citations as they are written.
6/// `#+print_bibliography:` asks for. Note styles become footnotes. `#+cite_export:`
7/// processors other than `basic` leave citations as they are written.
88struct CiteProcessor {
99 /// What a citation or bibliography entry exports as: text, Org markup (prefixes and
1010 /// suffixes) and an italic title.
@@ -310,9 +310,7 @@ struct CiteProcessor {
310310 return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, _, y in org(r.prefix) + [.text(y)] + org(r.suffix) }
311311 case "nocite", "n":
312312 return []
313 case "note", "ft":
314 return nil
315 case "text", "t":
313 case "text", "t", "note", "ft":
316314 return authorYear({ $0 + $1 + $2 }) { r, a, y in
317315 org(r.prefix) + [.text(capitalized(a) + (bare ? " " : " (") + y + (bare ? "" : ")"))] + org(r.suffix)
318316 }
@@ -421,6 +419,30 @@ struct CiteProcessor {
421419 }.compactMap { entry($0.element, style: style, keys: keys) }
422420 }
423421
422 /// Note citations (`note`, `ft`) as inline footnotes holding their text-style citation, as
423 /// `org-cite-wrap-citation` does, the blanks before them gone and punctuation right after
424 /// them moved in front (`org-cite-adjust-note`).
425 mutating func inliningNotes(_ text: String) -> String {
426 let defaultStyle = Self.exportSettings(text)?.citeStyle.map { String($0.split(separator: "/").first ?? "") }
427 let regex = try! NSRegularExpression(pattern: "[ \\t]*(\\[cite[/:][^\\]]*\\])([.,;:!?]*)")
428 var out = ""
429 var at = 0
430 let ns = text as NSString
431 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
432 let written = ns.substring(with: m.range(at: 1))
433 guard let citation = Self.parse(written), ["note", "ft"].contains(citation.style ?? defaultStyle ?? ""),
434 let pieces = next(written) else { continue }
435 let note = pieces.map { piece in
436 switch piece {
437 case .text(let s), .org(let s), .italic(let s): s
438 }
439 }.joined()
440 out += ns.substring(with: NSRange(location: at, length: m.range.location - at)) + ns.substring(with: m.range(at: 2)) + "[fn:: " + note + "]"
441 at = NSMaxRange(m.range)
442 }
443 return out + ns.substring(from: at)
444 }
445
424446 /// The export of the next citation written as `text`; nil leaves it as written.
425447 mutating func next(_ text: String) -> [Piece]? {
426448 guard var queue = outputs[text], !queue.isEmpty else { return nil }
Sources/OrgCore/Export/HTMLExport.swift +1 −1
@@ -55,7 +55,7 @@ public enum HTMLExport {
5555 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
5656 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
5757 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
58 let body = renderer.document(renderer.citations == nil ? text : CiteProcessor.removingNocite(text))
58 let body = renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
5959 header += renderer.tableOfContents()
6060 var head = options.htmlHead.map { $0 + "\n" }.joined()
6161 if renderer.hasMath {
Sources/OrgCore/Export/MarkdownExport.swift +1 −1
@@ -11,7 +11,7 @@ public enum MarkdownExport {
1111 if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
1212 renderer.inTitle = false
1313 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
14 out += renderer.document(renderer.citations == nil ? text : CiteProcessor.removingNocite(text))
14 out += renderer.document(renderer.citations == nil ? text : renderer.citations!.inliningNotes(CiteProcessor.removingNocite(text)))
1515 return out.trimmingCharacters(in: .newlines) + "\n"
1616 }
1717}
Tests/OrgCoreTests/CitationTests.swift +15
@@ -109,6 +109,21 @@ struct CitationTests {
109109 #expect(page.contains("<p>See (Doe, Jane and Roe, Richard, 2020).</p>"))
110110 }
111111
112 /// As ox-md exports them: footnotes after the punctuation, holding text-style citations.
113 @Test func noteCitationsBecomeFootnotes() throws {
114 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-cite-\(UUID().uuidString)")
115 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
116 defer { try? FileManager.default.removeItem(at: folder) }
117 try Self.bib.write(to: folder.appendingPathComponent("refs.bib"), atomically: true, encoding: .utf8)
118 let text = "#+bibliography: refs.bib\nFirst claim [cite/ft:@doe2020]. Second [cite/note:see @smith1999 p. 5], and third [cite/ft:@lee].\n\nEnd [cite/ft/c:@lee]\n"
119 let markdown = MarkdownExport.document(text, path: folder.appendingPathComponent("doc.org").path)
120 #expect(markdown.contains("First claim.[^1] Second,[^2] and third.[^3]"))
121 #expect(markdown.contains("End[^4]"))
122 #expect(markdown.contains("[^1]: Doe, Jane and Roe, Richard (2020)"))
123 #expect(markdown.contains("[^2]: see Smith, John (1999) p. 5"))
124 #expect(markdown.contains("[^4]: Lee, Kim And Park, Min And Choi, Su (2021)"))
125 }
126
112127 @Test(.enabled(if: oracle))
113128 func authorYearMatchesOcBasic() throws {
114129 try compare("#+bibliography: refs.bib\n#+bibliography: more.json\n" + Self.body)