| 1 | import 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 | |