HTML export options, includes, macros, MathJax; export dialog !92
8 files changed, +609 −43
Layout: unified · split
Sources/OrgCore/Export/ExportOptions.swift added +276
| @@ -0,0 +1,276 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// What an export includes, from `#+OPTIONS` and the export keywords (ox.el, Org 9.8.7). | ||
| 4 | public 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`). | ||
| 94 | public 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 { | |||
| 12 | /// footnotes collected at the end, captioned images as figures. | 12 | /// footnotes collected at the end, captioned images as figures. |
| 13 | public enum HTMLExport { | 13 | public enum HTMLExport { |
| 14 | public static func body(_ text: String, settings: OrgSettings = .default) -> String { | 14 | public static func body(_ text: String, settings: OrgSettings = .default) -> String { |
| 15 | let renderer = HTMLRenderer(settings: settings) | 15 | let renderer = HTMLRenderer(settings: settings, options: .orgo) |
| 16 | return renderer.document(text) | 16 | return renderer.document(text) |
| 17 | } | 17 | } |
| 18 | 18 | ||
| @@ -36,15 +36,30 @@ public enum HTMLExport { | |||
| 36 | return meta | 36 | return meta |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | /// A standalone page: the body under the document's title, with a small stylesheet. | 39 | /// A standalone page: the body under the document's title, with a small stylesheet, as |
| 40 | public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default) -> String { | 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) | ||
| 41 | let meta = metadata(text, settings: settings) | 46 | let meta = metadata(text, settings: settings) |
| 42 | let title = meta.title ?? fallbackTitle | 47 | let title = meta.title ?? fallbackTitle |
| 43 | let renderer = HTMLRenderer(settings: settings) | 48 | let renderer = HTMLRenderer(settings: settings, options: options) |
| 44 | let titleHTML = renderer.inlineString(title) | 49 | let titleHTML = renderer.inlineString(title) |
| 45 | var header = "<h1 class=\"title\">\(titleHTML)</h1>\n" | 50 | var header = options.title ? "<h1 class=\"title\">\(titleHTML)</h1>\n" : "" |
| 46 | let byline = [meta.author, meta.date].compactMap { $0 }.filter { !$0.isEmpty } | 51 | let byline = [options.author ? meta.author : nil, options.date ? meta.date : nil].compactMap { $0 }.filter { !$0.isEmpty } |
| 47 | if !byline.isEmpty { header += "<p class=\"byline\">\(byline.map(HTMLRenderer.escape).joined(separator: " · "))</p>\n" } | 52 | 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 | } | ||
| 48 | return """ | 63 | return """ |
| 49 | <!DOCTYPE html> | 64 | <!DOCTYPE html> |
| 50 | <html lang="en"> | 65 | <html lang="en"> |
| @@ -62,10 +77,11 @@ public enum HTMLExport { | |||
| 62 | .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); } | 77 | .tag { font-size: 0.75em; padding: 0 0.3em; border-radius: 3px; background: rgba(127, 127, 127, 0.18); } |
| 63 | .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; } | 78 | .todo { color: #c33; } .done { color: #393; } .byline { color: #888; } .center { text-align: center; } |
| 64 | figure { margin: 1em 0; } img { max-width: 100%; } | 79 | 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; } | ||
| 65 | </style> | 81 | </style> |
| 66 | </head> | 82 | \(head)</head> |
| 67 | <body> | 83 | <body> |
| 68 | \(header)\(renderer.document(text))</body> | 84 | \(header)\(body)</body> |
| 69 | </html> | 85 | </html> |
| 70 | 86 | ||
| 71 | """ | 87 | """ |
| @@ -74,6 +90,13 @@ public enum HTMLExport { | |||
| 74 | 90 | ||
| 75 | final class HTMLRenderer { | 91 | final class HTMLRenderer { |
| 76 | let settings: OrgSettings | 92 | 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 | ||
| 77 | private var footnoteNumbers: [String: Int] = [:] | 100 | private var footnoteNumbers: [String: Int] = [:] |
| 78 | private var footnoteOrder: [(label: String, content: FootnoteContent)] = [] | 101 | private var footnoteOrder: [(label: String, content: FootnoteContent)] = [] |
| 79 | private var definitions: [String: String] = [:] | 102 | private var definitions: [String: String] = [:] |
| @@ -86,8 +109,9 @@ final class HTMLRenderer { | |||
| 86 | case inline(String) | 109 | case inline(String) |
| 87 | } | 110 | } |
| 88 | 111 | ||
| 89 | init(settings: OrgSettings) { | 112 | init(settings: OrgSettings, options: ExportOptions = .orgo) { |
| 90 | self.settings = settings | 113 | self.settings = settings |
| 114 | self.options = options | ||
| 91 | } | 115 | } |
| 92 | 116 | ||
| 93 | static func escape(_ s: String) -> String { | 117 | static func escape(_ s: String) -> String { |
| @@ -109,6 +133,7 @@ final class HTMLRenderer { | |||
| 109 | func document(_ text: String) -> String { | 133 | func document(_ text: String) -> String { |
| 110 | let tree = OrgParser.parse(text, defaults: settings) | 134 | let tree = OrgParser.parse(text, defaults: settings) |
| 111 | collectDefinitions(tree.root) | 135 | collectDefinitions(tree.root) |
| 136 | selected = Self.selection(tree, tags: options.selectTags) | ||
| 112 | var out = "" | 137 | var out = "" |
| 113 | for child in tree.root.children { out += element(child) } | 138 | for child in tree.root.children { out += element(child) } |
| 114 | out += footnoteSection() | 139 | out += footnoteSection() |
| @@ -176,7 +201,19 @@ final class HTMLRenderer { | |||
| 176 | if exports == "results" || exports == "none" { return "" } | 201 | if exports == "results" || exports == "none" { return "" } |
| 177 | } | 202 | } |
| 178 | switch node.kind { | 203 | 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: | ||
| 180 | var out = "" | 217 | var out = "" |
| 181 | for child in node.children { out += element(child) } | 218 | for child in node.children { out += element(child) } |
| 182 | return out | 219 | return out |
| @@ -244,6 +281,55 @@ final class HTMLRenderer { | |||
| 244 | return candidate | 281 | return candidate |
| 245 | } | 282 | } |
| 246 | 283 | ||
| 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 | |||
| 247 | private func heading(_ node: SyntaxNode) -> String { | 333 | private func heading(_ node: SyntaxNode) -> String { |
| 248 | let tokens = node.tokens | 334 | let tokens = node.tokens |
| 249 | let level = tokens.first { $0.kind == .stars }?.text.count ?? 1 | 335 | let level = tokens.first { $0.kind == .stars }?.text.count ?? 1 |
| @@ -259,17 +345,28 @@ final class HTMLRenderer { | |||
| 259 | let id = customID ?? slug(titleText) | 345 | let id = customID ?? slug(titleText) |
| 260 | if customID != nil { ids.insert(id) } | 346 | if customID != nil { ids.insert(id) } |
| 261 | var parts: [String] = [] | 347 | 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 { | ||
| 263 | parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>") | 356 | parts.append("<span class=\"todo \(settings.isDone(todo) ? "done " : "")\(Self.escape(todo))\">\(Self.escape(todo))</span>") |
| 264 | } | 357 | } |
| 265 | if let priority = tokens.first(where: { $0.kind == .priority })?.text { | 358 | if options.priority, let priority = tokens.first(where: { $0.kind == .priority })?.text { |
| 266 | parts.append("<span class=\"priority\">\(Self.escape(priority))</span>") | 359 | parts.append("<span class=\"priority\">\(Self.escape(priority))</span>") |
| 267 | } | 360 | } |
| 268 | if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } | 361 | if let title, !titleText.isEmpty { parts.append(inline(title).trimmingCharacters(in: .whitespaces)) } |
| 269 | if let tags = tokens.first(where: { $0.kind == .tags })?.text { | 362 | var tagParts: [String] = [] |
| 270 | parts += tags.split(separator: ":").map { "<span class=\"tag\">\(Self.escape(String($0)))</span>" } | 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>" } | ||
| 271 | } | 365 | } |
| 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 | ||
| 273 | return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n" | 370 | return "<h\(h) id=\"\(Self.escape(id))\">\(parts.joined(separator: " "))</h\(h)>\n" |
| 274 | } | 371 | } |
| 275 | 372 | ||
| @@ -488,21 +585,44 @@ final class HTMLRenderer { | |||
| 488 | return out | 585 | return out |
| 489 | } | 586 | } |
| 490 | 587 | ||
| 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 | |||
| 492 | private func entities(_ text: String) -> String { | 597 | 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 | ||
| 494 | var out = "" | 600 | var out = "" |
| 495 | var rest = Substring(text) | 601 | var at = 0 |
| 496 | while let m = rest.firstMatch(of: /\\([a-zA-Z]+)(\{\})?/) { | 602 | for m in Self.textObjects.matches(in: text, range: NSRange(location: 0, length: ns.length)) { |
| 497 | out += Self.escape(String(rest[..<m.range.lowerBound])) | 603 | out += Self.escape(ns.substring(with: NSRange(location: at, length: m.range.location - at))) |
| 498 | if let display = OrgEntities.display[String(m.1)] { | 604 | at = NSMaxRange(m.range) |
| 499 | out += Self.escape(display) | 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>" | ||
| 500 | } else { | 621 | } else { |
| 501 | out += Self.escape(String(rest[m.range])) | 622 | out += Self.escape(whole) |
| 502 | } | 623 | } |
| 503 | rest = rest[m.range.upperBound...] | ||
| 504 | } | 624 | } |
| 505 | return out + Self.escape(String(rest)) | 625 | return out + Self.escape(ns.substring(from: at)) |
| 506 | } | 626 | } |
| 507 | 627 | ||
| 508 | private func object(_ node: SyntaxNode) -> String { | 628 | private func object(_ node: SyntaxNode) -> String { |
| @@ -520,14 +640,21 @@ final class HTMLRenderer { | |||
| 520 | case .superscript: | 640 | case .superscript: |
| 521 | let text = node.text | 641 | let text = node.text |
| 522 | let body = text.dropFirst() | 642 | 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) | ||
| 524 | return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>" | 646 | return text.hasPrefix("_") ? "<sub>\(Self.escape(inner))</sub>" : "<sup>\(Self.escape(inner))</sup>" |
| 525 | case .lineBreak: return "<br>\n" | 647 | case .lineBreak: return "<br>\n" |
| 526 | case .target, .macro: return "" | 648 | case .target, .macro: return "" |
| 527 | case .inlineSourceBlock: | 649 | case .inlineSourceBlock: |
| 528 | if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" } | 650 | if let m = node.text.firstMatch(of: /\{(.*)\}$/) { return "<code>\(Self.escape(String(m.1)))</code>" } |
| 529 | return Self.escape(node.text) | 651 | 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) | ||
| 531 | default: return inline(node) | 658 | default: return inline(node) |
| 532 | } | 659 | } |
| 533 | } | 660 | } |
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -729,6 +729,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 729 | keys = "C-c C-x A" | 729 | keys = "C-c C-x A" |
| 730 | command = "org.archive.to-sibling" | 730 | command = "org.archive.to-sibling" |
| 731 | [[bind]] | 731 | [[bind]] |
| 732 | keys = "C-c C-e" | ||
| 733 | command = "app.export-dialog" | ||
| 734 | [[bind]] | ||
| 732 | keys = "C-c C-x C-c" | 735 | keys = "C-c C-x C-c" |
| 733 | command = "org.columns" | 736 | command = "org.columns" |
| 734 | [[bind]] | 737 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -84,6 +84,7 @@ public enum EditorCommands { | |||
| 84 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), | 84 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), |
| 85 | EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), | 85 | EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), |
| 86 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), | 86 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), |
| 87 | EditorCommand(id: "app.export-dialog", title: "Export…", action: .app), | ||
| 87 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), | 88 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 88 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), | 89 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 89 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), | 90 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), |
Sources/Orgstar/ColumnViewSheet.swift −5
| @@ -55,8 +55,3 @@ struct ColumnViewSheet: View { | |||
| 55 | return String(repeating: "*", count: row.level) + " " + row.values[i] | 55 | return String(repeating: "*", count: row.level) + " " + row.values[i] |
| 56 | } | 56 | } |
| 57 | } | 57 | } |
| 58 | |||
| 59 | struct 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 { | |||
| 52 | } | 52 | } |
| 53 | /// A block being edited apart (`org-edit-special`). | 53 | /// A block being edited apart (`org-edit-special`). |
| 54 | @State private var blockEdit: BlockEditRequest? | 54 | @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? | ||
| 56 | 57 | ||
| 57 | @State private var selectedFile: String? | 58 | @State private var selectedFile: String? |
| 58 | /// A file that isn't text, shown as a preview instead of the open document. | 59 | /// A file that isn't text, shown as a preview instead of the open document. |
| @@ -208,8 +209,13 @@ struct ContentView: View { | |||
| 208 | .onChange(of: options, initial: true) { _, options in session.options = options } | 209 | .onChange(of: options, initial: true) { _, options in session.options = options } |
| 209 | .onChange(of: preset, initial: true) { loadKeymap() } | 210 | .onChange(of: preset, initial: true) { loadKeymap() } |
| 210 | .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } | 211 | .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } |
| 211 | .sheet(item: $columnView) { request in | 212 | .sheet(item: $panel) { panel in |
| 212 | ColumnViewSheet(view: request.view) { offset in commands.editor?.reveal(offset) } | 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 | } | ||
| 213 | } | 219 | } |
| 214 | .sheet(item: $blockEdit) { request in | 220 | .sheet(item: $blockEdit) { request in |
| 215 | BlockEditorView(edit: request.edit) { content in | 221 | BlockEditorView(edit: request.edit) { content in |
| @@ -333,8 +339,9 @@ struct ContentView: View { | |||
| 333 | } | 339 | } |
| 334 | } | 340 | } |
| 335 | 341 | ||
| 336 | /// Writes the export beside the file, as Emacs's export dispatcher does. | 342 | /// Writes the export beside the file, as Emacs's export dispatcher does, or to |
| 337 | private func export(_ format: String) { | 343 | /// `destination`, and opens it with `open`. |
| 344 | private func export(_ format: String, to destination: URL? = nil, open: Bool = false) { | ||
| 338 | guard let path = session.path, let text = session.document?.text else { | 345 | guard let path = session.path, let text = session.document?.text else { |
| 339 | commands.show("No file is open") | 346 | commands.show("No file is open") |
| 340 | return | 347 | return |
| @@ -342,16 +349,17 @@ struct ContentView: View { | |||
| 342 | let url = URL(fileURLWithPath: path) | 349 | let url = URL(fileURLWithPath: path) |
| 343 | let base = url.deletingPathExtension() | 350 | let base = url.deletingPathExtension() |
| 344 | let defaults = Preferences.orgSettings | 351 | let defaults = Preferences.orgSettings |
| 352 | let open = open || format == "html-open" | ||
| 345 | switch format { | 353 | switch format { |
| 346 | case "html", "html-open", "markdown": | 354 | case "html", "html-open", "markdown": |
| 347 | let target = base.appendingPathExtension(format == "markdown" ? "md" : "html") | 355 | let target = destination ?? base.appendingPathExtension(format == "markdown" ? "md" : "html") |
| 348 | let content = format == "markdown" | 356 | let content = format == "markdown" |
| 349 | ? MarkdownExport.document(text, settings: defaults) | 357 | ? 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) |
| 351 | do { | 359 | do { |
| 352 | try content.write(to: target, atomically: true, encoding: .utf8) | 360 | try content.write(to: target, atomically: true, encoding: .utf8) |
| 353 | commands.show("Exported to \(target.lastPathComponent)") | 361 | commands.show("Exported to \(target.lastPathComponent)") |
| 354 | if format == "html-open" { NSWorkspace.shared.open(target) } | 362 | if open { NSWorkspace.shared.open(target) } |
| 355 | } catch { | 363 | } catch { |
| 356 | commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)") | 364 | commands.show("Can't write \(target.lastPathComponent): \(error.localizedDescription)") |
| 357 | } | 365 | } |
| @@ -361,8 +369,14 @@ struct ContentView: View { | |||
| 361 | commands.show("Exporting with Emacs…") | 369 | commands.show("Exporting with Emacs…") |
| 362 | Task { | 370 | Task { |
| 363 | do { | 371 | do { |
| 364 | let written = try await EmacsRunner.export(text: text, path: path, function: function) | 372 | var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function)) |
| 365 | commands.show("Exported to \((written as NSString).lastPathComponent)") | 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) } | ||
| 366 | } catch { | 380 | } catch { |
| 367 | commands.show("\(error)") | 381 | commands.show("\(error)") |
| 368 | } | 382 | } |
| @@ -378,7 +392,7 @@ struct ContentView: View { | |||
| 378 | case .editBlock(let edit): | 392 | case .editBlock(let edit): |
| 379 | blockEdit = BlockEditRequest(edit: edit) | 393 | blockEdit = BlockEditRequest(edit: edit) |
| 380 | case .columnView(let view): | 394 | case .columnView(let view): |
| 381 | columnView = ColumnViewRequest(view: view) | 395 | panel = .columns(UUID(), view) |
| 382 | case .emacsTable(let table, let point, let form, let reason, let lisp): | 396 | case .emacsTable(let table, let point, let form, let reason, let lisp): |
| 383 | let run = { | 397 | let run = { |
| 384 | commands.show("Recalculating in Emacs (\(reason))…") | 398 | commands.show("Recalculating in Emacs (\(reason))…") |
| @@ -576,6 +590,9 @@ struct ContentView: View { | |||
| 576 | case "app.capture": | 590 | case "app.capture": |
| 577 | NSApp.activate() | 591 | NSApp.activate() |
| 578 | openWindow(id: "capture") | 592 | 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)) | ||
| 579 | case let id where id.hasPrefix("app.export."): | 596 | case let id where id.hasPrefix("app.export."): |
| 580 | export(String(id.dropFirst("app.export.".count))) | 597 | export(String(id.dropFirst("app.export.".count))) |
| 581 | default: | 598 | default: |
| @@ -854,6 +871,17 @@ struct BufferTabs: View { | |||
| 854 | } | 871 | } |
| 855 | } | 872 | } |
| 856 | 873 | ||
| 874 | enum 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 | |||
| 857 | struct BlockEditRequest: Identifiable { | 885 | struct BlockEditRequest: Identifiable { |
| 858 | let id = UUID() | 886 | let id = UUID() |
| 859 | let edit: BlockEdit | 887 | let edit: BlockEdit |
Sources/Orgstar/ExportSheet.swift added +57
| @@ -0,0 +1,57 @@ | |||
| 1 | import AppKit | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// C-c C-e: the format, where the file goes, and whether to open it after. | ||
| 5 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct 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 | } | ||