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
}
}