oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import UniformTypeIdentifiers
3#if os(macOS)
4import AppKit
5#else
6import 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
179extension 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
187struct DocsClipboardContainer: Codable {
188 let id: String
189 let type: String
190 let attrs: [String: String]?
191}
192
193final 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