import Foundation #if os(macOS) import AppKit private typealias PlatformFont = NSFont #else import UIKit private typealias PlatformFont = UIFont #endif extension NSAttributedString.Key { static let docsNode = NSAttributedString.Key("OficinaDocsNode") static let docsBlock = NSAttributedString.Key("OficinaDocsBlock") static let docsOffset = NSAttributedString.Key("OficinaDocsOffset") static let docsQuoteBorders = NSAttributedString.Key("OficinaDocsQuoteBorders") static let docsMarks = NSAttributedString.Key("OficinaDocsMarks") } @MainActor enum NativeTextProjection { static func render(_ projection: CRDTProjection, images: [String: DocsPlatformImage] = [:]) -> NSAttributedString { let output = NSMutableAttributedString(string: "") for node in projection.children { let start = output.length append(node, to: output, depth: 0, lists: [], images: images) #if os(macOS) let margins = DocumentBlockSpacing.margins(node) output.addAttributes([.docsTopBlock: node.id, .docsMarginBefore: margins.0, .docsMarginAfter: margins.1], range: NSRange(location: start, length: output.length - start)) #endif } DocumentBlockSpacing.apply(projection.children, to: output) DocumentInlineCode.apply(to: output) return output } private static func append(_ node: CRDTNode, to output: NSMutableAttributedString, depth: Int, lists: [NSTextList], images: [String: DocsPlatformImage], containers: [DocsClipboardContainer] = []) { if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) { var nested = lists if node.type == "bullet_list" { nested.append(NSTextList(markerFormat: .disc, options: 0)) } if node.type == "ordered_list" { let list = NSTextList(markerFormat: .decimal, options: 0) if let order = Int(node.attrs?["order"] ?? ""), (Int(Int32.min)...Int(Int32.max)).contains(order) { list.startingItemNumber = order } nested.append(list) } let ancestry = containers + [DocsClipboardContainer(id: node.id, type: node.type, attrs: node.attrs)] 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) } return } let start = output.length let style = NSMutableParagraphStyle() style.paragraphSpacing = 0 style.textLists = lists let indent = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0)) style.headIndent = containers.reduce(CGFloat(indent * 36)) { offset, container in offset + (container.type == "list_item" ? 16.8 : container.type == "blockquote" ? 14.25 : 0) } style.firstLineHeadIndent = style.headIndent switch node.attrs?["align"] { case "center": style.alignment = .center; case "right": style.alignment = .right; default: style.alignment = .left } let heading = node.type == "heading" ? max(1, min(6, Int(node.attrs?["level"] ?? "1") ?? 1)) : 0 #if os(macOS) style.headerLevel = heading #endif let base: CGFloat = heading == 1 ? 19.2 : heading == 2 ? 15.6 : heading == 3 ? 13.2 : 12 let leading: CGFloat = heading > 0 ? 1.3 : 1.4 var inheritedMarks: [String: JSONValue] = [:] if heading >= 4 || containers.contains(where: { $0.type == "blockquote" }) { inheritedMarks["em"] = .object([:]) } if node.type == "code_block" { inheritedMarks["code"] = .object([:]) } let baseFont = font(marks: inheritedMarks, size: base, heading: heading > 0) #if os(macOS) let naturalHeight = NSLayoutManager().defaultLineHeight(for: baseFont) #else let naturalHeight = baseFont.lineHeight #endif style.lineHeightMultiple = base * leading / naturalHeight style.minimumLineHeight = base * leading if heading > 0 { style.paragraphSpacingBefore = base * 1.5 style.paragraphSpacing = base * 0.5 } var baseAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .font: baseFont, .docsBlock: node.id, .docsBlockType: node.type, .docsHeading: heading] #if os(macOS) baseAttributes[.docsBaseFont] = baseFont baseAttributes[.docsLeading] = leading #endif var quoteBorders: [CGFloat] = [] var containerIndent: CGFloat = 0 for container in containers { if container.type == "blockquote" { quoteBorders.append(containerIndent); containerIndent += 14.25 } if container.type == "list_item" { containerIndent += 16.8 } } if !quoteBorders.isEmpty { baseAttributes[.docsQuoteBorders] = quoteBorders #if os(macOS) baseAttributes[.foregroundColor] = NSColor(white: 74.0 / 255, alpha: 1) #else baseAttributes[.foregroundColor] = UIColor(white: 74.0 / 255, alpha: 1) #endif } baseAttributes[.docsBlockAttributes] = try? JSONEncoder().encode(node.attrs ?? [:]) baseAttributes[.docsClipboardContainers] = try? JSONEncoder().encode(containers) if node.type == "horizontal_rule" { let rule = NSMutableAttributedString(attributedString: DocsAttachments.rule(width: 468)) rule.addAttributes(baseAttributes, range: NSRange(location: 0, length: rule.length)) output.append(rule); output.append(NSAttributedString(string: "\n", attributes: baseAttributes)); return } for child in node.children ?? [] { if child.type == "text" { var offset = 0 for run in child.runs ?? [] { var attributes = baseAttributes let marks = run.marks ?? [:] var visualMarks = inheritedMarks.merging(marks, uniquingKeysWith: { _, authored in authored }) if node.type == "code_block" { visualMarks["code"] = .object([:]) } attributes[.font] = font(marks: visualMarks, size: base, heading: heading > 0) attributes[.docsNode] = child.id attributes[.docsOffset] = offset attributes[.docsMarks] = try? JSONEncoder().encode(marks) 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 } output.append(NSAttributedString(string: run.insert, attributes: attributes)) offset += run.insert.utf16.count } } else if child.type == "hard_break" { output.append(NSAttributedString(string: "\u{2028}", attributes: baseAttributes)) } else if child.type == "image" { let image = NSMutableAttributedString(attributedString: DocsAttachments.image(images[child.attrs?["src"] ?? ""], alt: child.attrs?["alt"] ?? "", maxWidth: 468)) image.addAttributes(baseAttributes.merging([.docsNode: child.id], uniquingKeysWith: { _, new in new }), range: NSRange(location: 0, length: image.length)) if let attrs = try? JSONEncoder().encode(child.attrs ?? [:]) { image.addAttribute(.docsImageAttributes, value: attrs, range: NSRange(location: 0, length: image.length)) } output.append(image) } else { append(child, to: output, depth: depth, lists: lists, images: images, containers: containers) } } var ending = baseAttributes if output.length == start { ending[.docsNode] = node.id; ending[.docsOffset] = 0 } output.append(NSAttributedString(string: "\n", attributes: ending)) } private static func font(marks: [String: JSONValue], size: CGFloat, heading: Bool) -> PlatformFont { DocsFonts.font(marks: marks, size: size, heading: heading) } }