import Foundation /// A data-only HTML clipboard decoder. Tags, attributes and text become schema /// nodes; no DOM, script execution, stylesheet loading or network access exists. @MainActor enum DocsHTMLClipboard { // HTML5 named character references, generated from Python's standard-library // html.entities table. Kept as data so decoding never invokes an HTML view. private static let entities: [String: String] = { guard let url = DocsResources.bundle.url(forResource: "HTMLEntities", withExtension: "json"), let data = try? Data(contentsOf: url), let values = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } return values }() private final class Element { let tag: String let attrs: [String: String] var children: [Part] = [] init(_ tag: String, _ attrs: [String: String] = [:]) { self.tag = tag; self.attrs = attrs } } private enum Part { case text(String), element(Element) } private static let voidTags: Set = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"] private static let discarded: Set = ["script", "style", "head", "iframe", "object", "embed", "template", "noscript"] private static let blockTags: Set = ["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "ul", "ol", "li", "blockquote", "hr", "table", "tr", "td", "section", "article", "body", "html"] static func blocks(_ html: String) -> [JSONValue] { guard html.utf8.count <= 16 << 20, let root = parse(html) else { return [] } return convert(root.children, marks: [:]).map(\.json) } private static func parse(_ html: String) -> Element? { let root = Element("root") var stack = [root] var scanner = DocsHTMLScanner(html) while let piece = scanner.next() { guard case .tag(let token) = piece else { if case .text(let value) = piece { stack.last?.children.append(.text(decode(value))) } continue } if token.hasPrefix(" 0 { stack.removeSubrange(index...) } } else { // HTML permits implicit paragraph/list-item end tags. if ["p", "li"].contains(name), stack.last?.tag == name { stack.removeLast() } let node = Element(name, attributes(String(inside.dropFirst(name.count)))) stack.last?.children.append(.element(node)) if !voidTags.contains(name) && !token.hasSuffix("/>") { guard stack.count < 64 else { return nil } stack.append(node) } } } return root } private static func attributes(_ source: String) -> [String: String] { DocsHTMLScanner.attributes(source).mapValues(decode) } private static func convert(_ parts: [Part], marks: [String: JSONValue]) -> [DocsClipboardNode] { var result: [DocsClipboardNode] = [] var explicitParagraphs: Set = [] var implicitParagraphs: Set = [] var paragraph = DocsClipboardNode(type: "paragraph") func appendBlock(_ node: DocsClipboardNode) { let meaningful = node.children.filter { child in child.type != "text" || child.runs.contains { run in if case .string(let text)? = run["insert"] { return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return false } } if node.type == "paragraph", !meaningful.isEmpty, meaningful.allSatisfy({ $0.type == "hard_break" }) { for _ in meaningful { let blank = DocsClipboardNode(type: "paragraph") blank.append("", marks: [:]); explicitParagraphs.insert(ObjectIdentifier(blank)); result.append(blank) } } else { result.append(node) } } func flush() { if !paragraph.children.isEmpty { implicitParagraphs.insert(ObjectIdentifier(paragraph)); appendBlock(paragraph) } paragraph = DocsClipboardNode(type: "paragraph") } for part in parts { if case .element(let element) = part, blockTags.contains(element.tag) || containsBlocks(element) { flush() guard !discarded.contains(element.tag) else { continue } let nestedMarks = styledMarks(element, inherited: marks) switch element.tag { case "ul", "ol": let list = DocsClipboardNode(type: element.tag == "ol" ? "ordered_list" : "bullet_list") if element.tag == "ol", let start = element.attrs["start"], Int(start) != nil { list.attrs["order"] = start } for child in element.children { guard case .element(let item) = child, item.tag == "li" else { continue } let node = DocsClipboardNode(type: "list_item") node.children = convert(item.children, marks: styledMarks(item, inherited: nestedMarks)) if node.children.isEmpty { node.children = [DocsClipboardNode(type: "paragraph")] } list.children.append(node) } if !list.children.isEmpty { result.append(list) } case "blockquote": let node = DocsClipboardNode(type: "blockquote") node.children = convert(element.children, marks: nestedMarks) if !node.children.isEmpty { result.append(node) } case "hr": result.append(DocsClipboardNode(type: "horizontal_rule")) case "p", "pre", "h1", "h2", "h3", "h4", "h5", "h6": let heading = element.tag.hasPrefix("h") let node = DocsClipboardNode(type: heading ? "heading" : element.tag == "pre" ? "code_block" : "paragraph") if heading { node.attrs["level"] = String(element.tag.dropFirst()) } let style = styles(element.attrs["style"] ?? "") if let align = style["text-align"] ?? element.attrs["align"], ["left", "center", "right"].contains(align) { node.attrs["align"] = align } for name in (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace) { if name == "docs-align-center" { node.attrs["align"] = "center" } if name == "docs-align-right" { node.attrs["align"] = "right" } if name.hasPrefix("docs-indent-"), let level = Int(name.dropFirst(12)), (0...8).contains(level) { node.attrs["indent"] = String(level) } } for child in element.children { inline(child, into: node, marks: nestedMarks, pre: element.tag == "pre") } if node.children.isEmpty { node.append("", marks: nestedMarks) } explicitParagraphs.insert(ObjectIdentifier(node)) appendBlock(node) default: result += convert(element.children, marks: nestedMarks) } } else { inline(part, into: paragraph, marks: marks, pre: false) } } flush() return result.filter { node in if !implicitParagraphs.contains(ObjectIdentifier(node)) { return true } if explicitParagraphs.contains(ObjectIdentifier(node)) { return true } // Ignore indentation between HTML blocks, without stripping spaces // between inline runs inside actual paragraphs. if node.type != "paragraph" { return true } return node.children.contains { child in child.type != "text" || child.runs.contains { run in if case .string(let value)? = run["insert"] { return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return false }} } } private static func inline(_ part: Part, into node: DocsClipboardNode, marks: [String: JSONValue], pre: Bool) { switch part { case .text(let value): let text = pre ? value : value.replacingOccurrences(of: "[\\t\\r\\n ]+", with: " ", options: .regularExpression) if !text.isEmpty { node.append(text, marks: marks) } case .element(let element): guard !discarded.contains(element.tag) else { return } if element.tag == "br" { let classes = (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace) if !classes.contains("Apple-interchange-newline") && !classes.contains("ProseMirror-trailingBreak") { node.children.append(DocsClipboardNode(type: "hard_break")) } return } if element.tag == "img" { guard let src = element.attrs["src"], let scheme = URL(string: src)?.scheme?.lowercased(), ["http", "https"].contains(scheme) || (scheme == "data" && src.lowercased().hasPrefix("data:image/")) else { return } let image = DocsClipboardNode(type: "image") image.attrs = element.attrs.filter { ["src", "alt", "title"].contains($0.key) } node.children.append(image); return } let next = styledMarks(element, inherited: marks) for child in element.children { inline(child, into: node, marks: next, pre: pre) } } } private static func styles(_ value: String) -> [String: String] { var result: [String: String] = [:] for declaration in value.split(separator: ";") { let pair = declaration.split(separator: ":", maxSplits: 1) if pair.count == 2 { result[pair[0].trimmingCharacters(in: .whitespaces).lowercased()] = pair[1].trimmingCharacters(in: .whitespaces).lowercased() } } return result } private static func containsBlocks(_ element: Element) -> Bool { element.children.contains { part in if case .element(let child) = part { return blockTags.contains(child.tag) || containsBlocks(child) } return false } } private static func styledMarks(_ element: Element, inherited: [String: JSONValue]) -> [String: JSONValue] { var result = inherited let style = styles(element.attrs["style"] ?? "") for name in (element.attrs["class"] ?? "").split(whereSeparator: \.isWhitespace) { if ["docs-font-sans", "docs-font-serif", "docs-font-mono"].contains(String(name)) { for key in ["font_sans", "font_serif", "font_mono"] { result.removeValue(forKey: key) } result["font_" + name.dropFirst(10)] = .object([:]) } if name.hasPrefix("docs-size-") { let suffix = String(name.dropFirst(10)) if ["sm", "lg", "xl"].contains(suffix) || DocsRichClipboard.sizes.filter({ $0 != 12 }).map(String.init).contains(suffix) { for key in result.keys where key.hasPrefix("size_") { result.removeValue(forKey: key) } result["size_" + suffix] = .object([:]) } } } if ["b", "strong"].contains(element.tag) || ["bold", "bolder", "600", "700", "800", "900"].contains(style["font-weight"] ?? "") { result["strong"] = .object([:]) } if ["normal", "100", "200", "300", "400", "500"].contains(style["font-weight"] ?? "") { result.removeValue(forKey: "strong") } if ["i", "em"].contains(element.tag) || style["font-style"] == "italic" { result["em"] = .object([:]) } if style["font-style"] == "normal" { result.removeValue(forKey: "em") } if element.tag == "code" { result["code"] = .object([:]) } if element.tag == "a", let href = element.attrs["href"], DocsRichClipboard.safeLink(href) { var link: [String: JSONValue] = ["href": .string(href)] if let title = element.attrs["title"] { link["title"] = .string(title) } result["link"] = .object(link) } if let family = style["font-family"] ?? element.attrs["face"] { let family = family.lowercased() for key in ["font_sans", "font_serif", "font_mono"] { result.removeValue(forKey: key) } 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" result[key] = .object([:]) } if let raw = style["font-size"] { let number = Double(raw.prefix { $0.isNumber || $0 == "." }) if let number, number > 0 { let points = raw.hasSuffix("px") ? number * 0.75 : number let size = DocsRichClipboard.sizes.min { abs(Double($0) - points) < abs(Double($1) - points) } ?? 12 for key in result.keys where key.hasPrefix("size_") { result.removeValue(forKey: key) } if size != 12 { result["size_\(size)"] = .object([:]) } } } return result } private static func decode(_ value: String) -> String { guard let regex = try? NSRegularExpression(pattern: "&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);") else { return value } let raw = value as NSString var result = String() result.reserveCapacity(value.utf8.count) var position = 0 for match in regex.matches(in: value, range: NSRange(location: 0, length: raw.length)) { result += raw.substring(with: NSRange(location: position, length: match.range.location - position)) let key = raw.substring(with: match.range(at: 1)) var replacement = entities[key] if key.hasPrefix("#") { let hexadecimal = key.hasPrefix("#x") if let number = UInt32(key.dropFirst(hexadecimal ? 2 : 1), radix: hexadecimal ? 16 : 10), let scalar = Unicode.Scalar(number), number != 0 { replacement = String(scalar) } } result += replacement ?? raw.substring(with: match.range) position = NSMaxRange(match.range) } result += raw.substring(from: position) return result } }