| 1 | import Foundation |
| 2 | #if os(macOS) |
| 3 | import AppKit |
| 4 | private typealias PlatformFont = NSFont |
| 5 | #else |
| 6 | import UIKit |
| 7 | private typealias PlatformFont = UIFont |
| 8 | #endif |
| 9 | |
| 10 | extension NSAttributedString.Key { |
| 11 | static let docsNode = NSAttributedString.Key("OficinaDocsNode") |
| 12 | static let docsBlock = NSAttributedString.Key("OficinaDocsBlock") |
| 13 | static let docsOffset = NSAttributedString.Key("OficinaDocsOffset") |
| 14 | static let docsQuoteBorders = NSAttributedString.Key("OficinaDocsQuoteBorders") |
| 15 | static let docsMarks = NSAttributedString.Key("OficinaDocsMarks") |
| 16 | } |
| 17 | @MainActor enum NativeTextProjection { |
| 18 | static func render(_ projection: CRDTProjection, images: [String: DocsPlatformImage] = [:]) -> NSAttributedString { |
| 19 | let output = NSMutableAttributedString(string: "") |
| 20 | for node in projection.children { |
| 21 | let start = output.length |
| 22 | append(node, to: output, depth: 0, lists: [], images: images) |
| 23 | #if os(macOS) |
| 24 | let margins = DocumentBlockSpacing.margins(node) |
| 25 | output.addAttributes([.docsTopBlock: node.id, .docsMarginBefore: margins.0, .docsMarginAfter: margins.1], |
| 26 | range: NSRange(location: start, length: output.length - start)) |
| 27 | #endif |
| 28 | } |
| 29 | DocumentBlockSpacing.apply(projection.children, to: output) |
| 30 | DocumentInlineCode.apply(to: output) |
| 31 | return output |
| 32 | } |
| 33 | private static func append(_ node: CRDTNode, to output: NSMutableAttributedString, depth: Int, lists: [NSTextList], images: [String: DocsPlatformImage], containers: [DocsClipboardContainer] = []) { |
| 34 | if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) { |
| 35 | var nested = lists |
| 36 | if node.type == "bullet_list" { nested.append(NSTextList(markerFormat: .disc, options: 0)) } |
| 37 | if node.type == "ordered_list" { |
| 38 | let list = NSTextList(markerFormat: .decimal, options: 0) |
| 39 | if let order = Int(node.attrs?["order"] ?? ""), (Int(Int32.min)...Int(Int32.max)).contains(order) { list.startingItemNumber = order } |
| 40 | nested.append(list) |
| 41 | } |
| 42 | let ancestry = containers + [DocsClipboardContainer(id: node.id, type: node.type, attrs: node.attrs)] |
| 43 | for child in node.children ?? [] { append(child, to: output, depth: depth + (node.type == "list_item" || node.type == "blockquote" ? 1 : 0), lists: nested, images: images, containers: ancestry) } |
| 44 | return |
| 45 | } |
| 46 | let start = output.length |
| 47 | let style = NSMutableParagraphStyle() |
| 48 | style.paragraphSpacing = 0 |
| 49 | style.textLists = lists |
| 50 | let indent = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0)) |
| 51 | style.headIndent = containers.reduce(CGFloat(indent * 36)) { offset, container in |
| 52 | offset + (container.type == "list_item" ? 16.8 : container.type == "blockquote" ? 14.25 : 0) |
| 53 | } |
| 54 | style.firstLineHeadIndent = style.headIndent |
| 55 | switch node.attrs?["align"] { case "center": style.alignment = .center; case "right": style.alignment = .right; default: style.alignment = .left } |
| 56 | let heading = node.type == "heading" ? max(1, min(6, Int(node.attrs?["level"] ?? "1") ?? 1)) : 0 |
| 57 | #if os(macOS) |
| 58 | style.headerLevel = heading |
| 59 | #endif |
| 60 | let base: CGFloat = heading == 1 ? 19.2 : heading == 2 ? 15.6 : heading == 3 ? 13.2 : 12 |
| 61 | let leading: CGFloat = heading > 0 ? 1.3 : 1.4 |
| 62 | var inheritedMarks: [String: JSONValue] = [:] |
| 63 | if heading >= 4 || containers.contains(where: { $0.type == "blockquote" }) { inheritedMarks["em"] = .object([:]) } |
| 64 | if node.type == "code_block" { inheritedMarks["code"] = .object([:]) } |
| 65 | let baseFont = font(marks: inheritedMarks, size: base, heading: heading > 0) |
| 66 | #if os(macOS) |
| 67 | let naturalHeight = NSLayoutManager().defaultLineHeight(for: baseFont) |
| 68 | #else |
| 69 | let naturalHeight = baseFont.lineHeight |
| 70 | #endif |
| 71 | style.lineHeightMultiple = base * leading / naturalHeight |
| 72 | style.minimumLineHeight = base * leading |
| 73 | if heading > 0 { |
| 74 | style.paragraphSpacingBefore = base * 1.5 |
| 75 | style.paragraphSpacing = base * 0.5 |
| 76 | } |
| 77 | var baseAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .font: baseFont, .docsBlock: node.id, .docsBlockType: node.type, .docsHeading: heading] |
| 78 | #if os(macOS) |
| 79 | baseAttributes[.docsBaseFont] = baseFont |
| 80 | baseAttributes[.docsLeading] = leading |
| 81 | #endif |
| 82 | var quoteBorders: [CGFloat] = [] |
| 83 | var containerIndent: CGFloat = 0 |
| 84 | for container in containers { |
| 85 | if container.type == "blockquote" { quoteBorders.append(containerIndent); containerIndent += 14.25 } |
| 86 | if container.type == "list_item" { containerIndent += 16.8 } |
| 87 | } |
| 88 | if !quoteBorders.isEmpty { |
| 89 | baseAttributes[.docsQuoteBorders] = quoteBorders |
| 90 | #if os(macOS) |
| 91 | baseAttributes[.foregroundColor] = NSColor(white: 74.0 / 255, alpha: 1) |
| 92 | #else |
| 93 | baseAttributes[.foregroundColor] = UIColor(white: 74.0 / 255, alpha: 1) |
| 94 | #endif |
| 95 | } |
| 96 | baseAttributes[.docsBlockAttributes] = try? JSONEncoder().encode(node.attrs ?? [:]) |
| 97 | baseAttributes[.docsClipboardContainers] = try? JSONEncoder().encode(containers) |
| 98 | if node.type == "horizontal_rule" { |
| 99 | let rule = NSMutableAttributedString(attributedString: DocsAttachments.rule(width: 468)) |
| 100 | rule.addAttributes(baseAttributes, range: NSRange(location: 0, length: rule.length)) |
| 101 | output.append(rule); output.append(NSAttributedString(string: "\n", attributes: baseAttributes)); return |
| 102 | } |
| 103 | for child in node.children ?? [] { |
| 104 | if child.type == "text" { |
| 105 | var offset = 0 |
| 106 | for run in child.runs ?? [] { |
| 107 | var attributes = baseAttributes |
| 108 | let marks = run.marks ?? [:] |
| 109 | var visualMarks = inheritedMarks.merging(marks, uniquingKeysWith: { _, authored in authored }) |
| 110 | if node.type == "code_block" { visualMarks["code"] = .object([:]) } |
| 111 | attributes[.font] = font(marks: visualMarks, size: base, heading: heading > 0) |
| 112 | attributes[.docsNode] = child.id |
| 113 | attributes[.docsOffset] = offset |
| 114 | attributes[.docsMarks] = try? JSONEncoder().encode(marks) |
| 115 | if case .object(let link)? = marks["link"], case .string(let href)? = link["href"], let url = URL(string: href), ["https", "http", "mailto"].contains(url.scheme ?? "") { attributes[.link] = url } |
| 116 | output.append(NSAttributedString(string: run.insert, attributes: attributes)) |
| 117 | offset += run.insert.utf16.count |
| 118 | } |
| 119 | } else if child.type == "hard_break" { output.append(NSAttributedString(string: "\u{2028}", attributes: baseAttributes)) } |
| 120 | else if child.type == "image" { let image = NSMutableAttributedString(attributedString: DocsAttachments.image(images[child.attrs?["src"] ?? ""], alt: child.attrs?["alt"] ?? "", maxWidth: 468)) |
| 121 | image.addAttributes(baseAttributes.merging([.docsNode: child.id], uniquingKeysWith: { _, new in new }), range: NSRange(location: 0, length: image.length)) |
| 122 | if let attrs = try? JSONEncoder().encode(child.attrs ?? [:]) { image.addAttribute(.docsImageAttributes, value: attrs, range: NSRange(location: 0, length: image.length)) } |
| 123 | output.append(image) } |
| 124 | else { append(child, to: output, depth: depth, lists: lists, images: images, containers: containers) } |
| 125 | } |
| 126 | var ending = baseAttributes |
| 127 | if output.length == start { ending[.docsNode] = node.id; ending[.docsOffset] = 0 } |
| 128 | output.append(NSAttributedString(string: "\n", attributes: ending)) |
| 129 | } |
| 130 | private static func font(marks: [String: JSONValue], size: CGFloat, heading: Bool) -> PlatformFont { |
| 131 | DocsFonts.font(marks: marks, size: size, heading: heading) |
| 132 | } |
| 133 | } |
| 134 | |