krz/orgstar

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

Commit 8dea72acea

8dea72acea277d24d8a83e0843087d50d5af8667

parent: 1fcb653ab6

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 08:11 UTC

Export citations as oc-basic does

HTML and Markdown export format citations and #+print_bibliography:
as Org's basic processor: author-year, author, noauthor, text,
nocite and numeric styles with the bare and caps variants, prefixes
and suffixes as Org markup, year suffixes for the same authors and
year, and the author-year, plain and numeric bibliography styles.
Entries come from the BibTeX (with @string and #) or CSL-JSON files
#+bibliography: names; #+cite_export: sets the styles. Note styles and
other processors leave citations as written.

Layout: unified · split

Sources/OrgCore/Export/Citations.swift added +423
@@ -0,0 +1,423 @@
1import Foundation
2
3/// Citations as the `basic` processor (oc-basic.el, Org 9.8.7) exports them: author-year,
4/// author, noauthor, text, nocite and numeric styles with the bare and caps variants, from
5/// 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.
8struct CiteProcessor {
9 /// What a citation or bibliography entry exports as: text, Org markup (prefixes and
10 /// suffixes) and an italic title.
11 enum Piece: Equatable {
12 case text(String)
13 case org(String)
14 case italic(String)
15 }
16
17 struct Reference {
18 var prefix: String?
19 var key: String
20 var suffix: String?
21 }
22
23 struct Citation {
24 var text: String
25 var style: String?
26 var variant: String?
27 var prefix: String?
28 var suffix: String?
29 var references: [Reference]
30 }
31
32 typealias Entry = [String: String]
33
34 /// Each citation's export by its text, in document order.
35 private var outputs: [String: [[Piece]]] = [:]
36 private(set) var bibliography: [[Piece]] = []
37
38 // MARK: - Reading
39
40 static let keyPattern = "@([-.:?!`'/*@+|(){}<>&_^$#%~\\p{L}\\p{N}_]+)"
41
42 /// `org-element-citation-parser`: style, the common prefix and suffix, and references.
43 static func parse(_ text: String) -> Citation? {
44 guard let m = text.firstMatch(of: #/^\[cite(?:/([^:/\]]*)(?:/([^:\]]*))?)?:(.*)\]$/#.dotMatchesNewlines()) else { return nil }
45 let body = String(m.3)
46 var parts = body.components(separatedBy: ";")
47 let key = try! NSRegularExpression(pattern: keyPattern)
48 func hasKey(_ s: String) -> Bool { key.firstMatch(in: s, range: NSRange(location: 0, length: (s as NSString).length)) != nil }
49 guard let first = parts.firstIndex(where: hasKey), let last = parts.lastIndex(where: hasKey) else { return nil }
50 let prefix = first > 0 ? parts[..<first].joined(separator: ";") : nil
51 let suffix = last < parts.count - 1 ? parts[(last + 1)...].joined(separator: ";") : nil
52 parts = Array(parts[first...last])
53 var references: [Reference] = []
54 for part in parts {
55 let ns = part as NSString
56 guard let k = key.firstMatch(in: part, range: NSRange(location: 0, length: ns.length)) else { continue }
57 let before = ns.substring(to: k.range.location)
58 let after = ns.substring(from: NSMaxRange(k.range))
59 references.append(Reference(prefix: before.isEmpty ? nil : before, key: ns.substring(with: k.range(at: 1)), suffix: after.isEmpty ? nil : after))
60 }
61 func nonEmpty(_ s: String?) -> String? { s.flatMap { $0.isEmpty ? nil : $0 } }
62 return Citation(text: text, style: m.1.map(String.init).flatMap { $0.isEmpty ? nil : $0 }, variant: m.2.map(String.init),
63 prefix: nonEmpty(prefix), suffix: nonEmpty(suffix), references: references)
64 }
65
66 /// `#+cite_export:`: the processor, bibliography style and citation style.
67 static func exportSettings(_ text: String) -> (processor: String, bibStyle: String?, citeStyle: String?)? {
68 guard let line = text.matches(of: #/(?mi)^[ \t]*#\+cite_export:[ \t]*(.*?)[ \t]*$/#).last else { return nil }
69 let words = line.1.split(whereSeparator: \.isWhitespace).map(String.init)
70 guard let processor = words.first else { return nil }
71 return (processor, words.count > 1 ? words[1] : nil, words.count > 2 ? words[2] : nil)
72 }
73
74 static func isNocite(_ citation: Citation, defaultStyle: String?) -> Bool {
75 let style = citation.style ?? defaultStyle.map { String($0.split(separator: "/").first ?? "") }
76 return style == "nocite" || style == "n"
77 }
78
79 /// The text with nocite citations removed, and the blanks before them, as
80 /// `org-cite--set-previous-post-blank` leaves it.
81 static func removingNocite(_ text: String) -> String {
82 guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return text }
83 let defaultStyle = exportSettings(text)?.citeStyle
84 let regex = try! NSRegularExpression(pattern: "[ \\t]*(\\[cite[/:][^\\]]*\\])")
85 var out = text
86 for m in regex.matches(in: text, range: NSRange(location: 0, length: (text as NSString).length)).reversed() {
87 let ns = out as NSString
88 guard let citation = parse(ns.substring(with: m.range(at: 1))), isNocite(citation, defaultStyle: defaultStyle) else { continue }
89 // Blanks before a citation that starts its line are indentation.
90 let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location
91 let blanksOnly = ns.substring(with: NSRange(location: lineStart, length: m.range(at: 1).location - lineStart)).allSatisfy { $0 == " " || $0 == "\t" }
92 out = ns.replacingCharacters(in: blanksOnly ? m.range(at: 1) : m.range, with: "")
93 }
94 return out
95 }
96
97 // MARK: - Bibliographies
98
99 /// `org-cite-basic--parse-bibtex`: entries with lower-case field names, `id` and `type`,
100 /// values without delimiters, `@string`s expanded, blanks collapsed.
101 static func parseBibTeX(_ text: String) -> [String: Entry] {
102 var entries: [String: Entry] = [:]
103 var strings: [String: String] = [:]
104 let chars = Array(text.unicodeScalars)
105 var i = 0
106 func skipSpace() { while i < chars.count, chars[i].properties.isWhitespace { i += 1 } }
107 func word() -> String {
108 var out = ""
109 while i < chars.count, !chars[i].properties.isWhitespace, !"{}(),=#\"".unicodeScalars.contains(chars[i]) {
110 out.unicodeScalars.append(chars[i])
111 i += 1
112 }
113 return out
114 }
115 func braced() -> String {
116 var depth = 0
117 var out = ""
118 while i < chars.count {
119 let c = chars[i]
120 i += 1
121 if c == "{" {
122 depth += 1
123 if depth == 1 { continue }
124 } else if c == "}" {
125 depth -= 1
126 if depth == 0 { break }
127 }
128 out.unicodeScalars.append(c)
129 }
130 return out
131 }
132 func quoted() -> String {
133 var depth = 0
134 var out = ""
135 i += 1
136 while i < chars.count {
137 let c = chars[i]
138 i += 1
139 if c == "{" { depth += 1 } else if c == "}" { depth -= 1 } else if c == "\"", depth == 0 { break }
140 out.unicodeScalars.append(c)
141 }
142 return out
143 }
144 func value() -> String {
145 var parts: [String] = []
146 while true {
147 skipSpace()
148 guard i < chars.count else { break }
149 if chars[i] == "{" {
150 parts.append(braced())
151 } else if chars[i] == "\"" {
152 parts.append(quoted())
153 } else {
154 let w = word()
155 parts.append(strings[w.lowercased()] ?? w)
156 }
157 skipSpace()
158 if i < chars.count, chars[i] == "#" { i += 1 } else { break }
159 }
160 return parts.joined().replacingOccurrences(of: "[ \\t\\n]+", with: " ", options: .regularExpression)
161 }
162 while i < chars.count {
163 guard chars[i] == "@" else { i += 1; continue }
164 i += 1
165 let type = word().lowercased()
166 skipSpace()
167 guard i < chars.count, chars[i] == "{" || chars[i] == "(" else { continue }
168 let close: Unicode.Scalar = chars[i] == "{" ? "}" : ")"
169 i += 1
170 if type == "comment" || type == "preamble" {
171 var depth = 1
172 while i < chars.count, depth > 0 {
173 if chars[i] == "{" || chars[i] == "(" { depth += 1 } else if chars[i] == "}" || chars[i] == ")" { depth -= 1 }
174 i += 1
175 }
176 continue
177 }
178 if type == "string" {
179 skipSpace()
180 let name = word().lowercased()
181 skipSpace()
182 if i < chars.count, chars[i] == "=" { i += 1 }
183 strings[name] = value()
184 skipSpace()
185 if i < chars.count, chars[i] == close { i += 1 }
186 continue
187 }
188 skipSpace()
189 var key = ""
190 while i < chars.count, chars[i] != ",", chars[i] != close, !chars[i].properties.isWhitespace {
191 key.unicodeScalars.append(chars[i])
192 i += 1
193 }
194 var entry: Entry = ["id": key, "type": type]
195 while i < chars.count {
196 skipSpace()
197 if i < chars.count, chars[i] == "," { i += 1; skipSpace() }
198 guard i < chars.count, chars[i] != close else { i += 1; break }
199 let field = word().lowercased()
200 skipSpace()
201 guard !field.isEmpty, i < chars.count, chars[i] == "=" else { i += 1; continue }
202 i += 1
203 entry[field] = value()
204 }
205 if entries[key] == nil { entries[key] = entry }
206 }
207 return entries
208 }
209
210 /// `org-cite-basic--parse-json`, for CSL-JSON: authors as "family given" joined by "and",
211 /// and the year from `issued`.
212 static func parseJSON(_ data: Data) -> [String: Entry] {
213 guard let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [:] }
214 var entries: [String: Entry] = [:]
215 for item in items {
216 guard let id = item["id"] as? String else { continue }
217 var entry: Entry = [:]
218 for (field, value) in item {
219 switch field {
220 case "author", "editor":
221 let people = (value as? [[String: Any]]) ?? []
222 entry[field] = people.map { ($0["family"] as? String ?? "") + " " + ($0["given"] as? String ?? "") }.joined(separator: " and ")
223 case "issued":
224 let issued = value as? [String: Any] ?? [:]
225 if let parts = issued["date-parts"] as? [[Any]], let year = parts.first?.first {
226 entry["year"] = (year as? Int).map(String.init) ?? year as? String
227 } else if let raw = (issued["literal"] ?? issued["raw"]) as? String, let m = raw.firstMatch(of: /[0-9]{4}/) {
228 entry["year"] = String(m.0)
229 }
230 default:
231 if let s = value as? String { entry[field] = s } else if let n = value as? NSNumber { entry[field] = n.stringValue }
232 }
233 }
234 entries[id] = entry
235 }
236 return entries
237 }
238
239 // MARK: - Formatting
240
241 private var entries: [String: Entry] = [:]
242 private var yearCache: [(author: String?, year: String?, keys: [(key: String, suffix: String)])] = []
243
244 func author(_ key: String) -> String? { entries[key].flatMap { $0["author"] ?? $0["editor"] } }
245
246 /// `org-cite-basic--get-year`: the year, with a letter telling apart works by the same
247 /// authors in the same year.
248 mutating func year(_ key: String, noSuffix: Bool = false) -> String? {
249 let entry = entries[key]
250 var year = entry?["year"]
251 if year == nil, let date = entry?["date"], let m = date.firstMatch(of: /^([0-9]{4})(?:[^0-9]|$)/) { year = String(m.1) }
252 let author = self.author(key)
253 guard let index = yearCache.firstIndex(where: { $0.author == author && $0.year == year }) else {
254 yearCache.insert((author, year, [(key, "")]), at: 0)
255 return year
256 }
257 let suffix: String
258 if let known = yearCache[index].keys.first(where: { $0.key == key }) {
259 suffix = known.suffix
260 } else {
261 suffix = Self.suffix(yearCache[index].keys.count - 1)
262 yearCache[index].keys.insert((key, suffix), at: 0)
263 }
264 return noSuffix ? year : (year ?? "") + suffix
265 }
266
267 /// `org-cite-basic--number-to-suffix`.
268 static func suffix(_ number: Int) -> String {
269 var n = number
270 var result: [Int] = []
271 while true {
272 result.insert(n % 26, at: 0)
273 n /= 26
274 if n == 0 { break }
275 if n < 27 { result.insert(n - 1, at: 0); break }
276 if n == 27 { result.insert(contentsOf: [0, 0], at: 0); break }
277 }
278 return String(String.UnicodeScalarView(result.compactMap { Unicode.Scalar(97 + $0) }))
279 }
280
281 /// `org-cite-basic--field-less-p` with the author: `org-string<` ignoring case.
282 func authorLess(_ a: String, _ b: String) -> Bool {
283 (author(a) ?? "nil").lowercased() < (author(b) ?? "nil").lowercased()
284 }
285
286 /// `org-cite-basic-export-citation`. Nil for a style it doesn't do.
287 mutating func format(_ citation: Citation, keys: [String], defaultStyle: String?) -> [Piece]? {
288 let defaults = defaultStyle.map { $0.split(separator: "/", omittingEmptySubsequences: false).map(String.init) } ?? []
289 let style = citation.style ?? defaults.first
290 let variant = citation.variant.flatMap { $0.isEmpty ? nil : $0 } ?? (citation.style == nil && defaults.count > 1 ? defaults[1] : nil)
291 let bare = ["bare", "bare-caps", "b", "bc"].contains(variant ?? "")
292 let caps = ["caps", "bare-caps", "c", "bc"].contains(variant ?? "")
293 func org(_ s: String?) -> [Piece] { s.map { [.org($0)] } ?? [] }
294 func authorYear(_ cite: ([Piece], [Piece], [Piece]) -> [Piece], _ ref: (Reference, String, String) -> [Piece]) -> [Piece] {
295 var contents: [Piece] = []
296 for (n, reference) in citation.references.enumerated() {
297 if n > 0 { contents.append(.text(", ")) }
298 let author = self.author(reference.key) ?? "??"
299 let year = self.year(reference.key) ?? "????"
300 contents += ref(reference, author, year)
301 }
302 return cite(org(citation.prefix), contents, org(citation.suffix))
303 }
304 func capitalized(_ s: String) -> String { caps ? s.capitalized : s }
305 switch style {
306 case "author", "a":
307 let caps = ["caps", "c"].contains(variant ?? "")
308 return authorYear({ $0 + $1 + $2 }) { r, a, _ in org(r.prefix) + [.text(caps ? a.capitalized : a)] + org(r.suffix) }
309 case "noauthor", "na":
310 return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, _, y in org(r.prefix) + [.text(y)] + org(r.suffix) }
311 case "nocite", "n":
312 return []
313 case "note", "ft":
314 return nil
315 case "text", "t":
316 return authorYear({ $0 + $1 + $2 }) { r, a, y in
317 org(r.prefix) + [.text(capitalized(a) + (bare ? " " : " (") + y + (bare ? "" : ")"))] + org(r.suffix)
318 }
319 case "numeric", "nb":
320 let single = citation.references.count == 1 ? citation.references[0] : nil
321 let prefix = single.map { $0.prefix } ?? citation.prefix
322 let suffix = single.map { $0.suffix } ?? citation.suffix
323 let sorted = keys.sorted(by: authorLess)
324 let numbers = citation.references.compactMap { r in sorted.firstIndex(of: r.key).map { $0 + 1 } }.sorted()
325 return [.text("(")] + org(prefix) + [.text(Self.compact(numbers))] + org(suffix) + [.text(")")]
326 default:
327 return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, a, y in
328 org(r.prefix) + [.text(capitalized(a) + ", " + y)] + org(r.suffix)
329 }
330 }
331 }
332
333 /// `org-cite-basic--citation-numbers`: runs of three or more as `1-3`.
334 static func compact(_ numbers: [Int]) -> String {
335 guard var last = numbers.first else { return "" }
336 var result = [String(last)]
337 var rest = Array(numbers.dropFirst())
338 while !rest.isEmpty {
339 let current = rest.removeFirst()
340 let next = rest.first
341 if let next, current == last + 1, current == next - 1 {
342 if result.last != "-" { result.append("-") }
343 } else if result.last == "-" {
344 result.append(String(current))
345 } else {
346 result.append(", \(current)")
347 }
348 last = current
349 }
350 return result.joined()
351 }
352
353 /// `org-cite-basic--shorten-names`.
354 static func shortenNames(_ names: String) -> String {
355 names.components(separatedBy: " and ").map { name in
356 name.count == 1 ? "" : String(name.components(separatedBy: ", ")[0])
357 }.joined(separator: ", ")
358 }
359
360 /// `org-cite-basic--print-entry`. `org-cite-basic--print-bibtex-string` strips braces only
361 /// in the italic title, the one string inside an object.
362 mutating func entry(_ key: String, style: String?, keys: [String]) -> [Piece]? {
363 guard let entry = entries[key] else { return nil }
364 func clean(_ s: String?) -> String { (s ?? "").replacingOccurrences(of: "[{}]", with: "", options: .regularExpression) }
365 let author = self.author(key) ?? ""
366 let title = entry["title"]
367 let from = entry["publisher"] ?? entry["journal"] ?? entry["institution"] ?? entry["school"]
368 let fromText = from.map { ", " + $0 } ?? ""
369 switch style {
370 case "plain":
371 let year = self.year(key, noSuffix: true) ?? ""
372 return [.text(Self.shortenNames(author) + ". " + (title ?? "") + fromText + ", " + year + ".")]
373 case "numeric":
374 let n = (keys.sorted(by: authorLess).firstIndex(of: key) ?? 0) + 1
375 let year = self.year(key, noSuffix: true) ?? ""
376 return [.text("[\(n)] " + author + ", "), .italic(clean(title)), .text(fromText + ", " + year + ".")]
377 default:
378 let year = self.year(key) ?? ""
379 return [.text(author + " (" + year + "). "), .italic(clean(title)), .text(fromText + ".")]
380 }
381 }
382
383 // MARK: - Building
384
385 /// The citations of `text` (as written, nocite citations included) worked out in document
386 /// order, then the bibliography; nil when the export leaves citations alone.
387 init?(text: String, directory: URL?) {
388 guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return nil }
389 let settings = Self.exportSettings(text)
390 if let settings, settings.processor != "basic" { return nil }
391 // Each `#+bibliography:` names one file.
392 for m in text.matches(of: #/(?mi)^[ \t]*#\+bibliography:[ \t]*(.*?)[ \t]*$/#) {
393 for name in [String(m.1)].map({ $0.count > 1 && $0.hasPrefix("\"") && $0.hasSuffix("\"") ? String($0.dropFirst().dropLast()) : $0 }) where !name.isEmpty {
394 let path = (name as NSString).expandingTildeInPath
395 let url = path.hasPrefix("/") ? URL(fileURLWithPath: path) : (directory ?? URL(fileURLWithPath: "/")).appendingPathComponent(path)
396 guard let data = try? Data(contentsOf: url) else { continue }
397 let parsed = url.pathExtension.lowercased() == "json" ? Self.parseJSON(data) : Self.parseBibTeX(String(decoding: data, as: UTF8.self))
398 for (key, entry) in parsed where entries[key] == nil { entries[key] = entry }
399 }
400 }
401 let tree = OrgParser.parse(text)
402 let citations = tree.root.descendants().filter { $0.kind == .citation }.compactMap { Self.parse($0.text) }
403 var keys: [String] = []
404 for citation in citations { for r in citation.references where !keys.contains(r.key) { keys.append(r.key) } }
405 for citation in citations {
406 guard let pieces = format(citation, keys: keys, defaultStyle: settings?.citeStyle) else { continue }
407 outputs[citation.text, default: []].append(pieces)
408 }
409 let style = settings?.bibStyle
410 // `sort` is stable: keys with the same author keep their order.
411 bibliography = keys.enumerated().sorted { a, b in
412 authorLess(a.element, b.element) || (!authorLess(b.element, a.element) && a.offset < b.offset)
413 }.compactMap { entry($0.element, style: style, keys: keys) }
414 }
415
416 /// The export of the next citation written as `text`; nil leaves it as written.
417 mutating func next(_ text: String) -> [Piece]? {
418 guard var queue = outputs[text], !queue.isEmpty else { return nil }
419 let first = queue.removeFirst()
420 outputs[text] = queue
421 return first
422 }
423}
Sources/OrgCore/Export/HTMLExport.swift +24 −2
@@ -47,11 +47,12 @@ public enum HTMLExport {
47 let meta = metadata(text, settings: settings) 47 let meta = metadata(text, settings: settings)
48 let title = meta.title ?? fallbackTitle 48 let title = meta.title ?? fallbackTitle
49 let renderer = HTMLRenderer(settings: settings, options: options) 49 let renderer = HTMLRenderer(settings: settings, options: options)
50 renderer.citations = CiteProcessor(text: text, directory: directory)
50 let titleHTML = renderer.inlineString(title) 51 let titleHTML = renderer.inlineString(title)
51 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : "" 52 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
52 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty } 53 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
53 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" } 54 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
54 let body = renderer.document(text) 55 let body = renderer.document(renderer.citations == nil ? text : CiteProcessor.removingNocite(text))
55 header += renderer.tableOfContents() 56 header += renderer.tableOfContents()
56 var head = options.htmlHead.map { $0 + "\n" }.joined() 57 var head = options.htmlHead.map { $0 + "\n" }.joined()
57 if renderer.hasMath { 58 if renderer.hasMath {
@@ -104,6 +105,8 @@ final class HTMLRenderer {
104 private var anonymous = 0 105 private var anonymous = 0
105 private var figures = 0 106 private var figures = 0
106 private var ids: Set<String> = [] 107 private var ids: Set<String> = []
108 /// Citations and the bibliography, as `oc-basic` exports them.
109 var citations: CiteProcessor?
107 110
108 enum FootnoteContent { 111 enum FootnoteContent {
109 case definition(String) 112 case definition(String)
@@ -184,6 +187,20 @@ final class HTMLRenderer {
184 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines) 187 return inline(paragraph).trimmingCharacters(in: .whitespacesAndNewlines)
185 } 188 }
186 189
190 /// A citation's or a bibliography entry's pieces: text escaped, prefixes and suffixes as
191 /// Org markup with their blanks kept.
192 private func render(_ pieces: [CiteProcessor.Piece]) -> String {
193 pieces.map { piece in
194 switch piece {
195 case .text(let s): Self.escape(s)
196 case .italic(let s): "<i>" + Self.escape(s) + "</i>"
197 case .org(let s) where s.allSatisfy(\.isWhitespace): s
198 case .org(let s):
199 String(s.prefix { $0.isWhitespace }) + inlineString(s) + (s.allSatisfy(\.isWhitespace) ? "" : String(s.reversed().prefix { $0.isWhitespace }))
200 }
201 }.joined()
202 }
203
187 // MARK: - Elements 204 // MARK: - Elements
188 205
189 private var affiliated: [String: String] = [:] 206 private var affiliated: [String: String] = [:]
@@ -228,6 +245,8 @@ final class HTMLRenderer {
228 return "" 245 return ""
229 case .paragraph: 246 case .paragraph:
230 return paragraph(node) 247 return paragraph(node)
248 case .keyword where citations != nil && node.text.range(of: "^[ \\t]*#\\+print_bibliography:", options: [.regularExpression, .caseInsensitive]) != nil:
249 return citations!.bibliography.map { "<p>" + render($0) + "</p>\n" }.joined()
231 case .plainList: 250 case .plainList:
232 return list(node) 251 return list(node)
233 case .table: 252 case .table:
@@ -682,7 +701,10 @@ final class HTMLRenderer {
682 case .exportSnippet: 701 case .exportSnippet:
683 guard node.tokens.first?.text == "@@html:" else { return "" } 702 guard node.tokens.first?.text == "@@html:" else { return "" }
684 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined() 703 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
685 case .citation, .inlineBabelCall: return Self.escape(node.text) 704 case .citation:
705 guard let pieces = citations?.next(node.text) else { return Self.escape(node.text) }
706 return render(pieces)
707 case .inlineBabelCall: return Self.escape(node.text)
686 default: return inline(node) 708 default: return inline(node)
687 } 709 }
688 } 710 }
Sources/OrgCore/Export/MarkdownExport.swift +25 −3
@@ -3,11 +3,13 @@ import Foundation
3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and 3/// Markdown export, GitHub-flavored: ATX headings, fenced code, pipe tables, task lists and
4/// `[^n]` footnotes. The title, when there is one, becomes the top heading. 4/// `[^n]` footnotes. The title, when there is one, becomes the top heading.
5public enum MarkdownExport { 5public enum MarkdownExport {
6 public static func document(_ text: String, settings: OrgSettings = .default) -> String { 6 /// `path` is the file's, for `#+bibliography:`.
7 public static func document(_ text: String, settings: OrgSettings = .default, path: String? = nil) -> String {
7 let renderer = MarkdownRenderer(settings: settings) 8 let renderer = MarkdownRenderer(settings: settings)
9 renderer.citations = CiteProcessor(text: text, directory: path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() })
8 var out = "" 10 var out = ""
9 if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" } 11 if let title = HTMLExport.metadata(text, settings: settings).title { out = "# " + renderer.inlineString(title) + "\n\n" }
10 out += renderer.document(text) 12 out += renderer.document(renderer.citations == nil ? text : CiteProcessor.removingNocite(text))
11 return out.trimmingCharacters(in: .newlines) + "\n" 13 return out.trimmingCharacters(in: .newlines) + "\n"
12 } 14 }
13} 15}
@@ -18,6 +20,8 @@ final class MarkdownRenderer {
18 private var numbers: [String: Int] = [:] 20 private var numbers: [String: Int] = [:]
19 private var notes: [(Int, String)] = [] 21 private var notes: [(Int, String)] = []
20 private var anonymous = 0 22 private var anonymous = 0
23 /// Citations and the bibliography, as `oc-basic` exports them.
24 var citations: CiteProcessor?
21 25
22 init(settings: OrgSettings) { 26 init(settings: OrgSettings) {
23 self.settings = settings 27 self.settings = settings
@@ -85,6 +89,8 @@ final class MarkdownRenderer {
85 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " ")) 89 parts.append(tags.split(separator: ":").map { "`\($0)`" }.joined(separator: " "))
86 } 90 }
87 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ") 91 return String(repeating: "#", count: min(level + 1, 6)) + " " + parts.filter { !$0.isEmpty }.joined(separator: " ")
92 case .keyword where citations != nil && node.text.range(of: "^[ \\t]*#\\+print_bibliography:", options: [.regularExpression, .caseInsensitive]) != nil:
93 return citations!.bibliography.map { indent + render($0) }.joined(separator: "\n\n")
88 case .paragraph: 94 case .paragraph:
89 return indent + inline(node).trimmingCharacters(in: .whitespacesAndNewlines) 95 return indent + inline(node).trimmingCharacters(in: .whitespacesAndNewlines)
90 .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + indent) 96 .components(separatedBy: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.joined(separator: "\n" + indent)
@@ -220,6 +226,19 @@ final class MarkdownRenderer {
220 return out 226 return out
221 } 227 }
222 228
229 /// A citation's or a bibliography entry's pieces, prefixes and suffixes as Org markup.
230 private func render(_ pieces: [CiteProcessor.Piece]) -> String {
231 pieces.map { piece in
232 switch piece {
233 case .text(let s): s
234 case .italic(let s): "*" + s + "*"
235 case .org(let s) where s.allSatisfy(\.isWhitespace): s
236 case .org(let s):
237 String(s.prefix { $0.isWhitespace }) + inlineString(s) + (s.allSatisfy(\.isWhitespace) ? "" : String(s.reversed().prefix { $0.isWhitespace }))
238 }
239 }.joined()
240 }
241
223 private func code(_ text: String) -> String { 242 private func code(_ text: String) -> String {
224 let ticks = text.contains("`") ? "``" : "`" 243 let ticks = text.contains("`") ? "``" : "`"
225 return ticks + (ticks == "``" ? " " + text + " " : text) + ticks 244 return ticks + (ticks == "``" ? " " + text + " " : text) + ticks
@@ -256,7 +275,10 @@ final class MarkdownRenderer {
256 case .exportSnippet: 275 case .exportSnippet:
257 guard node.tokens.first?.text == "@@html:" else { return "" } 276 guard node.tokens.first?.text == "@@html:" else { return "" }
258 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined() 277 return node.tokens.filter { $0.kind != .marker }.map(\.text).joined()
259 case .citation, .inlineBabelCall: return node.text 278 case .citation:
279 guard let pieces = citations?.next(node.text) else { return node.text }
280 return render(pieces)
281 case .inlineBabelCall: return node.text
260 default: return inline(node) 282 default: return inline(node)
261 } 283 }
262 } 284 }
Sources/Orgstar/ContentView.swift +1 −1
@@ -388,7 +388,7 @@ struct ContentView: View {
388 case "html", "html-open", "markdown": 388 case "html", "html-open", "markdown":
389 let target = destination ?? base.appendingPathExtension(format == "markdown" ? "md" : "html") 389 let target = destination ?? base.appendingPathExtension(format == "markdown" ? "md" : "html")
390 let content = format == "markdown" 390 let content = format == "markdown"
391 ? MarkdownExport.document(text, settings: defaults) 391 ? MarkdownExport.document(text, settings: defaults, path: path)
392 : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults, path: path) 392 : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults, path: path)
393 do { 393 do {
394 try content.write(to: target, atomically: true, encoding: .utf8) 394 try content.write(to: target, atomically: true, encoding: .utf8)
Sources/OrgstarMobile/ExportMenu.swift +1 −1
@@ -35,7 +35,7 @@ struct OrgExport: Transferable {
35 let settings = settings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile) 35 let settings = settings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile)
36 let content = switch format { 36 let content = switch format {
37 case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path) 37 case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path)
38 case .markdown: MarkdownExport.document(text, settings: settings) 38 case .markdown: MarkdownExport.document(text, settings: settings, path: path)
39 } 39 }
40 let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) 40 let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
41 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) 41 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
Tests/OrgCoreTests/CitationTests.swift added +115
@@ -0,0 +1,115 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Citations against `oc-basic` through ox-md: each paragraph and the bibliography.
6struct CitationTests {
7 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
8
9 static let bib = #"""
10 @string{jos = "Journal of Stuff"}
11 @article{doe2020,
12 author = {Doe, Jane and Roe, Richard},
13 title = {A Study of {Things}},
14 journal = jos,
15 year = {2020},
16 pages = {1--10}
17 }
18 @book{smith1999,
19 author = "Smith, John",
20 title = {The Book},
21 publisher = {Pub House},
22 year = 1999
23 }
24 @misc{lee,
25 author = {Lee, Kim and Park, Min and Choi, Su},
26 title = {Three Authors},
27 year = {2021}
28 }
29 @article{doe2020b,
30 author = {Doe, Jane and Roe, Richard},
31 title = {Another Study},
32 journal = {Other Journal},
33 year = {2020}
34 }
35 @report{org,
36 editor = {{Org Team}},
37 title = {Manual},
38 institution = {GNU},
39 date = {2022-03-04}
40 }
41 @comment{ignored}
42 """#
43
44 static let json = #"""
45 [{"id": "csl1", "author": [{"family": "Zed", "given": "Ann"}], "title": "From JSON", "issued": {"date-parts": [[2018, 5]]}, "publisher": "Pubs"}]
46 """#
47
48 static let body = """
49 Plain [cite:@doe2020] and [cite:@smith1999 p. 5] and [cite/t:@lee].
50
51 Several [cite:see @doe2020;@smith1999] and common [cite:pre; @lee; post].
52
53 Same year [cite:@doe2020b] then [cite/t:@doe2020] and [cite/text/c:@lee].
54
55 Noauthor [cite/na:@doe2020], bare [cite/na/b:@smith1999], author [cite/a:@lee] [cite/author/caps:@lee].
56
57 Bare default [cite//b:@smith1999] and text bare [cite/t/b:@org], missing [cite:@nope], json [cite:@csl1].
58
59 Nocite here [cite/n:@lee] and there [cite/nocite:@smith1999].
60
61 Markup [cite:/see/ @doe2020 *p. 3*].
62
63 #+print_bibliography:
64 """
65
66 func compare(_ text: String) throws {
67 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-cite-\(UUID().uuidString)")
68 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
69 defer { try? FileManager.default.removeItem(at: folder) }
70 try Self.bib.write(to: folder.appendingPathComponent("refs.bib"), atomically: true, encoding: .utf8)
71 try Self.json.write(to: folder.appendingPathComponent("more.json"), atomically: true, encoding: .utf8)
72 let path = folder.appendingPathComponent("doc.org").path
73 try text.write(toFile: path, atomically: true, encoding: .utf8)
74 let text = "#+OPTIONS: toc:nil\n" + text
75 try text.write(toFile: path, atomically: true, encoding: .utf8)
76 let emacs = try EmacsOracle.evaluate("", """
77 (with-current-buffer (find-file-noselect \(lispString(path)))
78 (require 'ox-md) (require 'oc-basic)
79 (list (org-export-as 'md nil nil t)))
80 """)[0]
81 let ours = MarkdownExport.document(text, path: path)
82 func paragraphs(_ s: String) -> [String] {
83 s.components(separatedBy: "\n\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
84 }
85 #expect(paragraphs(ours) == paragraphs(emacs), "ours:\n\(ours)\nemacs:\n\(emacs)")
86 }
87
88 @Test func htmlUsesTheSameCitations() throws {
89 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-cite-\(UUID().uuidString)")
90 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
91 defer { try? FileManager.default.removeItem(at: folder) }
92 try Self.bib.write(to: folder.appendingPathComponent("refs.bib"), atomically: true, encoding: .utf8)
93 let page = HTMLExport.page("#+bibliography: refs.bib\nSee [cite:@doe2020 *p. 3*] [cite/n:@lee].\n\n#+print_bibliography:\n", fallbackTitle: "t",
94 path: folder.appendingPathComponent("doc.org").path)
95 #expect(page.contains("<p>See (Doe, Jane and Roe, Richard, 2020 <strong>p. 3</strong>).</p>"))
96 #expect(page.contains("<p>Doe, Jane and Roe, Richard (2020). <i>A Study of Things</i>, Journal of Stuff.</p>"))
97 #expect(page.contains("<p>Lee, Kim and Park, Min and Choi, Su (2021). <i>Three Authors</i>.</p>"))
98 #expect(!page.contains("[cite"))
99 }
100
101 @Test(.enabled(if: oracle))
102 func authorYearMatchesOcBasic() throws {
103 try compare("#+bibliography: refs.bib\n#+bibliography: more.json\n" + Self.body)
104 }
105
106 @Test(.enabled(if: oracle))
107 func numericMatchesOcBasic() throws {
108 try compare("#+bibliography: refs.bib\n#+cite_export: basic numeric numeric\n" + "Cites [cite:@smith1999;@doe2020;@lee] and [cite:@doe2020b] and [cite:see @lee p. 2] and [cite:pre; @lee;@org; post].\n\n#+print_bibliography:\n")
109 }
110
111 @Test(.enabled(if: oracle))
112 func plainBibliographyMatchesOcBasic() throws {
113 try compare("#+bibliography: refs.bib\n#+cite_export: basic plain\n" + "A [cite:@lee] [cite:@doe2020] [cite:@org].\n\n#+print_bibliography:\n")
114 }
115}