| 1 | import Foundation |
| 2 | import UniformTypeIdentifiers |
| 3 | #if os(macOS) |
| 4 | import AppKit |
| 5 | #else |
| 6 | import UIKit |
| 7 | #endif |
| 8 | |
| 9 | /// Clipboard import uses platform RTF decoding and a bounded, non-rendering HTML |
| 10 | /// parser. It never opens a web view or fetches images, fonts, or remote styles. |
| 11 | @MainActor enum DocsRichClipboard { |
| 12 | static let sizes = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 72] |
| 13 | static func read() -> [JSONValue]? { |
| 14 | if let native = readNative() { return native } |
| 15 | #if os(macOS) |
| 16 | let board = NSPasteboard.general |
| 17 | let rtf = board.data(forType: .rtf) |
| 18 | let html = board.string(forType: .html) |
| 19 | #else |
| 20 | let board = UIPasteboard.general |
| 21 | let rtf = board.data(forPasteboardType: UTType.rtf.identifier) |
| 22 | let html = board.data(forPasteboardType: UTType.html.identifier).flatMap { String(data: $0, encoding: .utf8) } |
| 23 | #endif |
| 24 | // HTML retains semantic headings and list structure that a platform's |
| 25 | // RTF reader may expose only as visual paragraph formatting. |
| 26 | if let html, html.utf8.count <= 16 << 20 { |
| 27 | let result = DocsHTMLClipboard.blocks(html) |
| 28 | if !result.isEmpty { return result } |
| 29 | } |
| 30 | if let rtf, rtf.count <= 16 << 20, |
| 31 | let value = try? NSAttributedString(data: rtf, options: [.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil) { |
| 32 | let result = blocks(value) |
| 33 | if !result.isEmpty { return result } |
| 34 | } |
| 35 | return nil |
| 36 | } |
| 37 | static func blocks(_ source: NSAttributedString) -> [JSONValue] { |
| 38 | guard source.length > 0 else { return [] } |
| 39 | var output: [DocsClipboardNode] = [] |
| 40 | var lists: [DocsClipboardNode] = [] |
| 41 | var listTypes: [String] = [] |
| 42 | var ancestry: [(DocsClipboardContainer, DocsClipboardNode)] = [] |
| 43 | let raw = source.string as NSString |
| 44 | var start = 0 |
| 45 | while start < source.length { |
| 46 | var end = start |
| 47 | while end < source.length && raw.character(at: end) != 10 && raw.character(at: end) != 13 { end += 1 } |
| 48 | let attributes = source.attributes(at: start, effectiveRange: nil) |
| 49 | let semanticType = attributes[.docsBlockType] as? String |
| 50 | if semanticType == "code_block", attributes[.docsBlock] != nil { |
| 51 | var blockRange = NSRange() |
| 52 | _ = source.attribute(.docsBlock, at: start, longestEffectiveRange: &blockRange, in: NSRange(location: start, length: source.length - start)) |
| 53 | end = NSMaxRange(blockRange) |
| 54 | if end > start && raw.character(at: end - 1) == 10 { end -= 1 } |
| 55 | } |
| 56 | let style = attributes[.paragraphStyle] as? NSParagraphStyle |
| 57 | #if os(macOS) |
| 58 | let level = style?.headerLevel ?? 0 |
| 59 | #else |
| 60 | let level = attributes[.docsHeading] as? Int ?? 0 |
| 61 | #endif |
| 62 | let block = DocsClipboardNode(type: semanticType ?? (level > 0 ? "heading" : "paragraph")) |
| 63 | if let data = attributes[.docsBlockAttributes] as? Data, let attrs = try? JSONDecoder().decode([String: String].self, from: data) { block.attrs = attrs } |
| 64 | if level > 0 { block.attrs["level"] = String(min(6, level)) } |
| 65 | switch style?.alignment { |
| 66 | case .center: block.attrs["align"] = "center" |
| 67 | case .right: block.attrs["align"] = "right" |
| 68 | default: break |
| 69 | } |
| 70 | if semanticType == nil, style?.textLists.isEmpty != false, let indent = style?.headIndent, indent >= 36 { |
| 71 | block.attrs["indent"] = String(min(8, Int(indent / 36))) |
| 72 | } |
| 73 | source.enumerateAttributes(in: NSRange(location: start, length: end - start)) { attributes, range, _ in |
| 74 | if block.type == "horizontal_rule" { return } |
| 75 | if let image = attachment(attributes) { block.children.append(image); return } |
| 76 | let text = raw.substring(with: range) |
| 77 | let pieces = text.components(separatedBy: "\u{2028}") |
| 78 | for (index, piece) in pieces.enumerated() { |
| 79 | if index > 0 { block.children.append(DocsClipboardNode(type: "hard_break")) } |
| 80 | if !piece.isEmpty { block.append(piece, marks: marks(attributes)) } |
| 81 | } |
| 82 | } |
| 83 | if block.children.isEmpty && block.type != "horizontal_rule" { block.append("", marks: [:]) } |
| 84 | if let data = attributes[.docsClipboardContainers] as? Data, |
| 85 | let path = try? JSONDecoder().decode([DocsClipboardContainer].self, from: data) { |
| 86 | var common = 0 |
| 87 | while common < min(path.count, ancestry.count), path[common].id == ancestry[common].0.id { common += 1 } |
| 88 | ancestry = Array(ancestry.prefix(common)) |
| 89 | for container in path.dropFirst(common) { |
| 90 | let node = DocsClipboardNode(type: container.type); node.attrs = container.attrs ?? [:] |
| 91 | if let parent = ancestry.last?.1 { parent.children.append(node) } else { output.append(node) } |
| 92 | ancestry.append((container, node)) |
| 93 | } |
| 94 | if let parent = ancestry.last?.1 { parent.children.append(block) } else { output.append(block) } |
| 95 | start = end + 1 |
| 96 | continue |
| 97 | } |
| 98 | let current = (style?.textLists ?? []).map { $0.markerFormat == .decimal ? "ordered_list" : "bullet_list" } |
| 99 | var common = 0 |
| 100 | while common < min(current.count, listTypes.count), current[common] == listTypes[common] { common += 1 } |
| 101 | lists = Array(lists.prefix(common)); listTypes = Array(listTypes.prefix(common)) |
| 102 | for type in current.dropFirst(common) { |
| 103 | let list = DocsClipboardNode(type: type) |
| 104 | if let parent = lists.last { |
| 105 | if parent.children.isEmpty { parent.children.append(DocsClipboardNode(type: "list_item")) } |
| 106 | parent.children[parent.children.count - 1].children.append(list) |
| 107 | } else { output.append(list) } |
| 108 | lists.append(list); listTypes.append(type) |
| 109 | } |
| 110 | if let list = lists.last { |
| 111 | let item = DocsClipboardNode(type: "list_item"); item.children = [block]; list.children.append(item) |
| 112 | } else { output.append(block) } |
| 113 | if end < source.length && raw.character(at: end) == 13 && end + 1 < source.length && raw.character(at: end + 1) == 10 { end += 1 } |
| 114 | start = end + 1 |
| 115 | } |
| 116 | return output.map(\.json) |
| 117 | } |
| 118 | private static func attachment(_ attributes: [NSAttributedString.Key: Any]) -> DocsClipboardNode? { |
| 119 | if let data = attributes[.docsImageAttributes] as? Data, |
| 120 | let attrs = try? JSONDecoder().decode([String: String].self, from: data) { |
| 121 | let image = DocsClipboardNode(type: "image"); image.attrs = attrs; return image |
| 122 | } |
| 123 | guard let attachment = attributes[.attachment] as? NSTextAttachment else { return nil } |
| 124 | let wrapper = attachment.fileWrapper |
| 125 | var data = wrapper?.isRegularFile == true ? wrapper?.regularFileContents : attachment.contents |
| 126 | var mime = "image/png" |
| 127 | if let source = data, source.starts(with: [0xff, 0xd8]) { mime = "image/jpeg" } |
| 128 | else if let source = data, source.starts(with: Array("GIF8".utf8)) { mime = "image/gif" } |
| 129 | else { |
| 130 | #if os(macOS) |
| 131 | if let tiff = attachment.image?.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) { data = bitmap.representation(using: .png, properties: [:]) } |
| 132 | #else |
| 133 | data = attachment.image?.pngData() ?? data |
| 134 | #endif |
| 135 | } |
| 136 | guard let data, !data.isEmpty, data.count <= 8 << 20 else { return nil } |
| 137 | let image = DocsClipboardNode(type: "image") |
| 138 | image.attrs = ["src": "data:\(mime);base64," + data.base64EncodedString(), "alt": ""] |
| 139 | return image |
| 140 | } |
| 141 | static func marks(_ attributes: [NSAttributedString.Key: Any]) -> [String: JSONValue] { |
| 142 | var result: [String: JSONValue] = [:] |
| 143 | if let data = attributes[.docsMarks] as? Data, |
| 144 | let original = try? JSONDecoder().decode([String: JSONValue].self, from: data) { result = original } |
| 145 | if let font = attributes[.font] as? DocsPlatformFont { |
| 146 | #if os(macOS) |
| 147 | let traits = font.fontDescriptor.symbolicTraits |
| 148 | if traits.contains(.bold) { result["strong"] = .object([:]) } |
| 149 | if traits.contains(.italic) { result["em"] = .object([:]) } |
| 150 | let mono = traits.contains(.monoSpace) |
| 151 | let family = (font.familyName ?? font.fontName).lowercased() |
| 152 | #else |
| 153 | let traits = font.fontDescriptor.symbolicTraits |
| 154 | if traits.contains(.traitBold) { result["strong"] = .object([:]) } |
| 155 | if traits.contains(.traitItalic) { result["em"] = .object([:]) } |
| 156 | let mono = traits.contains(.traitMonoSpace) |
| 157 | let family = font.familyName.lowercased() |
| 158 | #endif |
| 159 | if mono || family.contains("mono") || family.contains("courier") { result["font_mono"] = .object([:]) } |
| 160 | else if family.contains("serif") || family.contains("times") || family.contains("georgia") { result["font_serif"] = .object([:]) } |
| 161 | else { result["font_sans"] = .object([:]) } |
| 162 | let size = sizes.min { abs(CGFloat($0) - font.pointSize) < abs(CGFloat($1) - font.pointSize) } ?? 12 |
| 163 | if size != 12 { result["size_\(size)"] = .object([:]) } |
| 164 | } |
| 165 | let href = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String |
| 166 | if let href, safeLink(href) { |
| 167 | var link: [String: JSONValue] = ["href": .string(href)] |
| 168 | if case .object(let original)? = result["link"], original["href"] == .string(href), let title = original["title"] { link["title"] = title } |
| 169 | result["link"] = .object(link) |
| 170 | } |
| 171 | return result |
| 172 | } |
| 173 | static func safeLink(_ value: String) -> Bool { |
| 174 | guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else { return false } |
| 175 | return ["https", "http", "mailto"].contains(scheme) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | extension NSAttributedString.Key { |
| 180 | static let docsHeading = NSAttributedString.Key("OficinaDocsHeading") |
| 181 | static let docsBlockType = NSAttributedString.Key("OficinaDocsBlockType") |
| 182 | static let docsBlockAttributes = NSAttributedString.Key("OficinaDocsBlockAttributes") |
| 183 | static let docsClipboardContainers = NSAttributedString.Key("OficinaDocsClipboardContainers") |
| 184 | static let docsImageAttributes = NSAttributedString.Key("OficinaDocsImageAttributes") |
| 185 | } |
| 186 | |
| 187 | struct DocsClipboardContainer: Codable { |
| 188 | let id: String |
| 189 | let type: String |
| 190 | let attrs: [String: String]? |
| 191 | } |
| 192 | |
| 193 | final class DocsClipboardNode { |
| 194 | let type: String |
| 195 | var attrs: [String: String] = [:] |
| 196 | var children: [DocsClipboardNode] = [] |
| 197 | var runs: [[String: JSONValue]] = [] |
| 198 | init(type: String) { self.type = type } |
| 199 | func append(_ value: String, marks: [String: JSONValue]) { |
| 200 | let text: DocsClipboardNode |
| 201 | if let last = children.last, last.type == "text" { text = last } |
| 202 | else { text = DocsClipboardNode(type: "text"); children.append(text) } |
| 203 | text.runs.append(["insert": .string(value), "marks": .object(marks)]) |
| 204 | } |
| 205 | var json: JSONValue { |
| 206 | var value: [String: JSONValue] = ["type": .string(type)] |
| 207 | if !attrs.isEmpty { value["attrs"] = .object(attrs.mapValues(JSONValue.string)) } |
| 208 | if type == "text" { value["runs"] = .array(runs.map(JSONValue.object)) } |
| 209 | else { value["children"] = .array(children.map(\.json)) } |
| 210 | return .object(value) |
| 211 | } |
| 212 | } |
| 213 | |