krz/orgstar

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

Commit e4d9b52b0e

e4d9b52b0ed18dbbe6402e33315f0e989b737988

parent: f5b4116d13

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 04:59 UTC

HTML export options, includes, macros, MathJax; export dialog

- Page export follows #+OPTIONS (toc, num, tags, todo, pri, ^, H, tex,
  author, date, title) with Org's defaults: table of contents and section
  numbers.
- :noexport: subtrees, COMMENT headings and #+EXCLUDE_TAGS are left out;
  with :export: or #+SELECT_TAGS only those trees; archived trees export
  their heading.
- #+INCLUDE (whole file, ::*Heading, ::#id, :lines, :minlevel, src/example)
  and macros ({{{title}}}, #+MACRO with $N, n, keyword, input-file, time).
- LaTeX fragments are kept for MathJax, which the page loads when needed;
  sub/superscripts follow ^:{}; #+HTML_HEAD lines go in the head.
- C-c C-e: Export… dialog with format, destination and open-after.

Layout: unified · split

Sources/OrgCore/Export/ExportOptions.swift added +276
@@ -0,0 +1,276 @@
1import Foundation
2
3/// What an export includes, from `#+OPTIONS` and the export keywords (ox.el, Org 9.8.7).
4public struct ExportOptions: Sendable, Equatable {
5 public enum Scripts: Sendable, Equatable {
6 case on
7 /// `^:{}`: only `_{…}` and `^{…}`.
8 case braces
9 case off
10 }
11
12 /// `toc`: the table of contents' depth, nil for none.
13 public var toc: Int?
14 /// `num`: section numbers to this depth, nil for none.
15 public var num: Int?
16 public var tags = true
17 /// `tags:not-in-toc`.
18 public var tagsInTOC = true
19 public var todo = true
20 public var priority = false
21 public var scripts = Scripts.on
22 /// `H`: the deepest heading level the TOC and numbering count.
23 public var headlineLevels = 3
24 public var tex = true
25 public var author = true
26 public var date = true
27 public var title = true
28 public var excludeTags = ["noexport"]
29 public var selectTags = ["export"]
30 /// `#+HTML_HEAD` and `#+HTML_HEAD_EXTRA` lines, in order.
31 public var htmlHead: [String] = []
32
33 /// `org-export-with-*` defaults.
34 public static let org = ExportOptions(toc: 3, num: 3)
35
36 /// The shape the conformance corpus holds: no table of contents or numbers, priorities
37 /// shown.
38 public static let orgo: ExportOptions = {
39 var options = ExportOptions(toc: nil, num: nil)
40 options.priority = true
41 return options
42 }()
43
44 /// `defaults` with the document's `#+OPTIONS`, `#+EXCLUDE_TAGS`, `#+SELECT_TAGS` and
45 /// `#+HTML_HEAD` applied.
46 public static func parse(_ text: String, defaults: ExportOptions = .org) -> ExportOptions {
47 var options = defaults
48 var tocSet: String?
49 var numSet: String?
50 for m in text.matches(of: #/(?mi)^[ \t]*#\+(OPTIONS|EXCLUDE_TAGS|SELECT_TAGS|HTML_HEAD|HTML_HEAD_EXTRA):[ \t]*(.*?)[ \t]*$/#) {
51 let value = String(m.2)
52 switch m.1.uppercased() {
53 case "EXCLUDE_TAGS": options.excludeTags = value.split(whereSeparator: \.isWhitespace).map(String.init)
54 case "SELECT_TAGS": options.selectTags = value.split(whereSeparator: \.isWhitespace).map(String.init)
55 case "HTML_HEAD", "HTML_HEAD_EXTRA": options.htmlHead.append(value)
56 default:
57 for item in value.matches(of: /(\S+?):(\S+)/) {
58 let key = String(item.1)
59 let v = String(item.2)
60 let on = v != "nil"
61 switch key {
62 case "toc": tocSet = v
63 case "num": numSet = v
64 case "tags":
65 options.tags = on
66 options.tagsInTOC = v != "not-in-toc"
67 case "todo": options.todo = on
68 case "pri": options.priority = on
69 case "^": options.scripts = v == "{}" ? .braces : on ? .on : .off
70 case "H": if let n = Int(v) { options.headlineLevels = n }
71 case "tex": options.tex = on
72 case "author": options.author = on
73 case "date": options.date = on
74 case "title": options.title = on
75 default: break
76 }
77 }
78 }
79 }
80 // `toc` and `num` as t (to H), nil or a depth.
81 func depth(_ v: String?, _ current: Int?) -> Int? {
82 guard let v else { return current.map { _ in options.headlineLevels } }
83 if v == "nil" { return nil }
84 return Int(v).map { min($0, options.headlineLevels) } ?? options.headlineLevels
85 }
86 options.toc = depth(tocSet, options.toc)
87 options.num = depth(numSet, options.num)
88 return options
89 }
90}
91
92/// What happens to the text before it's parsed for export: `#+INCLUDE` (ox.el's
93/// `org-export-expand-include-keyword`) and macros (`org-macro-replace-all`).
94public enum ExportPreprocess {
95 /// `#+INCLUDE: "file" [example|export FORMAT|src LANG] [:lines "A-B"] [:minlevel N]`, with
96 /// `file::*Heading` or `file::#custom-id` for one subtree. Relative to `directory`.
97 public static func includes(_ text: String, directory: URL?, depth: Int = 0) -> String {
98 guard depth < 8, text.range(of: "#+INCLUDE:", options: .caseInsensitive) != nil else { return text }
99 var lines: [String] = []
100 for line in text.components(separatedBy: "\n") {
101 guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+INCLUDE:[ \t]+"([^"]+)"(.*)$/#) else {
102 lines.append(line)
103 continue
104 }
105 let spec = String(m.1)
106 let rest = String(m.2)
107 var file = spec
108 var target: String?
109 if let r = spec.range(of: "::") {
110 file = String(spec[..<r.lowerBound])
111 target = String(spec[r.upperBound...])
112 }
113 let url = (file as NSString).isAbsolutePath || directory == nil
114 ? URL(fileURLWithPath: (file as NSString).expandingTildeInPath)
115 : directory!.appendingPathComponent(file)
116 guard var included = try? String(contentsOf: url, encoding: .utf8) else {
117 lines.append("")
118 continue
119 }
120 if let target { included = subtree(included, target) ?? "" }
121 if let range = rest.firstMatch(of: /:lines[ \t]+"([0-9]*)-([0-9]*)"/) {
122 let all = included.components(separatedBy: "\n")
123 let low = max(1, Int(range.1) ?? 1)
124 let high = min(all.count, (Int(range.2).map { $0 - 1 }) ?? all.count)
125 included = low <= high ? all[(low - 1)..<high].joined(separator: "\n") : ""
126 }
127 let words = rest.replacingOccurrences(of: ":[a-z-]+[ \\t]+(\"[^\"]*\"|\\S+)", with: "", options: .regularExpression)
128 .split(whereSeparator: \.isWhitespace).map(String.init)
129 if let kind = words.first?.lowercased(), ["src", "example", "export", "quote", "verse", "center", "comment"].contains(kind) {
130 // The words after the kind, as written, up to the first option.
131 var parameter = rest.drop { $0 == " " || $0 == "\t" }.dropFirst(kind.count).drop { $0 == " " || $0 == "\t" }
132 if let option = parameter.firstIndex(of: ":") { parameter = parameter[..<option] }
133 let body = included.hasSuffix("\n") ? String(included.dropLast()) : included
134 let escaped = kind == "src" || kind == "example" ? BlockEdit.escape(body) : body
135 lines.append("#+BEGIN_\(kind)" + (parameter.isEmpty ? "" : " " + parameter))
136 lines.append(escaped)
137 lines.append("#+END_\(kind)")
138 continue
139 }
140 if let minLevel = rest.firstMatch(of: /:minlevel[ \t]+([0-9]+)/).flatMap({ Int($0.1) }) {
141 included = shift(included, to: minLevel)
142 }
143 let expanded = includes(included, directory: url.deletingLastPathComponent(), depth: depth + 1)
144 lines.append(expanded.hasSuffix("\n") ? String(expanded.dropLast()) : expanded)
145 }
146 return lines.joined(separator: "\n")
147 }
148
149 /// The subtree of `text` at `*Heading` or `#custom-id`.
150 static func subtree(_ text: String, _ target: String) -> String? {
151 let tree = OrgParser.parse(text)
152 let model = DocumentModel(tree: tree)
153 let found: Int?
154 if target.hasPrefix("#") {
155 let id = String(target.dropFirst())
156 found = model.headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
157 } else {
158 let title = target.hasPrefix("*") ? String(target.dropFirst()) : target
159 found = model.headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
160 }
161 guard let index = found else { return nil }
162 let start = model.headings[index].headingRange.lowerBound
163 let end = model.headings[(index + 1)...].first { $0.level <= model.headings[index].level }?.headingRange.lowerBound ?? (text as NSString).length
164 return (text as NSString).substring(with: NSRange(location: start, length: end - start))
165 }
166
167 /// Headings moved so the shallowest is at `level`.
168 static func shift(_ text: String, to level: Int) -> String {
169 let levels = text.matches(of: #/(?m)^(\*+) /#).map { $0.1.count }
170 guard let shallowest = levels.min() else { return text }
171 let delta = level - shallowest
172 guard delta != 0 else { return text }
173 return text.components(separatedBy: "\n").map { line in
174 guard let m = line.firstMatch(of: #/^(\*+) /#) else { return line }
175 return String(repeating: "*", count: max(1, m.1.count + delta)) + line[m.1.endIndex...]
176 }.joined(separator: "\n")
177 }
178
179 /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title,
180 /// author, date, email, input-file, keyword, property, n and time macros. Others become
181 /// empty, as Lisp ones do here.
182 public static func macros(_ text: String, path: String?, now: Date = Date()) -> String {
183 guard text.contains("{{{") else { return text }
184 var definitions: [String: String] = [:]
185 var keywords: [String: String] = [:]
186 for m in text.matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) {
187 let key = m.1.uppercased()
188 let value = String(m.2)
189 if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) {
190 definitions[d.1.lowercased()] = String(d.2)
191 } else if keywords[key] == nil {
192 keywords[key] = value
193 }
194 }
195 var counters: [String: Int] = [:]
196 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
197 var out = ""
198 var at = 0
199 let ns = text as NSString
200 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
201 out += ns.substring(with: NSRange(location: at, length: m.range.location - at))
202 at = NSMaxRange(m.range)
203 // Not on #+MACRO lines themselves.
204 let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location
205 if ns.substring(from: lineStart).range(of: "^[ \\t]*#\\+MACRO:", options: [.regularExpression, .caseInsensitive]) != nil {
206 out += ns.substring(with: m.range)
207 continue
208 }
209 let name = ns.substring(with: m.range(at: 1)).lowercased()
210 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
211 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now)
212 }
213 out += ns.substring(from: at)
214 return out
215 }
216
217 /// Macro arguments: split on commas, `\,` for a literal one, blanks around trimmed.
218 static func arguments(_ s: String) -> [String] {
219 var args: [String] = []
220 var current = ""
221 var escaped = false
222 for c in s {
223 if escaped {
224 current.append(c == "," ? "," : "\\")
225 if c != "," { current.append(c) }
226 escaped = false
227 } else if c == "\\" {
228 escaped = true
229 } else if c == "," {
230 args.append(current)
231 current = ""
232 } else {
233 current.append(c)
234 }
235 }
236 if escaped { current.append("\\") }
237 args.append(current)
238 return args.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
239 }
240
241 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String],
242 counters: inout [String: Int], path: String?, now: Date) -> String {
243 if let template = definitions[name] {
244 guard !template.hasPrefix("(eval") else { return "" }
245 var result = template
246 for (i, arg) in args.enumerated().reversed() { result = result.replacingOccurrences(of: "$\(i + 1)", with: arg) }
247 return result.replacingOccurrences(of: "\\$[0-9]+", with: "", options: .regularExpression)
248 }
249 switch name {
250 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
251 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
252 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? ""
253 case "n":
254 let counter = args.first ?? ""
255 let action = args.count > 1 ? args[1] : ""
256 if action == "-" { return String(counters[counter] ?? 0) }
257 let value = Int(action) ?? ((counters[counter] ?? 0) + 1)
258 counters[counter] = value
259 return String(value)
260 case "time":
261 guard let format = args.first else { return "" }
262 return strftime(format, now)
263 default: return ""
264 }
265 }
266
267 /// `format-time-string` for the common directives.
268 static func strftime(_ format: String, _ date: Date) -> String {
269 var t = time_t(date.timeIntervalSince1970)
270 var tm = tm()
271 localtime_r(&t, &tm)
272 var buffer = [Int8](repeating: 0, count: 256)
273 let count = Foundation.strftime(&buffer, buffer.count, format, &tm)
274 return String(decoding: buffer.prefix(count).map { UInt8(bitPattern: $0) }, as: UTF8.self)
275 }
276}
Sources/OrgCore/Export/HTMLExport.swift +154 −27
@@ -12,7 +12,7 @@ public struct ExportMetadata: Sendable, Equatable {
1212/// footnotes collected at the end, captioned images as figures.
1313public enum HTMLExport {
1414 public static func body(_ text: String, settings: OrgSettings = .default) -> String {
15 let renderer = HTMLRenderer(settings: settings)
15 let renderer = HTMLRenderer(settings: settings, options: .orgo)
1616 return renderer.document(text)
1717 }
1818
@@ -36,15 +36,30 @@ public enum HTMLExport {
3636 return meta
3737 }
3838
39 /// A standalone page: the body under the document's title, with a small stylesheet.
40 public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default) -> String {
39 /// A standalone page: the body under the document's title, with a small stylesheet, as
40 /// `#+OPTIONS` and the export keywords ask. `path` is the file's, for `#+INCLUDE` and
41 /// `{{{input-file}}}`.
42 public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String {
43 let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() }
44 let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now)
45 let options = ExportOptions.parse(text)
4146 let meta = metadata(text, settings: settings)
4247 let title = meta.title ?? fallbackTitle
43 let renderer = HTMLRenderer(settings: settings)
48 let renderer = HTMLRenderer(settings: settings, options: options)
4449 let titleHTML = renderer.inlineString(title)
45 var header = "<h1 class=\"title\">\(titleHTML)</h1>\n"
46 let byline = [meta.author, meta.date].compactMap { $0 }.filter { !$0.isEmpty }
50 var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : ""
51 let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty }
4752 if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" }
53 let body = renderer.document(text)
54 header += renderer.tableOfContents()
55 var head = options.htmlHead.map { $0 + "\n" }.joined()
56 if renderer.hasMath {
57 head += """
58 <script>window.MathJax = { tex: { inlineMath: [['\\\\(', '\\\\)']], displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']] } };</script>
59 <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
60
61 """
62 }
4863 return """
4964 <!DOCTYPE html>
5065 <html lang="en">
@@ -62,10 +77,11 @@ public enum HTMLExport {
6277 .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); }
6378 .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; }
6479 figure { margin: 1em 0; } img { max-width: 100%; }
80 #table-of-contents ul { list-style: none; padding-left: 1.2em; } #table-of-contents > div > ul { padding-left: 0; }
6581 </style>
66 </head>
82 \(head)</head>
6783 <body>
68 \(header)\(renderer.document(text))</body>
84 \(header)\(body)</body>
6985 </html>
7086
7187 """
@@ -74,6 +90,13 @@ public enum HTMLExport {
7490
7591final class HTMLRenderer {
7692 let settings: OrgSettings
93 let options: ExportOptions
94 /// With select tags in the document: the sections to export, by heading start.
95 private var selected: Set<Int>?
96 private var numbers: [Int] = []
97 private var tocEntries: [(level: Int, id: String, html: String)] = []
98 /// A LaTeX fragment was exported, for MathJax.
99 private(set) var hasMath = false
77100 private var footnoteNumbers: [String: Int] = [:]
78101 private var footnoteOrder: [(label: String, content: FootnoteContent)] = []
79102 private var definitions: [String: String] = [:]
@@ -86,8 +109,9 @@ final class HTMLRenderer {
86109 case inline(String)
87110 }
88111
89 init(settings: OrgSettings) {
112 init(settings: OrgSettings, options: ExportOptions = .orgo) {
90113 self.settings = settings
114 self.options = options
91115 }
92116
93117 static func escape(_ s: String) -> String {
@@ -109,6 +133,7 @@ final class HTMLRenderer {
109133 func document(_ text: String) -> String {
110134 let tree = OrgParser.parse(text, defaults: settings)
111135 collectDefinitions(tree.root)
136 selected = Self.selection(tree, tags: options.selectTags)
112137 var out = ""
113138 for child in tree.root.children { out += element(child) }
114139 out += footnoteSection()
@@ -176,7 +201,19 @@ final class HTMLRenderer {
176201 if exports == "results" || exports == "none" { return "" }
177202 }
178203 switch node.kind {
179 case .zerothSection, .section, .document:
204 case .section:
205 guard let heading = node.firstChild(.heading) else { fallthrough }
206 let tags = heading.tokens.first { $0.kind == .tags }?.text.split(separator: ":").map(String.init) ?? []
207 let title = heading.firstChild(.title)?.text.trimmingCharacters(in: .whitespaces) ?? ""
208 // `org-export--skip-p`: excluded tags, COMMENT, unselected trees.
209 if tags.contains(where: options.excludeTags.contains) || title == "COMMENT" || title.hasPrefix("COMMENT ") { return "" }
210 if let selected, !selected.contains(heading.range.lowerBound) { return "" }
211 // Archived trees export their heading only.
212 if tags.contains("ARCHIVE") { return self.heading(heading) }
213 var out = ""
214 for child in node.children { out += element(child) }
215 return out
216 case .zerothSection, .document:
180217 var out = ""
181218 for child in node.children { out += element(child) }
182219 return out
@@ -244,6 +281,55 @@ final class HTMLRenderer {
244281 return candidate
245282 }
246283
284 /// `org-export--selected-trees`: headings with a select tag, everything below them and
285 /// their ancestors; nil when no heading has one.
286 static func selection(_ tree: OrgTree, tags: [String]) -> Set<Int>? {
287 let model = DocumentModel(tree: tree)
288 let picked = model.headings.indices.filter { model.headings[$0].localTags.contains(where: tags.contains) }
289 guard !picked.isEmpty else { return nil }
290 var set: Set<Int> = []
291 for index in picked {
292 set.insert(model.headings[index].headingRange.lowerBound)
293 for a in model.ancestors(of: index) { set.insert(model.headings[a].headingRange.lowerBound) }
294 let level = model.headings[index].level
295 for d in model.headings[(index + 1)...].prefix(while: { $0.level > level }) { set.insert(d.headingRange.lowerBound) }
296 }
297 return set
298 }
299
300 /// The table of contents, as ox-html writes it, or nothing.
301 func tableOfContents() -> String {
302 guard options.toc != nil, !tocEntries.isEmpty else { return "" }
303 var out = "<div id=\"table-of-contents\" role=\"doc-toc\">\n<h2>Table of Contents</h2>\n<div id=\"text-table-of-contents\" role=\"doc-toc\">\n"
304 var depth = 0
305 var open: [Bool] = []
306 let base = tocEntries.map(\.level).min() ?? 1
307 for entry in tocEntries {
308 let level = entry.level - base + 1
309 if level > depth {
310 while depth < level {
311 out += "\n<ul>\n"
312 depth += 1
313 open.append(false)
314 }
315 } else {
316 out += "</li>\n"
317 while depth > level {
318 out += "</ul>\n</li>\n"
319 depth -= 1
320 open.removeLast()
321 }
322 }
323 out += "<li><a href=\"#\(Self.escape(entry.id))\">\(entry.html)</a>"
324 }
325 out += "</li>\n"
326 while depth > 1 {
327 out += "</ul>\n</li>\n"
328 depth -= 1
329 }
330 return out + "</ul>\n</div>\n</div>\n"
331 }
332
247333 private func heading(_ node: SyntaxNode) -> String {
248334 let tokens = node.tokens
249335 let level = tokens.first { $0.kind == .stars }?.text.count ?? 1
@@ -259,17 +345,28 @@ final class HTMLRenderer {
259345 let id = customID ?? slug(titleText)
260346 if customID != nil { ids.insert(id) }
261347 var parts: [String] = []
262 if let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
348 let h = min(level + 1, 6)
349 if let num = options.num, level <= num {
350 if numbers.count < level { numbers += Array(repeating: 0, count: level - numbers.count) }
351 numbers = Array(numbers.prefix(level))
352 numbers[level - 1] += 1
353 parts.append("<span class=\"section-number-\(h)\">\(numbers.map(String.init).joined(separator: ".")).</span>")
354 }
355 if options.todo, let todo = tokens.first(where: { $0.kind == .todoKeyword })?.text {
263356 parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>")
264357 }
265 if let priority = tokens.first(where: { $0.kind == .priority })?.text {
358 if options.priority, let priority = tokens.first(where: { $0.kind == .priority })?.text {
266359 parts.append("<span class=\"priority\">\(Self.escape(priority))</span>")
267360 }
268361 if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) }
269 if let tags = tokens.first(where: { $0.kind == .tags })?.text {
270 parts += tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
362 var tagParts: [String] = []
363 if options.tags, let tags = tokens.first(where: { $0.kind == .tags })?.text {
364 tagParts = tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" }
271365 }
272 let h = min(level + 1, 6)
366 if let toc = options.toc, level <= toc {
367 tocEntries.append((level, id, (parts + (options.tagsInTOC ? tagParts : [])).joined(separator: " ")))
368 }
369 parts += tagParts
273370 return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n"
274371 }
275372
@@ -488,21 +585,44 @@ final class HTMLRenderer {
488585 return out
489586 }
490587
491 /// `\alpha` and friends as characters.
588 /// Text: LaTeX fragments left for MathJax, `\alpha` and friends as characters, `_` and `^`
589 /// scripts as `<sub>` and `<sup>`, as `#+OPTIONS` says.
590 static let textObjects = try! NSRegularExpression(pattern: [
591 #"\\\((.+?)\\\)"#, #"\\\[(.+?)\\\]"#, #"\$\$(.+?)\$\$"#,
592 #"(?<![$\w])\$([^\s$.,;](?:[^$]*?[^\s$.,\\])?)\$(?=[\s.,;:!?'")\]-]|$)"#,
593 #"\\([a-zA-Z]+)(\{\})?"#,
594 #"(?<=\S)([_^])(\{[^{}]*\}|[+-]?[\p{L}\p{N}.,\\]*[\p{L}\p{N}])"#,
595 ].joined(separator: "|"))
596
492597 private func entities(_ text: String) -> String {
493 guard text.contains("\\") else { return Self.escape(text) }
598 guard text.contains(where: { $0 == "\\" || $0 == "$" || $0 == "_" || $0 == "^" }) else { return Self.escape(text) }
599 let ns = text as NSString
494600 var out = ""
495 var rest = Substring(text)
496 while let m = rest.firstMatch(of: /\\([a-zA-Z]+)(\{\})?/) {
497 out += Self.escape(String(rest[..<m.range.lowerBound]))
498 if let display = OrgEntities.display[String(m.1)] {
499 out += Self.escape(display)
601 var at = 0
602 for m in Self.textObjects.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
603 out += Self.escape(ns.substring(with: NSRange(location: at, length: m.range.location - at)))
604 at = NSMaxRange(m.range)
605 let whole = ns.substring(with: m.range)
606 func group(_ i: Int) -> String? { m.range(at: i).location == NSNotFound ? nil : ns.substring(with: m.range(at: i)) }
607 if group(1) != nil || group(2) != nil || group(3) != nil || group(4) != nil {
608 guard options.tex else { continue }
609 hasMath = true
610 out += Self.escape(whole)
611 } else if let name = group(5) {
612 out += Self.escape(OrgEntities.display[name] ?? whole)
613 } else if let marker = group(7), let body = group(8) {
614 let braced = body.hasPrefix("{")
615 if options.scripts == .off || (options.scripts == .braces && !braced) {
616 out += Self.escape(whole)
617 continue
618 }
619 let inner = braced ? String(body.dropFirst().dropLast()) : body
620 out += marker == "_" ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
500621 } else {
501 out += Self.escape(String(rest[m.range]))
622 out += Self.escape(whole)
502623 }
503 rest = rest[m.range.upperBound...]
504624 }
505 return out + Self.escape(String(rest))
625 return out + Self.escape(ns.substring(from: at))
506626 }
507627
508628 private func object(_ node: SyntaxNode) -> String {
@@ -520,14 +640,21 @@ final class HTMLRenderer {
520640 case .superscript:
521641 let text = node.text
522642 let body = text.dropFirst()
523 let inner = body.hasPrefix("{") && body.hasSuffix("}") ? String(body.dropFirst().dropLast()) : String(body)
643 let braced = body.hasPrefix("{") && body.hasSuffix("}")
644 if options.scripts == .off || (options.scripts == .braces && !braced) { return Self.escape(text) }
645 let inner = braced ? String(body.dropFirst().dropLast()) : String(body)
524646 return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>"
525647 case .lineBreak: return "<br>\n"
526648 case .target, .macro: return ""
527649 case .inlineSourceBlock:
528650 if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" }
529651 return Self.escape(node.text)
530 case .statisticsCookie, .latexFragment: return Self.escape(node.text)
652 case .latexFragment:
653 // For MathJax, as ox-html leaves them with `tex:t`; `tex:nil` drops them.
654 guard options.tex else { return "" }
655 hasMath = true
656 return Self.escape(node.text)
657 case .statisticsCookie: return Self.escape(node.text)
531658 default: return inline(node)
532659 }
533660 }
Sources/OrgCore/Keymap/Presets.swift +3
@@ -729,6 +729,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
729729 keys = "C-c C-x A"
730730 command = "org.archive.to-sibling"
731731 [[bind]]
732 keys = "C-c C-e"
733 command = "app.export-dialog"
734 [[bind]]
732735 keys = "C-c C-x C-c"
733736 command = "org.columns"
734737 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -84,6 +84,7 @@ public enum EditorCommands {
8484 EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app),
8585 EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app),
8686 EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app),
87 EditorCommand(id: "app.export-dialog", title: "Export…", action: .app),
8788 EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app),
8889 EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app),
8990 EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app),
Sources/Orgstar/ColumnViewSheet.swift −5
@@ -55,8 +55,3 @@ struct ColumnViewSheet: View {
5555 return String(repeating: "*", count: row.level) + " " + row.values[i]
5656 }
5757}
58
59struct ColumnViewRequest: Identifiable {
60 let id = UUID()
61 let view: ColumnView.View
62}
Sources/Orgstar/ContentView.swift +39 −11
@@ -52,7 +52,8 @@ struct ContentView: View {
5252 }
5353 /// A block being edited apart (`org-edit-special`).
5454 @State private var blockEdit: BlockEditRequest?
55 @State private var columnView: ColumnViewRequest?
55 /// A sheet a command opened: column view or the export dialog.
56 @State private var panel: Panel?
5657
5758 @State private var selectedFile: String?
5859 /// A file that isn't text, shown as a preview instead of the open document.
@@ -208,8 +209,13 @@ struct ContentView: View {
208209 .onChange(of: options, initial: true) { _, options in session.options = options }
209210 .onChange(of: preset, initial: true) { loadKeymap() }
210211 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
211 .sheet(item: $columnView) { request in
212 ColumnViewSheet(view: request.view) { offset in commands.editor?.reveal(offset) }
212 .sheet(item: $panel) { panel in
213 switch panel {
214 case .columns(_, let view):
215 ColumnViewSheet(view: view) { offset in commands.editor?.reveal(offset) }
216 case .export(_, let source):
217 ExportSheet(source: source) { format, destination, open in export(format, to: destination, open: open) }
218 }
213219 }
214220 .sheet(item: $blockEdit) { request in
215221 BlockEditorView(edit: request.edit) { content in
@@ -333,8 +339,9 @@ struct ContentView: View {
333339 }
334340 }
335341
336 /// Writes the export beside the file, as Emacs's export dispatcher does.
337 private func export(_ format: String) {
342 /// Writes the export beside the file, as Emacs's export dispatcher does, or to
343 /// `destination`, and opens it with `open`.
344 private func export(_ format: String, to destination: URL? = nil, open: Bool = false) {
338345 guard let path = session.path, let text = session.document?.text else {
339346 commands.show("No file is open")
340347 return
@@ -342,16 +349,17 @@ struct ContentView: View {
342349 let url = URL(fileURLWithPath: path)
343350 let base = url.deletingPathExtension()
344351 let defaults = Preferences.orgSettings
352 let open = open || format == "html-open"
345353 switch format {
346354 case "html", "html-open", "markdown":
347 let target = base.appendingPathExtension(format == "markdown" ? "md" : "html")
355 let target = destination ?? base.appendingPathExtension(format == "markdown" ? "md" : "html")
348356 let content = format == "markdown"
349357 ? MarkdownExport.document(text, settings: defaults)
350 : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults)
358 : HTMLExport.page(text, fallbackTitle: base.lastPathComponent, settings: defaults, path: path)
351359 do {
352360 try content.write(to: target, atomically: true, encoding: .utf8)
353361 commands.show("Exported to \(target.lastPathComponent)")
354 if format == "html-open" { NSWorkspace.shared.open(target) }
362 if open { NSWorkspace.shared.open(target) }
355363 } catch {
356364 commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)")
357365 }
@@ -361,8 +369,14 @@ struct ContentView: View {
361369 commands.show("Exporting with Emacs…")
362370 Task {
363371 do {
364 let written = try await EmacsRunner.export(text: text, path: path, function: function)
365 commands.show("Exported to \((written as NSString).lastPathComponent)")
372 var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function))
373 if let destination, destination.standardizedFileURL != written.standardizedFileURL {
374 try? FileManager.default.removeItem(at: destination)
375 try FileManager.default.moveItem(at: written, to: destination)
376 written = destination
377 }
378 commands.show("Exported to \(written.lastPathComponent)")
379 if open { NSWorkspace.shared.open(written) }
366380 } catch {
367381 commands.show("\(error)")
368382 }
@@ -378,7 +392,7 @@ struct ContentView: View {
378392 case .editBlock(let edit):
379393 blockEdit = BlockEditRequest(edit: edit)
380394 case .columnView(let view):
381 columnView = ColumnViewRequest(view: view)
395 panel = .columns(UUID(), view)
382396 case .emacsTable(let table, let point, let form, let reason, let lisp):
383397 let run = {
384398 commands.show("Recalculating in Emacs (\(reason))…")
@@ -576,6 +590,9 @@ struct ContentView: View {
576590 case "app.capture":
577591 NSApp.activate()
578592 openWindow(id: "capture")
593 case "app.export-dialog":
594 guard let path = session.path else { return commands.show("No file is open") }
595 panel = .export(UUID(), URL(fileURLWithPath: path))
579596 case let id where id.hasPrefix("app.export."):
580597 export(String(id.dropFirst("app.export.".count)))
581598 default:
@@ -854,6 +871,17 @@ struct BufferTabs: View {
854871 }
855872}
856873
874enum Panel: Identifiable {
875 case columns(UUID, ColumnView.View)
876 case export(UUID, URL)
877
878 var id: UUID {
879 switch self {
880 case .columns(let id, _), .export(let id, _): id
881 }
882 }
883}
884
857885struct BlockEditRequest: Identifiable {
858886 let id = UUID()
859887 let edit: BlockEdit
Sources/Orgstar/ExportSheet.swift added +57
@@ -0,0 +1,57 @@
1import AppKit
2import SwiftUI
3
4/// C-c C-e: the format, where the file goes, and whether to open it after.
5struct ExportSheet: View {
6 let source: URL
7 let export: (_ format: String, _ destination: URL, _ open: Bool) -> Void
8 @AppStorage("exportFormat") private var format = "html"
9 @AppStorage("exportOpenAfter") private var openAfter = false
10 @State private var destination: URL?
11 @Environment(\.dismiss) private var dismiss
12
13 static let formats: [(id: String, title: String, ext: String)] = [
14 ("html", "HTML", "html"), ("markdown", "Markdown", "md"), ("pdf", "PDF (Emacs)", "pdf"),
15 ("odt", "ODT (Emacs)", "odt"), ("latex", "LaTeX (Emacs)", "tex"), ("text", "Plain text (Emacs)", "txt"),
16 ]
17
18 private var ext: String { Self.formats.first { $0.id == format }?.ext ?? "html" }
19 private var target: URL { destination ?? source.deletingPathExtension().appendingPathExtension(ext) }
20
21 var body: some View {
22 Form {
23 Picker("Format", selection: $format) {
24 ForEach(Self.formats, id: \.id) { Text($0.title).tag($0.id) }
25 }
26 .onChange(of: format) { destination = destination.map { $0.deletingPathExtension().appendingPathExtension(ext) } }
27 LabeledContent("Destination") {
28 HStack {
29 Text(target.path(percentEncoded: false)).lineLimit(1).truncationMode(.middle).foregroundStyle(.secondary)
30 Button("Choose…") { choose() }
31 }
32 }
33 Toggle("Open after export", isOn: $openAfter)
34 }
35 .formStyle(.grouped)
36 .safeAreaInset(edge: .bottom) {
37 HStack {
38 Spacer()
39 Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
40 Button("Export") {
41 export(format, target, openAfter)
42 dismiss()
43 }
44 .keyboardShortcut(.defaultAction)
45 }
46 .padding()
47 }
48 .frame(width: 520)
49 }
50
51 private func choose() {
52 let panel = NSSavePanel()
53 panel.directoryURL = target.deletingLastPathComponent()
54 panel.nameFieldStringValue = target.lastPathComponent
55 if panel.runModal() == .OK, let url = panel.url { destination = url }
56 }
57}
Tests/OrgCoreTests/ExportOptionsTests.swift added +79
@@ -0,0 +1,79 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct ExportOptionsTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 @Test func readsOptions() {
9 let o = ExportOptions.parse("#+OPTIONS: toc:2 num:nil tags:not-in-toc\n#+OPTIONS: todo:nil ^:{} pri:t H:4\n#+EXCLUDE_TAGS: private\n#+HTML_HEAD: <link rel=x>\n")
10 #expect(o.toc == 2 && o.num == nil && o.tags && !o.tagsInTOC && !o.todo && o.scripts == .braces && o.priority && o.headlineLevels == 4)
11 #expect(o.excludeTags == ["private"] && o.htmlHead == ["<link rel=x>"])
12 #expect(ExportOptions.parse("* x\n").toc == 3)
13 #expect(ExportOptions.parse("#+OPTIONS: H:2 toc:t\n").toc == 2)
14 }
15
16 @Test func pagesFollowOptions() {
17 let org = """
18 #+TITLE: Doc
19 #+OPTIONS: ^:{} pri:t
20 #+HTML_HEAD: <link rel="stylesheet" href="s.css">
21 * TODO [#A] One :work:
22 x_1 y_{2} \\(a^2\\) $b$ and $5
23 ** Two
24 * Secret :noexport:
25 hidden
26 * COMMENT Draft
27 gone
28 * Old :ARCHIVE:
29 archived body
30 """
31 let page = HTMLExport.page(org, fallbackTitle: "f")
32 #expect(page.contains("<div id=\"table-of-contents\""))
33 #expect(page.contains("<span class=\"section-number-2\">1.</span>"))
34 #expect(page.contains("<span class=\"section-number-3\">1.1.</span>"))
35 #expect(page.contains("x_1 y<sub>2</sub>"))
36 #expect(page.contains("<span class=\"priority\">[#A]</span>"))
37 #expect(page.contains("<link rel=\"stylesheet\" href=\"s.css\">"))
38 #expect(page.contains("MathJax-script") && page.contains("\\(a^2\\)"))
39 #expect(!page.contains("hidden") && !page.contains("Secret") && !page.contains("gone"))
40 #expect(page.contains("Old") && !page.contains("archived body"))
41 let plain = HTMLExport.page("#+OPTIONS: toc:nil num:nil todo:nil tags:nil\n* TODO A :t:\n", fallbackTitle: "f")
42 #expect(!plain.contains("id=\"table-of-contents\"") && !plain.contains("section-number") && !plain.contains("TODO") && !plain.contains("class=\"tag\""))
43 let selected = HTMLExport.page("* A\na\n* B :export:\nb\n** C\nc\n", fallbackTitle: "f")
44 #expect(!selected.contains("<p>a</p>") && selected.contains("<p>b</p>") && selected.contains("<p>c</p>"))
45 }
46
47 @Test(.enabled(if: oracle))
48 func expandsIncludesAndMacrosLikeOrg() throws {
49 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-include-\(UUID().uuidString)")
50 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
51 defer { try? FileManager.default.removeItem(at: folder) }
52 try "* Part\nbody\n** Sub\nsub body\n* Other\n:PROPERTIES:\n:CUSTOM_ID: oth\n:END:\nother\n".write(to: folder.appendingPathComponent("part.org"), atomically: true, encoding: .utf8)
53 try "line 1\nline 2\n* not a heading\nline 4\nline 5\n".write(to: folder.appendingPathComponent("code.sh"), atomically: true, encoding: .utf8)
54 let text = """
55 #+TITLE: The Doc
56 #+AUTHOR: Me
57 #+MACRO: greet Hello, $1 and $2!
58 Intro {{{title}}} by {{{author}}}.
59 #+INCLUDE: "part.org"
60 #+INCLUDE: "part.org::*Sub" :minlevel 1
61 #+INCLUDE: "part.org::#oth"
62 #+INCLUDE: "code.sh" src sh :lines "2-4"
63 #+INCLUDE: "code.sh" example
64 {{{greet(you\\, me,them)}}} {{{n}}} {{{n}}} {{{n(x)}}} {{{keyword(AUTHOR)}}} {{{input-file}}}
65 """ + "\n"
66 let path = folder.appendingPathComponent("main.org").path
67 try text.write(toFile: path, atomically: true, encoding: .utf8)
68 let emacs = try EmacsOracle.evaluate(text, """
69 (with-current-buffer (find-file-noselect \(lispString(path)))
70 (require 'ox)
71 (org-export-expand-include-keyword)
72 (org-macro-initialize-templates)
73 (org-macro-replace-all org-macro-templates)
74 (list (buffer-substring-no-properties (point-min) (point-max))))
75 """)
76 let ours = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: folder), path: path)
77 #expect(ours == emacs.first, "ours:\n\(ours)\nemacs:\n\(emacs.first ?? "")")
78 }
79}