Sources/OrgCore/Export/ExportOptions.swift
308 lines · 15619 bytes
13 symbols in this file
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 /// `setup` is the setup files' keyword lines, whose definitions count too.
183 public static func macros(_ text: String, path: String?, now: Date = Date(), setup: String = "") -> String {
184 guard text.contains("{{{") else { return text }
185 var definitions: [String: String] = [:]
186 var keywords: [String: String] = [:]
187 for m in (setup + text).matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) {
188 let key = m.1.uppercased()
189 let value = String(m.2)
190 if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) {
191 definitions[d.1.lowercased()] = String(d.2)
192 } else if keywords[key] == nil {
193 keywords[key] = value
194 }
195 }
196 var counters: [String: Int] = [:]
197 var model: DocumentModel?
198 /// `org-entry-get` with `selective` inheritance at the heading holding `location`, or
199 /// at the entry `search` finds (`*Title`, `#custom-id` or a title).
200 func property(_ name: String, _ search: String?, at location: Int) -> String {
201 if model == nil { model = DocumentModel(tree: OrgParser.parse(text)) }
202 let headings = model!.headings
203 let index: Int?
204 if let search, !search.isEmpty {
205 if search.hasPrefix("#") {
206 let id = String(search.dropFirst())
207 index = headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
208 } else {
209 let title = search.hasPrefix("*") ? String(search.dropFirst()) : search
210 index = headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
211 }
212 } else {
213 index = headings.lastIndex { $0.headingRange.lowerBound <= location }
214 }
215 guard let index else { return "" }
216 if name.uppercased() == "ITEM" { return headings[index].title }
217 return model!.property(name, of: index)?.value ?? ""
218 }
219 let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
220 var out = ""
221 var at = 0
222 let ns = text as NSString
223 for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
224 out += ns.substring(with: NSRange(location: at, length: m.range.location - at))
225 at = NSMaxRange(m.range)
226 // Not on #+MACRO lines themselves.
227 let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location
228 if ns.substring(from: lineStart).range(of: "^[ \\t]*#\\+MACRO:", options: [.regularExpression, .caseInsensitive]) != nil {
229 out += ns.substring(with: m.range)
230 continue
231 }
232 let name = ns.substring(with: m.range(at: 1)).lowercased()
233 // Inline results stay for the exporter, which shows them as `:exports` says.
234 if name == "results", definitions[name] == nil {
235 out += ns.substring(with: m.range)
236 continue
237 }
238 let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
239 let location = m.range.location
240 out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now) {
241 property($0, $1, at: location)
242 }
243 }
244 out += ns.substring(from: at)
245 return out
246 }
247
248 /// Macro arguments: split on commas, `\,` for a literal one, blanks around trimmed.
249 static func arguments(_ s: String) -> [String] {
250 var args: [String] = []
251 var current = ""
252 var escaped = false
253 for c in s {
254 if escaped {
255 current.append(c == "," ? "," : "\\")
256 if c != "," { current.append(c) }
257 escaped = false
258 } else if c == "\\" {
259 escaped = true
260 } else if c == "," {
261 args.append(current)
262 current = ""
263 } else {
264 current.append(c)
265 }
266 }
267 if escaped { current.append("\\") }
268 args.append(current)
269 return args.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
270 }
271
272 static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String],
273 counters: inout [String: Int], path: String?, now: Date, property: (String, String?) -> String) -> String {
274 if let template = definitions[name] {
275 guard !template.hasPrefix("(eval") else { return "" }
276 var result = template
277 for (i, arg) in args.enumerated().reversed() { result = result.replacingOccurrences(of: "$\(i + 1)", with: arg) }
278 return result.replacingOccurrences(of: "\\$[0-9]+", with: "", options: .regularExpression)
279 }
280 switch name {
281 case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
282 case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
283 case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? ""
284 case "property": return args.first.map { property($0, args.count > 1 ? args[1] : nil) } ?? ""
285 case "n":
286 let counter = args.first ?? ""
287 let action = args.count > 1 ? args[1] : ""
288 if action == "-" { return String(counters[counter] ?? 0) }
289 let value = Int(action) ?? ((counters[counter] ?? 0) + 1)
290 counters[counter] = value
291 return String(value)
292 case "time":
293 guard let format = args.first else { return "" }
294 return strftime(format, now)
295 default: return ""
296 }
297 }
298
299 /// `format-time-string` for the common directives.
300 static func strftime(_ format: String, _ date: Date) -> String {
301 var t = time_t(date.timeIntervalSince1970)
302 var tm = tm()
303 localtime_r(&t, &tm)
304 var buffer = [Int8](repeating: 0, count: 256)
305 let count = Foundation.strftime(&buffer, buffer.count, format, &tm)
306 return String(decoding: buffer.prefix(count).map { UInt8(bitPattern: $0) }, as: UTF8.self)
307 }
308}