import Foundation import UniformTypeIdentifiers #if os(macOS) import AppKit #else import UIKit #endif /// Clipboard import uses platform RTF decoding and a bounded, non-rendering HTML /// parser. It never opens a web view or fetches images, fonts, or remote styles. @MainActor enum DocsRichClipboard { static let sizes = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 72] static func read() -> [JSONValue]? { if let native = readNative() { return native } #if os(macOS) let board = NSPasteboard.general let rtf = board.data(forType: .rtf) let html = board.string(forType: .html) #else let board = UIPasteboard.general let rtf = board.data(forPasteboardType: UTType.rtf.identifier) let html = board.data(forPasteboardType: UTType.html.identifier).flatMap { String(data: $0, encoding: .utf8) } #endif // HTML retains semantic headings and list structure that a platform's // RTF reader may expose only as visual paragraph formatting. if let html, html.utf8.count <= 16 << 20 { let result = DocsHTMLClipboard.blocks(html) if !result.isEmpty { return result } } if let rtf, rtf.count <= 16 << 20, let value = try? NSAttributedString(data: rtf, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) { let result = blocks(value) if !result.isEmpty { return result } } return nil } static func blocks(_ source: NSAttributedString) -> [JSONValue] { guard source.length > 0 else { return [] } var output: [DocsClipboardNode] = [] var lists: [DocsClipboardNode] = [] var listTypes: [String] = [] var ancestry: [(DocsClipboardContainer, DocsClipboardNode)] = [] let raw = source.string as NSString var start = 0 while start < source.length { var end = start while end < source.length && raw.character(at: end) != 10 && raw.character(at: end) != 13 { end += 1 } let attributes = source.attributes(at: start, effectiveRange: nil) let semanticType = attributes[.docsBlockType] as? String if semanticType == "code_block", attributes[.docsBlock] != nil { var blockRange = NSRange() _ = source.attribute(.docsBlock, at: start, longestEffectiveRange: &blockRange, in: NSRange(location: start, length: source.length - start)) end = NSMaxRange(blockRange) if end > start && raw.character(at: end - 1) == 10 { end -= 1 } } let style = attributes[.paragraphStyle] as? NSParagraphStyle #if os(macOS) let level = style?.headerLevel ?? 0 #else let level = attributes[.docsHeading] as? Int ?? 0 #endif let block = DocsClipboardNode(type: semanticType ?? (level > 0 ? "heading" : "paragraph")) if let data = attributes[.docsBlockAttributes] as? Data, let attrs = try? JSONDecoder().decode([String: String].self, from: data) { block.attrs = attrs } if level > 0 { block.attrs["level"] = String(min(6, level)) } switch style?.alignment { case .center: block.attrs["align"] = "center" case .right: block.attrs["align"] = "right" default: break } if semanticType == nil, style?.textLists.isEmpty != false, let indent = style?.headIndent, indent >= 36 { block.attrs["indent"] = String(min(8, Int(indent / 36))) } source.enumerateAttributes(in: NSRange(location: start, length: end - start)) { attributes, range, _ in if block.type == "horizontal_rule" { return } if let image = attachment(attributes) { block.children.append(image); return } let text = raw.substring(with: range) let pieces = text.components(separatedBy: "\u{2028}") for (index, piece) in pieces.enumerated() { if index > 0 { block.children.append(DocsClipboardNode(type: "hard_break")) } if !piece.isEmpty { block.append(piece, marks: marks(attributes)) } } } if block.children.isEmpty && block.type != "horizontal_rule" { block.append("", marks: [:]) } if let data = attributes[.docsClipboardContainers] as? Data, let path = try? JSONDecoder().decode([DocsClipboardContainer].self, from: data) { var common = 0 while common < min(path.count, ancestry.count), path[common].id == ancestry[common].0.id { common += 1 } ancestry = Array(ancestry.prefix(common)) for container in path.dropFirst(common) { let node = DocsClipboardNode(type: container.type); node.attrs = container.attrs ?? [:] if let parent = ancestry.last?.1 { parent.children.append(node) } else { output.append(node) } ancestry.append((container, node)) } if let parent = ancestry.last?.1 { parent.children.append(block) } else { output.append(block) } start = end + 1 continue } let current = (style?.textLists ?? []).map { $0.markerFormat == .decimal ? "ordered_list" : "bullet_list" } var common = 0 while common < min(current.count, listTypes.count), current[common] == listTypes[common] { common += 1 } lists = Array(lists.prefix(common)); listTypes = Array(listTypes.prefix(common)) for type in current.dropFirst(common) { let list = DocsClipboardNode(type: type) if let parent = lists.last { if parent.children.isEmpty { parent.children.append(DocsClipboardNode(type: "list_item")) } parent.children[parent.children.count - 1].children.append(list) } else { output.append(list) } lists.append(list); listTypes.append(type) } if let list = lists.last { let item = DocsClipboardNode(type: "list_item"); item.children = [block]; list.children.append(item) } else { output.append(block) } if end < source.length && raw.character(at: end) == 13 && end + 1 < source.length && raw.character(at: end + 1) == 10 { end += 1 } start = end + 1 } return output.map(\.json) } private static func attachment(_ attributes: [NSAttributedString.Key: Any]) -> DocsClipboardNode? { if let data = attributes[.docsImageAttributes] as? Data, let attrs = try? JSONDecoder().decode([String: String].self, from: data) { let image = DocsClipboardNode(type: "image"); image.attrs = attrs; return image } guard let attachment = attributes[.attachment] as? NSTextAttachment else { return nil } let wrapper = attachment.fileWrapper var data = wrapper?.isRegularFile == true ? wrapper?.regularFileContents : attachment.contents var mime = "image/png" if let source = data, source.starts(with: [0xff, 0xd8]) { mime = "image/jpeg" } else if let source = data, source.starts(with: Array("GIF8".utf8)) { mime = "image/gif" } else { #if os(macOS) if let tiff = attachment.image?.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) { data = bitmap.representation(using: .png, properties: [:]) } #else data = attachment.image?.pngData() ?? data #endif } guard let data, !data.isEmpty, data.count <= 8 << 20 else { return nil } let image = DocsClipboardNode(type: "image") image.attrs = ["src": "data:\(mime);base64," + data.base64EncodedString(), "alt": ""] return image } static func marks(_ attributes: [NSAttributedString.Key: Any]) -> [String: JSONValue] { var result: [String: JSONValue] = [:] if let data = attributes[.docsMarks] as? Data, let original = try? JSONDecoder().decode([String: JSONValue].self, from: data) { result = original } if let font = attributes[.font] as? DocsPlatformFont { #if os(macOS) let traits = font.fontDescriptor.symbolicTraits if traits.contains(.bold) { result["strong"] = .object([:]) } if traits.contains(.italic) { result["em"] = .object([:]) } let mono = traits.contains(.monoSpace) let family = (font.familyName ?? font.fontName).lowercased() #else let traits = font.fontDescriptor.symbolicTraits if traits.contains(.traitBold) { result["strong"] = .object([:]) } if traits.contains(.traitItalic) { result["em"] = .object([:]) } let mono = traits.contains(.traitMonoSpace) let family = font.familyName.lowercased() #endif if mono || family.contains("mono") || family.contains("courier") { result["font_mono"] = .object([:]) } else if family.contains("serif") || family.contains("times") || family.contains("georgia") { result["font_serif"] = .object([:]) } else { result["font_sans"] = .object([:]) } let size = sizes.min { abs(CGFloat($0) - font.pointSize) < abs(CGFloat($1) - font.pointSize) } ?? 12 if size != 12 { result["size_\(size)"] = .object([:]) } } let href = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String if let href, safeLink(href) { var link: [String: JSONValue] = ["href": .string(href)] if case .object(let original)? = result["link"], original["href"] == .string(href), let title = original["title"] { link["title"] = title } result["link"] = .object(link) } return result } static func safeLink(_ value: String) -> Bool { guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else { return false } return ["https", "http", "mailto"].contains(scheme) } } extension NSAttributedString.Key { static let docsHeading = NSAttributedString.Key("OficinaDocsHeading") static let docsBlockType = NSAttributedString.Key("OficinaDocsBlockType") static let docsBlockAttributes = NSAttributedString.Key("OficinaDocsBlockAttributes") static let docsClipboardContainers = NSAttributedString.Key("OficinaDocsClipboardContainers") static let docsImageAttributes = NSAttributedString.Key("OficinaDocsImageAttributes") } struct DocsClipboardContainer: Codable { let id: String let type: String let attrs: [String: String]? } final class DocsClipboardNode { let type: String var attrs: [String: String] = [:] var children: [DocsClipboardNode] = [] var runs: [[String: JSONValue]] = [] init(type: String) { self.type = type } func append(_ value: String, marks: [String: JSONValue]) { let text: DocsClipboardNode if let last = children.last, last.type == "text" { text = last } else { text = DocsClipboardNode(type: "text"); children.append(text) } text.runs.append(["insert": .string(value), "marks": .object(marks)]) } var json: JSONValue { var value: [String: JSONValue] = ["type": .string(type)] if !attrs.isEmpty { value["attrs"] = .object(attrs.mapValues(JSONValue.string)) } if type == "text" { value["runs"] = .array(runs.map(JSONValue.object)) } else { value["children"] = .array(children.map(\.json)) } return .object(value) } }