oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3/// A data-only HTML clipboard decoder. Tags, attributes and text become schema
4/// nodes; no DOM, script execution, stylesheet loading or network access exists.
5@MainActor enum DocsHTMLClipboard {
6 // HTML5 named character references, generated from Python's standard-library
7 // html.entities table. Kept as data so decoding never invokes an HTML view.
8 private static let entities: [String: String] = {
9 guard let url = DocsResources.bundle.url(forResource: "HTMLEntities", withExtension: "json"),
10 let data = try? Data(contentsOf: url),
11 let values = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
12 return values
13 }()
14 private final class Element {
15 let tag: String
16 let attrs: [String: String]
17 var children: [Part] = []
18 init(_ tag: String, _ attrs: [String: String] = [:]) { self.tag = tag; self.attrs = attrs }
19 }
20 private enum Part { case text(String), element(Element) }
21 private static let voidTags: Set<String> = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]
22 private static let discarded: Set<String> = ["script", "style", "head", "iframe", "object", "embed", "template", "noscript"]
23 private static let blockTags: Set<String> = ["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "ul", "ol", "li", "blockquote", "hr", "table", "tr", "td", "section", "article", "body", "html"]
24 static func blocks(_ html: String) -> [JSONValue] {
25 guard html.utf8.count <= 16 << 20, let root = parse(html) else { return [] }
26 return convert(root.children, marks: [:]).map(\.json)
27 }
28 private static func parse(_ html: String) -> Element? {
29 let root = Element("root")
30 var stack = [root]
31 var scanner = DocsHTMLScanner(html)
32 while let piece = scanner.next() {
33 guard case .tag(let token) = piece else {
34 if case .text(let value) = piece { stack.last?.children.append(.text(decode(value))) }
35 continue
36 }
37 if token.hasPrefix("<!") || token.hasPrefix("<?") { continue }
38 let closing = token.hasPrefix("</")
39 let inside = String(token.dropFirst(closing ? 2 : 1).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
40 let name = String(inside.prefix { $0.isLetter || $0.isNumber || $0 == "-" }).lowercased()
41 guard !name.isEmpty else { continue }
42 if closing {
43 if let index = stack.lastIndex(where: { $0.tag == name }), index > 0 { stack.removeSubrange(index...) }
44 } else {
45 // HTML permits implicit paragraph/list-item end tags.
46 if ["p", "li"].contains(name), stack.last?.tag == name { stack.removeLast() }
47 let node = Element(name, attributes(String(inside.dropFirst(name.count))))
48 stack.last?.children.append(.element(node))
49 if !voidTags.contains(name) && !token.hasSuffix("/>") {
50 guard stack.count < 64 else { return nil }
51 stack.append(node)
52 }
53 }
54 }
55 return root
56 }
57 private static func attributes(_ source: String) -> [String: String] {
58 DocsHTMLScanner.attributes(source).mapValues(decode)
59 }
60 private static func convert(_ parts: [Part], marks: [String: JSONValue]) -> [DocsClipboardNode] {
61 var result: [DocsClipboardNode] = []
62 var explicitParagraphs: Set<ObjectIdentifier> = []
63 var implicitParagraphs: Set<ObjectIdentifier> = []
64 var paragraph = DocsClipboardNode(type: "paragraph")
65 func appendBlock(_ node: DocsClipboardNode) {
66 let meaningful = node.children.filter { child in
67 child.type != "text" || child.runs.contains { run in
68 if case .string(let text)? = run["insert"] { return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
69 return false
70 }
71 }
72 if node.type == "paragraph", !meaningful.isEmpty, meaningful.allSatisfy({ $0.type == "hard_break" }) {
73 for _ in meaningful {
74 let blank = DocsClipboardNode(type: "paragraph")
75 blank.append("", marks: [:]); explicitParagraphs.insert(ObjectIdentifier(blank)); result.append(blank)
76 }
77 } else { result.append(node) }
78 }
79 func flush() {
80 if !paragraph.children.isEmpty { implicitParagraphs.insert(ObjectIdentifier(paragraph)); appendBlock(paragraph) }
81 paragraph = DocsClipboardNode(type: "paragraph")
82 }
83 for part in parts {
84 if case .element(let element) = part, blockTags.contains(element.tag) || containsBlocks(element) {
85 flush()
86 guard !discarded.contains(element.tag) else { continue }
87 let nestedMarks = styledMarks(element, inherited: marks)
88 switch element.tag {
89 case "ul", "ol":
90 let list = DocsClipboardNode(type: element.tag == "ol" ? "ordered_list" : "bullet_list")
91 if element.tag == "ol", let start = element.attrs["start"], Int(start) != nil { list.attrs["order"] = start }
92 for child in element.children {
93 guard case .element(let item) = child, item.tag == "li" else { continue }
94 let node = DocsClipboardNode(type: "list_item")
95 node.children = convert(item.children, marks: styledMarks(item, inherited: nestedMarks))
96 if node.children.isEmpty { node.children = [DocsClipboardNode(type: "paragraph")] }
97 list.children.append(node)
98 }
99 if !list.children.isEmpty { result.append(list) }
100 case "blockquote":
101 let node = DocsClipboardNode(type: "blockquote")
102 node.children = convert(element.children, marks: nestedMarks)
103 if !node.children.isEmpty { result.append(node) }
104 case "hr": result.append(DocsClipboardNode(type: "horizontal_rule"))
105 case "p", "pre", "h1", "h2", "h3", "h4", "h5", "h6":
106 let heading = element.tag.hasPrefix("h")
107 let node = DocsClipboardNode(type: heading ? "heading" : element.tag == "pre" ? "code_block" : "paragraph")
108 if heading { node.attrs["level"] = String(element.tag.dropFirst()) }
109 let style = styles(element.attrs["style"] ?? "")
110 if let align = style["text-align"] ?? element.attrs["align"], ["left", "center", "right"].contains(align) { node.attrs["align"] = align }
111 for name in (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace) {
112 if name == "docs-align-center" { node.attrs["align"] = "center" }
113 if name == "docs-align-right" { node.attrs["align"] = "right" }
114 if name.hasPrefix("docs-indent-"), let level = Int(name.dropFirst(12)), (0...8).contains(level) { node.attrs["indent"] = String(level) }
115 }
116 for child in element.children { inline(child, into: node, marks: nestedMarks, pre: element.tag == "pre") }
117 if node.children.isEmpty { node.append("", marks: nestedMarks) }
118 explicitParagraphs.insert(ObjectIdentifier(node))
119 appendBlock(node)
120 default: result += convert(element.children, marks: nestedMarks)
121 }
122 } else { inline(part, into: paragraph, marks: marks, pre: false) }
123 }
124 flush()
125 return result.filter { node in
126 if !implicitParagraphs.contains(ObjectIdentifier(node)) { return true }
127 if explicitParagraphs.contains(ObjectIdentifier(node)) { return true }
128 // Ignore indentation between HTML blocks, without stripping spaces
129 // between inline runs inside actual paragraphs.
130 if node.type != "paragraph" { return true }
131 return node.children.contains { child in child.type != "text" || child.runs.contains { run in
132 if case .string(let value)? = run["insert"] { return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
133 return false
134 }}
135 }
136 }
137 private static func inline(_ part: Part, into node: DocsClipboardNode, marks: [String: JSONValue], pre: Bool) {
138 switch part {
139 case .text(let value):
140 let text = pre ? value : value.replacingOccurrences(of: "[\\t\\r\\n ]+", with: " ", options: .regularExpression)
141 if !text.isEmpty { node.append(text, marks: marks) }
142 case .element(let element):
143 guard !discarded.contains(element.tag) else { return }
144 if element.tag == "br" {
145 let classes = (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace)
146 if !classes.contains("Apple-interchange-newline") && !classes.contains("ProseMirror-trailingBreak") { node.children.append(DocsClipboardNode(type: "hard_break")) }
147 return
148 }
149 if element.tag == "img" {
150 guard let src = element.attrs["src"], let scheme = URL(string: src)?.scheme?.lowercased(),
151 ["http", "https"].contains(scheme) || (scheme == "data" && src.lowercased().hasPrefix("data:image/")) else { return }
152 let image = DocsClipboardNode(type: "image")
153 image.attrs = element.attrs.filter { ["src", "alt", "title"].contains($0.key) }
154 node.children.append(image); return
155 }
156 let next = styledMarks(element, inherited: marks)
157 for child in element.children { inline(child, into: node, marks: next, pre: pre) }
158 }
159 }
160 private static func styles(_ value: String) -> [String: String] {
161 var result: [String: String] = [:]
162 for declaration in value.split(separator: ";") {
163 let pair = declaration.split(separator: ":", maxSplits: 1)
164 if pair.count == 2 { result[pair[0].trimmingCharacters(in: .whitespaces).lowercased()] = pair[1].trimmingCharacters(in: .whitespaces).lowercased() }
165 }
166 return result
167 }
168 private static func containsBlocks(_ element: Element) -> Bool {
169 element.children.contains { part in
170 if case .element(let child) = part { return blockTags.contains(child.tag) || containsBlocks(child) }
171 return false
172 }
173 }
174 private static func styledMarks(_ element: Element, inherited: [String: JSONValue]) -> [String: JSONValue] {
175 var result = inherited
176 let style = styles(element.attrs["style"] ?? "")
177 for name in (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace) {
178 if ["docs-font-sans", "docs-font-serif", "docs-font-mono"].contains(String(name)) {
179 for key in ["font_sans", "font_serif", "font_mono"] { result.removeValue(forKey: key) }
180 result["font_" + name.dropFirst(10)] = .object([:])
181 }
182 if name.hasPrefix("docs-size-") {
183 let suffix = String(name.dropFirst(10))
184 if ["sm", "lg", "xl"].contains(suffix) || DocsRichClipboard.sizes.filter({ $0 != 12 }).map(String.init).contains(suffix) {
185 for key in result.keys where key.hasPrefix("size_") { result.removeValue(forKey: key) }
186 result["size_" + suffix] = .object([:])
187 }
188 }
189 }
190 if ["b", "strong"].contains(element.tag) || ["bold", "bolder", "600", "700", "800", "900"].contains(style["font-weight"] ?? "") { result["strong"] = .object([:]) }
191 if ["normal", "100", "200", "300", "400", "500"].contains(style["font-weight"] ?? "") { result.removeValue(forKey: "strong") }
192 if ["i", "em"].contains(element.tag) || style["font-style"] == "italic" { result["em"] = .object([:]) }
193 if style["font-style"] == "normal" { result.removeValue(forKey: "em") }
194 if element.tag == "code" { result["code"] = .object([:]) }
195 if element.tag == "a", let href = element.attrs["href"], DocsRichClipboard.safeLink(href) {
196 var link: [String: JSONValue] = ["href": .string(href)]
197 if let title = element.attrs["title"] { link["title"] = .string(title) }
198 result["link"] = .object(link)
199 }
200 if let family = style["font-family"] ?? element.attrs["face"] {
201 let family = family.lowercased()
202 for key in ["font_sans", "font_serif", "font_mono"] { result.removeValue(forKey: key) }
203 let key = family.contains("mono") || family.contains("courier") ? "font_mono" : family.contains("sans") ? "font_sans" : family.contains("serif") || family.contains("times") || family.contains("georgia") ? "font_serif" : "font_sans"
204 result[key] = .object([:])
205 }
206 if let raw = style["font-size"] {
207 let number = Double(raw.prefix { $0.isNumber || $0 == "." })
208 if let number, number > 0 {
209 let points = raw.hasSuffix("px") ? number * 0.75 : number
210 let size = DocsRichClipboard.sizes.min { abs(Double($0) - points) < abs(Double($1) - points) } ?? 12
211 for key in result.keys where key.hasPrefix("size_") { result.removeValue(forKey: key) }
212 if size != 12 { result["size_\(size)"] = .object([:]) }
213 }
214 }
215 return result
216 }
217 private static func decode(_ value: String) -> String {
218 guard let regex = try? NSRegularExpression(pattern: "&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);") else { return value }
219 let raw = value as NSString
220 var result = String()
221 result.reserveCapacity(value.utf8.count)
222 var position = 0
223 for match in regex.matches(in: value, range: NSRange(location: 0, length: raw.length)) {
224 result += raw.substring(with: NSRange(location: position, length: match.range.location - position))
225 let key = raw.substring(with: match.range(at: 1))
226 var replacement = entities[key]
227 if key.hasPrefix("#") {
228 let hexadecimal = key.hasPrefix("#x")
229 if let number = UInt32(key.dropFirst(hexadecimal ? 2 : 1), radix: hexadecimal ? 16 : 10), let scalar = Unicode.Scalar(number), number != 0 { replacement = String(scalar) }
230 }
231 result += replacement ?? raw.substring(with: match.range)
232 position = NSMaxRange(match.range)
233 }
234 result += raw.substring(from: position)
235 return result
236 }
237}
238