import Foundation import UniformTypeIdentifiers #if os(macOS) import AppKit #else import UIKit #endif @MainActor extension DocsRichClipboard { private static let nativeType = "net.amadan.oficina.docs.fragment" static func readNative() -> [JSONValue]? { #if os(macOS) let data = NSPasteboard.general.data(forType: NSPasteboard.PasteboardType(nativeType)) #else let data = UIPasteboard.general.data(forPasteboardType: nativeType) #endif guard let data, data.count <= 16 << 20, let values = try? JSONDecoder().decode([JSONValue].self, from: data), !values.isEmpty else { return nil } // This is still untrusted input. The replica validates the same node // vocabulary and transaction limits as every other paste command. return values } @discardableResult static func write(_ source: NSAttributedString) -> Bool { guard source.length > 0 else { return false } let values = blocks(source) guard !values.isEmpty, let data = try? JSONEncoder().encode(values), data.count <= 16 << 20 else { return false } let html = html(values) let rtf = try? source.data(from: NSRange(location: 0, length: source.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]) #if os(macOS) let board = NSPasteboard.general board.clearContents() guard board.setData(data, forType: NSPasteboard.PasteboardType(nativeType)) else { return false } _ = board.setString(source.string, forType: .string) _ = board.setString(html, forType: .html) if let rtf { _ = board.setData(rtf, forType: .rtf) } #else var item: [String: Any] = [nativeType: data, UTType.utf8PlainText.identifier: source.string, UTType.html.identifier: Data(html.utf8)] if let rtf { item[UTType.rtf.identifier] = rtf } UIPasteboard.general.setItems([item]) #endif return true } static func html(_ values: [JSONValue]) -> String { values.map(htmlNode).joined() } private static func escape(_ value: String) -> String { value.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "\"", with: """) } private static func string(_ value: JSONValue?) -> String { if case .string(let result) = value { return result }; return "" } private static func htmlNode(_ node: JSONValue) -> String { guard case .object(let object) = node else { return "" } let type = string(object["type"]) let attrs: [String: JSONValue] if case .object(let value)? = object["attrs"] { attrs = value } else { attrs = [:] } if type == "text", case .array(let runs)? = object["runs"] { return runs.map(htmlRun).joined() } let children: [JSONValue] if case .array(let value)? = object["children"] { children = value } else { children = [] } let body = html(children) if type == "hard_break" { return "
" } if type == "horizontal_rule" { return "
" } if type == "image" { return "" } let tag: String switch type { case "heading": tag = "h" + String(max(1, min(6, Int(string(attrs["level"])) ?? 1))) case "bullet_list": tag = "ul" case "ordered_list": tag = "ol" case "list_item": tag = "li" case "blockquote": tag = "blockquote" case "code_block": tag = "pre" default: tag = "p" } var attributes = "" if type == "ordered_list", let order = Int(string(attrs["order"])) { attributes += " start=\"\(order)\"" } var classes: [String] = [] if ["center", "right"].contains(string(attrs["align"])) { classes.append("docs-align-" + string(attrs["align"])) } if let indent = Int(string(attrs["indent"])), indent > 0 { classes.append("docs-indent-\(min(8, indent))") } if !classes.isEmpty { attributes += " class=\"" + classes.joined(separator: " ") + "\"" } return "<\(tag)\(attributes)>\(body)" } private static func htmlRun(_ run: JSONValue) -> String { guard case .object(let values) = run else { return "" } var output = escape(string(values["insert"])) guard case .object(let marks)? = values["marks"] else { return output } for key in marks.keys.sorted() { if key == "strong" { output = "\(output)" } else if key == "em" { output = "\(output)" } else if key == "code" { output = "\(output)" } else if key == "link", case .object(let link)? = marks[key], safeLink(string(link["href"])) { let title = link["title"].map { " title=\"\(escape(string($0)))\"" } ?? "" output = "\(output)" } else if ["font_sans", "font_serif", "font_mono"].contains(key) { let family = key == "font_serif" ? "serif" : key == "font_mono" ? "monospace" : "sans-serif" output = "\(output)" } else if key.hasPrefix("size_") { let suffix = String(key.dropFirst(5)) let size = ["sm": 10, "lg": 14, "xl": 18][suffix] ?? Int(suffix) if let size, sizes.contains(size), size != 12 { output = "\(output)" } } } return output } }