oficina / docs Public

Match Mac rich-text layout and block pagination to web fixtures

Paul Campbell pushed by paul@keymail.dev bed1bacd0a63aa780c85dad60f8362b30321d31b parent 5debafd
11 files changed, +555 −25
  • docs/superpowers/specs/2026-09-20-mac-document-windows.md +22 −6
  • native/apple/Sources/DocsKit/DocsFonts.swift +1 −0
  • native/apple/Sources/DocsKit/DocsLayoutManager.swift +71 −0
  • native/apple/Sources/DocsKit/DocsPagedTextView.swift +33 −13
  • native/apple/Sources/DocsKit/DocsPaging.swift +21 −1
  • native/apple/Sources/DocsKit/DocumentBlockSpacing.swift +54 −0
  • native/apple/Sources/DocsKit/DocumentInlineCode.swift +38 −0
  • native/apple/Sources/DocsKit/DocumentPageLayout.swift +51 −0
  • native/apple/Sources/DocsKit/NativeTextProjection.swift +39 −5
  • native/apple/Tests/DocsKitTests/BrowserTypographyTests.swift +179 −0
  • native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift +46 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-20-mac-document-windows.md b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
index 4de6ef9..abefde8 100644
--- a/docs/superpowers/specs/2026-09-20-mac-document-windows.md
+++ b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
@@ -58,12 +58,28 @@ updates and fixed logical page width. Deliberately restoring full storage replac
and viewport-dependent width made the corresponding tests fail. A synthetic WebKit
fixture matches native heading, bold and body line breaks and body line spacing.
-This verifies the measured typography cases, not universal browser/TextKit parity.
-Complex lists, inline code, mixed styles, margin collapsing and multi-page pagination
-need a larger shared layout fixture suite before exact parity can be claimed. The
-existing native line-based page breaking and web block-based pagination differ.
-That remaining work is triggered by the requested exact cross-platform typesetting
-acceptance check; it is not considered complete by this branch.
+The follow-up comparison suite loads the production web CSS, bundled fonts and
+pages.mjs pagination functions directly into WebKit. It covers nested lists and
+quotes, heading margins, serif/monospace fonts, inline code including a code span
+starting on a wrapped line, mixed 72pt text, whole-block pagination and an oversized
+first paragraph. Native Mac pagination now uses the same 648pt top-level-block
+budget, an 18pt separator and collapsed surrounding margins. It does not insert
+characters or change CRDT data. Font struts and per-run half-leading determine
+line height; nested margins are collapsed before TextKit layout.
+
+Storage edits invalidate separator offsets immediately, including optimistic edits
+and marked-text composition. A regression test inserts, composes and deletes before
+an existing separator. Unchanged view layouts reuse the measured pagination plan.
+Deliberately removing code-start padding and storage invalidation made their tests
+fail for the intended reasons; the restored implementations pass.
+
+Follow-up review: GPT-6 Astra (high), 2026-09-20; no remaining blocker in the bounded
+source review. Docs validation: 39 XCTest cases plus 15 Swift Testing cases pass,
+as does the iOS simulator build. The web comparison allows one CSS pixel for
+per-line metric rounding and 1.5pt accumulated position error across a long fixture.
+These are measured layout results, not a claim of pixel-identical rasterization or
+proof for every possible document. iOS retains its existing responsive paginator;
+the document-window pagination change targets Mac as requested.
Builds are local ad-hoc Mac applications, not signed distribution or TestFlight
releases. No server deployment, merge or publication was performed.
diff --git a/native/apple/Sources/DocsKit/DocsFonts.swift b/native/apple/Sources/DocsKit/DocsFonts.swift
index a8328d4..d73fbe8 100644
--- a/native/apple/Sources/DocsKit/DocsFonts.swift
+++ b/native/apple/Sources/DocsKit/DocsFonts.swift
@@ -28,6 +28,7 @@ typealias DocsPlatformFont = UIFont
else if key == "size_lg" { size = 14 }
else if key == "size_xl" { size = 18 }
}
+ if marks["code"] != nil { size *= 0.85 }
let family = marks["font_mono"] != nil || marks["code"] != nil ? "Mono" : marks["font_serif"] != nil ? "Serif" : "Sans"
let bold = marks["strong"] != nil || heading
let italic = marks["em"] != nil
diff --git a/native/apple/Sources/DocsKit/DocsLayoutManager.swift b/native/apple/Sources/DocsKit/DocsLayoutManager.swift
new file mode 100644
index 0000000..699d4b3
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsLayoutManager.swift
@@ -0,0 +1,71 @@
+#if os(macOS)
+import AppKit
+
+extension NSAttributedString.Key {
+ static let docsBaseFont = NSAttributedString.Key("OficinaDocsBaseFont")
+ static let docsLeading = NSAttributedString.Key("OficinaDocsLeading")
+}
+
+/// CSS line boxes include the paragraph's font strut and each inline run's
+/// half-leading. TextKit's single paragraph multiplier cannot express that when
+/// a line mixes font families or sizes.
+@MainActor final class DocsLayoutManager: NSLayoutManager, @preconcurrency NSLayoutManagerDelegate {
+ private(set) var storageRevision: UInt64 = 0
+ override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions,
+ range newCharRange: NSRange, changeInLength delta: Int, invalidatedRange invalidatedCharRange: NSRange) {
+ super.processEditing(for: textStorage, edited: editMask, range: newCharRange,
+ changeInLength: delta, invalidatedRange: invalidatedCharRange)
+ storageRevision &+= 1
+ for container in textContainers {
+ (container as? DocsPageTextContainer)?.blockStarts = [:]
+ (container.textView as? DocsPagedTextView)?.invalidatePagination()
+ }
+ }
+ override init() { super.init(); usesFontLeading = false; delegate = self }
+ required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
+ override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
+ super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
+ guard let storage = textStorage, let container = textContainers.first as? DocsPageTextContainer else { return }
+ let characters = characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
+ storage.enumerateAttribute(.docsQuoteBorders, in: characters) { value, range, _ in
+ guard let borders = value as? [CGFloat] else { return }
+ let glyphs = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
+ var bounds = self.boundingRect(forGlyphRange: glyphs, in: container)
+ bounds.origin.y += origin.y
+ NSColor(white: 200.0 / 255, alpha: 1).setFill()
+ for inset in borders {
+ CGRect(x: origin.x + container.geometry.textX + inset, y: bounds.minY, width: 2.25, height: bounds.height).fill()
+ }
+ }
+ }
+ func layoutManager(_ layoutManager: NSLayoutManager, shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer<NSRect>,
+ lineFragmentUsedRect: UnsafeMutablePointer<NSRect>, baselineOffset: UnsafeMutablePointer<CGFloat>,
+ in textContainer: NSTextContainer, forGlyphRange glyphRange: NSRange) -> Bool {
+ guard let storage = textStorage, glyphRange.length > 0 else { return false }
+ let range = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
+ guard range.location < storage.length,
+ let base = storage.attribute(.docsBaseFont, at: range.location, effectiveRange: nil) as? NSFont,
+ let leading = storage.attribute(.docsLeading, at: range.location, effectiveRange: nil) as? CGFloat else { return false }
+ var above: CGFloat = 0, below: CGFloat = 0
+ func include(_ font: NSFont) {
+ let halfLeading = (font.pointSize * leading - font.ascender + font.descender) / 2
+ above = max(above, font.ascender + halfLeading)
+ below = max(below, -font.descender + halfLeading)
+ }
+ include(base)
+ var attachment = false
+ storage.enumerateAttributes(in: range) { attributes, _, _ in
+ if let font = attributes[.font] as? NSFont { include(font) }
+ if attributes[.attachment] != nil { attachment = true }
+ }
+ guard !attachment else { return false }
+ let end = NSMaxRange(range)
+ let endsParagraph = end > 0 && (storage.string as NSString).character(at: end - 1) == 10
+ let style = storage.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle
+ lineFragmentRect.pointee.size.height = above + below + (endsParagraph ? style?.paragraphSpacing ?? 0 : 0)
+ lineFragmentUsedRect.pointee.size.height = above + below
+ baselineOffset.pointee = above
+ return true
+ }
+}
+#endif
diff --git a/native/apple/Sources/DocsKit/DocsPagedTextView.swift b/native/apple/Sources/DocsKit/DocsPagedTextView.swift
index 79e49ff..a539668 100644
--- a/native/apple/Sources/DocsKit/DocsPagedTextView.swift
+++ b/native/apple/Sources/DocsKit/DocsPagedTextView.swift
@@ -5,9 +5,18 @@ import AppKit
@MainActor class DocsPagedTextView: NSTextView {
var paged = false { didSet { if oldValue != paged { needsLayout = true; needsDisplay = true } } }
private var updatingGeometry = false
+ private var separators: [CGFloat] = []
+ private var measuredRevision: UInt64?
+ private var measuredWidth: CGFloat = 0
+ private var measuredPaged = false
+ func invalidatePagination() {
+ measuredRevision = nil
+ if !separators.isEmpty { separators = []; needsDisplay = true }
+ needsLayout = true
+ }
override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) {
let storage = NSTextStorage()
- let manager = NSLayoutManager()
+ let manager = DocsLayoutManager()
manager.usesFontLeading = false
let pages = DocsPageTextContainer(size: CGSize(width: max(320, frameRect.width), height: 1_000_000))
pages.lineFragmentPadding = 0
@@ -31,9 +40,22 @@ import AppKit
let width = max(636, enclosingScrollView?.contentView.bounds.width ?? bounds.width)
let size = CGSize(width: width, height: 1_000_000)
if pages.size != size { pages.size = size }
- pages.geometry = DocsPageGeometry(viewportWidth: width, paged: paged)
- manager.ensureLayout(for: pages)
- let height = pages.geometry.documentHeight(usedHeight: manager.usedRect(for: pages).maxY)
+ pages.geometry = DocsPageGeometry(viewportWidth: width, paged: false)
+ let revision = (manager as? DocsLayoutManager)?.storageRevision
+ if measuredRevision != revision || measuredWidth != width || measuredPaged != paged {
+ pages.blockStarts = [:]
+ manager.ensureLayout(for: pages)
+ if paged, let storage = textStorage {
+ let result = DocumentPageLayout.measure(storage: storage, manager: manager, container: pages)
+ pages.blockStarts = result.starts
+ if separators != result.separators { separators = result.separators; needsDisplay = true }
+ manager.ensureLayout(for: pages)
+ } else if !separators.isEmpty { separators = []; needsDisplay = true }
+ measuredRevision = revision; measuredWidth = width; measuredPaged = paged
+ }
+ let bottom = manager.numberOfGlyphs > 0
+ ? manager.lineFragmentRect(forGlyphAt: manager.numberOfGlyphs - 1, effectiveRange: nil).maxY : DocsPageGeometry.verticalMargin
+ let height = pages.geometry.documentHeight(usedHeight: bottom)
if abs(frame.width - width) > 0.5 || abs(frame.height - height) > 0.5 {
setFrameSize(CGSize(width: width, height: height))
}
@@ -43,19 +65,17 @@ import AppKit
dirtyRect.fill()
if let pages = textContainer as? DocsPageTextContainer {
let geometry = pages.geometry
+ NSColor.white.setFill()
+ CGRect(x: geometry.paperX, y: 0, width: geometry.paperWidth, height: bounds.height).fill()
if paged {
- for page in geometry.page(at: dirtyRect.minY)...geometry.page(at: dirtyRect.maxY) {
- NSColor.white.setFill()
- geometry.pageRect(page).fill()
+ for (index, y) in separators.enumerated() where y + 18 >= dirtyRect.minY && y <= dirtyRect.maxY {
+ NSColor.windowBackgroundColor.setFill()
+ CGRect(x: geometry.paperX, y: y, width: geometry.paperWidth, height: 18).fill()
let label = String(localized: "docs.editor.page.number", bundle: DocsResources.bundle)
- .replacingOccurrences(of: "{n}", with: String(page + 1))
- (label as NSString).draw(at: CGPoint(x: geometry.paperX + geometry.paperWidth - 80,
- y: CGFloat(page) * geometry.stride + DocsPageGeometry.paperHeight - 35),
+ .replacingOccurrences(of: "{n}", with: String(index + 2))
+ (label as NSString).draw(at: CGPoint(x: geometry.paperX + geometry.paperWidth - 80, y: y + 3),
withAttributes: [.font: NSFont.systemFont(ofSize: 10), .foregroundColor: NSColor.darkGray])
}
- } else {
- NSColor.white.setFill()
- CGRect(x: geometry.paperX, y: 0, width: geometry.paperWidth, height: bounds.height).fill()
}
}
super.draw(dirtyRect)
diff --git a/native/apple/Sources/DocsKit/DocsPaging.swift b/native/apple/Sources/DocsKit/DocsPaging.swift
index c04ef52..8717aef 100644
--- a/native/apple/Sources/DocsKit/DocsPaging.swift
+++ b/native/apple/Sources/DocsKit/DocsPaging.swift
@@ -56,6 +56,11 @@ struct DocsPageGeometry: Equatable, Sendable {
}
final class DocsPageTextContainer: NSTextContainer {
+ #if os(macOS)
+ var blockStarts: [Int: CGFloat] = [:] {
+ didSet { if oldValue != blockStarts { layoutManager?.textContainerChangedGeometry(self) } }
+ }
+ #endif
var geometry = DocsPageGeometry(viewportWidth: 612, paged: false) {
didSet {
if oldValue != geometry { layoutManager?.textContainerChangedGeometry(self) }
@@ -68,6 +73,21 @@ final class DocsPageTextContainer: NSTextContainer {
remaining remainingRect: UnsafeMutablePointer<CGRect>?
) -> CGRect {
remainingRect?.pointee = .zero
- return geometry.lineRect(proposedRect)
+ var rect = geometry.lineRect(proposedRect)
+ #if os(macOS)
+ if let top = blockStarts[characterIndex] { rect.origin.y = max(rect.minY, top) }
+ #endif
+ if let storage = layoutManager?.textStorage, characterIndex < storage.length {
+ if characterIndex == 0, let margin = storage.attribute(.docsFirstMargin, at: 0, effectiveRange: nil) as? CGFloat {
+ rect.origin.y = max(rect.minY, DocsPageGeometry.verticalMargin + margin)
+ }
+ var range = NSRange()
+ if let padding = storage.attribute(.docsCodeStart, at: characterIndex, effectiveRange: &range) as? CGFloat,
+ range.location == characterIndex {
+ rect.origin.x += padding
+ rect.size.width = max(0, rect.width - padding)
+ }
+ }
+ return rect
}
}
diff --git a/native/apple/Sources/DocsKit/DocumentBlockSpacing.swift b/native/apple/Sources/DocsKit/DocumentBlockSpacing.swift
new file mode 100644
index 0000000..819b99c
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocumentBlockSpacing.swift
@@ -0,0 +1,54 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+extension NSAttributedString.Key { static let docsFirstMargin = NSAttributedString.Key("OficinaDocsFirstMargin") }
+
+/// Collapse CSS block margins before handing paragraphs to TextKit, whose
+/// paragraph spacing otherwise adds adjacent margins together.
+@MainActor enum DocumentBlockSpacing {
+ private struct Block { var id: String; var before: CGFloat; var after: CGFloat }
+ static func apply(_ nodes: [CRDTNode], to output: NSMutableAttributedString) {
+ let blocks = nodes.enumerated().flatMap { index, node in
+ flatten(node, clearBefore: index == 0, clearAfter: index == nodes.count - 1)
+ }
+ guard let first = blocks.first, output.length > 0 else { return }
+ output.addAttribute(.docsFirstMargin, value: first.before, range: NSRange(location: 0, length: 1))
+ var ranges: [String: NSRange] = [:]
+ output.enumerateAttribute(.docsBlock, in: NSRange(location: 0, length: output.length)) { value, range, _ in
+ guard let id = value as? String else { return }
+ ranges[id] = ranges[id].map { NSUnionRange($0, range) } ?? range
+ }
+ for (index, block) in blocks.enumerated() {
+ guard let range = ranges[block.id], let existing = output.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle,
+ let style = existing.mutableCopy() as? NSMutableParagraphStyle else { continue }
+ style.paragraphSpacingBefore = 0
+ style.paragraphSpacing = index + 1 < blocks.count ? max(block.after, blocks[index + 1].before) : block.after
+ output.addAttribute(.paragraphStyle, value: style, range: range)
+ }
+ }
+ static func margins(_ node: CRDTNode) -> (CGFloat, CGFloat) {
+ let blocks = flatten(node)
+ return (blocks.first?.before ?? 0, blocks.last?.after ?? 0)
+ }
+ private static func flatten(_ node: CRDTNode, clearBefore: Bool = false, clearAfter: Bool = false) -> [Block] {
+ if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) {
+ let children = node.children ?? []
+ var blocks = children.enumerated().flatMap { index, child in
+ flatten(child, clearAfter: node.type == "list_item" && index == children.count - 1)
+ }
+ guard !blocks.isEmpty else { return [] }
+ if node.type == "blockquote" {
+ blocks[0].before = max(clearBefore ? 0 : 12, blocks[0].before)
+ blocks[blocks.count - 1].after = max(clearAfter ? 0 : 12, blocks[blocks.count - 1].after)
+ }
+ return blocks
+ }
+ let level = node.type == "heading" ? max(1, min(6, Int(node.attrs?["level"] ?? "1") ?? 1)) : 0
+ let size: CGFloat = level == 1 ? 19.2 : level == 2 ? 15.6 : level == 3 ? 13.2 : 12
+ return [Block(id: node.id, before: level > 0 && !clearBefore ? size * 1.5 : 0, after: level > 0 && !clearAfter ? size * 0.5 : 0)]
+ }
+}
diff --git a/native/apple/Sources/DocsKit/DocumentInlineCode.swift b/native/apple/Sources/DocsKit/DocumentInlineCode.swift
new file mode 100644
index 0000000..aa9e128
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocumentInlineCode.swift
@@ -0,0 +1,38 @@
+import Foundation
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+
+extension NSAttributedString.Key { static let docsCodeStart = NSAttributedString.Key("OficinaDocsCodeStart") }
+
+/// Inline decoration uses glyph advances, never extra characters: CRDT and
+/// clipboard offsets continue to refer to the authored text.
+@MainActor enum DocumentInlineCode {
+ static func apply(to output: NSMutableAttributedString) {
+ var spans: [NSRange] = []
+ output.enumerateAttribute(.docsMarks, in: NSRange(location: 0, length: output.length)) { value, range, _ in
+ guard let data = value as? Data,
+ let marks = try? JSONDecoder().decode([String: JSONValue].self, from: data), marks["code"] != nil else { return }
+ if let last = spans.last, NSMaxRange(last) == range.location { spans[spans.count - 1] = NSUnionRange(last, range) }
+ else { spans.append(range) }
+ }
+ for range in spans where range.length > 0 {
+ guard let font = output.attribute(.font, at: range.location, effectiveRange: nil) as? DocsPlatformFont else { continue }
+ let padding = font.pointSize * 0.35
+ let paragraph = (output.string as NSString).paragraphRange(for: NSRange(location: range.location, length: 0))
+ output.addAttribute(.docsCodeStart, value: padding,
+ range: (output.string as NSString).rangeOfComposedCharacterSequence(at: range.location))
+ if paragraph.location != range.location && range.location > 0 {
+ output.addAttribute(.kern, value: padding, range: (output.string as NSString).rangeOfComposedCharacterSequence(at: range.location - 1))
+ }
+ output.addAttribute(.kern, value: padding, range: (output.string as NSString).rangeOfComposedCharacterSequence(at: NSMaxRange(range) - 1))
+ #if os(macOS)
+ output.addAttribute(.backgroundColor, value: NSColor(white: 240.0 / 255, alpha: 1), range: range)
+ #else
+ output.addAttribute(.backgroundColor, value: UIColor(white: 240.0 / 255, alpha: 1), range: range)
+ #endif
+ }
+ }
+}
diff --git a/native/apple/Sources/DocsKit/DocumentPageLayout.swift b/native/apple/Sources/DocsKit/DocumentPageLayout.swift
new file mode 100644
index 0000000..afc90b1
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocumentPageLayout.swift
@@ -0,0 +1,51 @@
+#if os(macOS)
+import AppKit
+
+extension NSAttributedString.Key {
+ static let docsTopBlock = NSAttributedString.Key("OficinaDocsTopBlock")
+ static let docsMarginBefore = NSAttributedString.Key("OficinaDocsMarginBefore")
+ static let docsMarginAfter = NSAttributedString.Key("OficinaDocsMarginAfter")
+}
+
+/// Mirrors pages.mjs: keep top-level blocks intact and put a visual separator
+/// before the next block exceeding the 648pt text budget. All offsets are layout
+/// only; an oversized paragraph/list is never split or rewritten to make a page.
+@MainActor enum DocumentPageLayout {
+ struct Result {
+ var starts: [Int: CGFloat] = [:]
+ var separators: [CGFloat] = []
+ }
+ static func measure(storage: NSTextStorage, manager: NSLayoutManager, container: NSTextContainer) -> Result {
+ var result = Result()
+ var pageTop: CGFloat?
+ var added: CGFloat = 0
+ var previousMargin: CGFloat = 0
+ storage.enumerateAttribute(.docsTopBlock, in: NSRange(location: 0, length: storage.length)) { value, range, _ in
+ guard value != nil else { return }
+ let glyphs = manager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
+ var top = CGFloat.greatestFiniteMagnitude
+ var bottom: CGFloat = 0
+ manager.enumerateLineFragments(forGlyphRange: glyphs) { rect, used, _, _, _ in
+ top = min(top, rect.minY); bottom = max(bottom, used.maxY)
+ }
+ guard bottom > top else { return }
+ let before = storage.attribute(.docsMarginBefore, at: range.location, effectiveRange: nil) as? CGFloat ?? 0
+ let after = storage.attribute(.docsMarginAfter, at: range.location, effectiveRange: nil) as? CGFloat ?? 0
+ defer { previousMargin = after }
+ if let currentTop = pageTop {
+ if bottom - currentTop > 648 {
+ // Collapse margins independently on either side of the gap.
+ let collapsed = max(previousMargin, before)
+ let separator = top + added - collapsed + max(previousMargin, 12)
+ let extra = max(previousMargin, 12) + 18 + max(before, 12) - collapsed
+ added += extra
+ result.starts[range.location] = top + added
+ result.separators.append(separator)
+ pageTop = top
+ }
+ } else { pageTop = top }
+ }
+ return result
+ }
+}
+#endif
diff --git a/native/apple/Sources/DocsKit/NativeTextProjection.swift b/native/apple/Sources/DocsKit/NativeTextProjection.swift
index 98a5878..5f13ed9 100644
--- a/native/apple/Sources/DocsKit/NativeTextProjection.swift
+++ b/native/apple/Sources/DocsKit/NativeTextProjection.swift
@@ -11,12 +11,23 @@ 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 { append(node, to: output, depth: 0, lists: [], images: images) }
+ 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] = []) {
@@ -37,7 +48,9 @@ extension NSAttributedString.Key {
style.paragraphSpacing = 0
style.textLists = lists
let indent = max(0, min(8, Int(node.attrs?["indent"] ?? "0") ?? 0))
- style.headIndent = CGFloat(depth * 24 + indent * 36)
+ 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
@@ -46,7 +59,10 @@ extension NSAttributedString.Key {
#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
- let baseFont = font(marks: [:], size: base, heading: heading > 0)
+ 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
@@ -58,7 +74,25 @@ extension NSAttributedString.Key {
style.paragraphSpacingBefore = base * 1.5
style.paragraphSpacing = base * 0.5
}
- var baseAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .font: font(marks: node.type == "code_block" ? ["code": .object([:])] : [:], size: base, heading: heading > 0), .docsBlock: node.id, .docsBlockType: node.type, .docsHeading: heading]
+ 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" {
@@ -72,7 +106,7 @@ extension NSAttributedString.Key {
for run in child.runs ?? [] {
var attributes = baseAttributes
let marks = run.marks ?? [:]
- var visualMarks = 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
diff --git a/native/apple/Tests/DocsKitTests/BrowserTypographyTests.swift b/native/apple/Tests/DocsKitTests/BrowserTypographyTests.swift
new file mode 100644
index 0000000..33f0e99
--- /dev/null
+++ b/native/apple/Tests/DocsKitTests/BrowserTypographyTests.swift
@@ -0,0 +1,179 @@
+#if os(macOS)
+import AppKit
+import WebKit
+import XCTest
+@testable import DocsKit
+
+/// Measures the production stylesheet, not a second copy of its typography rules.
+@MainActor final class BrowserTypographyTests: XCTestCase {
+ func testRichTextLineBreaksMatchBrowser() async throws {
+ let prose = "A shared document keeps the same words on each line across the browser and native editor. This deliberately long sentence exercises the available measure and the selected font metrics."
+ let cases: [(String, CRDTNode, String)] = [
+ ("heading4", block("h4", prose, type: "heading", attrs: ["level": "4"]), "<h4>\(prose)</h4>"),
+ ("list", CRDTNode(id: "list", type: "bullet_list", children: [CRDTNode(id: "item", type: "list_item", children: [block("li", prose)])]), "<ul><li><p>\(prose)</p></li></ul>"),
+ ("nested-list", CRDTNode(id: "outer", type: "bullet_list", children: [CRDTNode(id: "outerItem", type: "list_item", children: [CRDTNode(id: "inner", type: "ordered_list", children: [CRDTNode(id: "innerItem", type: "list_item", children: [block("nested", prose)])])])]), "<ul><li><ol><li><p>\(prose)</p></li></ol></li></ul>"),
+ ("quote", CRDTNode(id: "quote", type: "blockquote", children: [block("qp", prose)]), "<blockquote><p>\(prose)</p></blockquote>"),
+ ("serif", block("serif", prose, marks: ["font_serif": .object([:])]), "<p><span class='docs-font-serif'>\(prose)</span></p>"),
+ ("mono", block("mono", prose, marks: ["font_mono": .object([:])]), "<p><span class='docs-font-mono'>\(prose)</span></p>"),
+ ("large", block("large", prose, marks: ["size_24": .object([:])]), "<p><span class='docs-size-24'>\(prose)</span></p>"),
+ ("mixed-size", CRDTNode(id: "mixedSize", type: "paragraph", children: [CRDTNode(id: "mixedSizet", type: "text", runs: [CRDTRun(insert: "Large", marks: ["size_72": .object([:])]), CRDTRun(insert: " " + prose + " " + prose)])]), "<p><span class='docs-size-72'>Large</span> \(prose) \(prose)</p>"),
+ ("mixed-code-wrap", CRDTNode(id: "wrap", type: "paragraph", children: [CRDTNode(id: "wrapt", type: "text", runs: [CRDTRun(insert: String(repeating: "Wide words ", count: 7)), CRDTRun(insert: "WRAPPEDCODE " + prose, marks: ["code": .object([:])])])]), "<p>\(String(repeating: "Wide words ", count: 7))<code>WRAPPEDCODE \(prose)</code></p>"),
+ ("mixed-code", CRDTNode(id: "mixed", type: "paragraph", children: [CRDTNode(id: "mixedt", type: "text", runs: [CRDTRun(insert: "Before 🧪 "), CRDTRun(insert: prose, marks: ["code": .object([:])]), CRDTRun(insert: " after.")])]), "<p>Before 🧪 <code>\(prose)</code> after.</p>"),
+ ("code", block("code", prose, marks: ["code": .object([:])]), "<p><code>\(prose)</code></p>"),
+ ]
+ for (name, node, html) in cases {
+ let browser = try await browserLines(html)
+ let native = nativeLines([node])
+ XCTAssertEqual(native.map(\.text), browser.map(\.text), name)
+ if name == "mixed-size", native.count > 2, browser.count > 2 {
+ XCTAssertEqual(native.last!.y - native[native.count - 2].y, browser.last!.y - browser[browser.count - 2].y, accuracy: 0.76)
+ }
+ for (lhs, rhs) in zip(native, browser) {
+ XCTAssertEqual(lhs.x, rhs.x, accuracy: 0.76, "\(name): text origin")
+ if !name.hasPrefix("mixed") { XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76, "\(name): line spacing") }
+ }
+ }
+ }
+
+ func testAdjacentHeadingMarginsCollapse() async throws {
+ let text = "A heading with a predictable measure"
+ let nodes = (0..<4).map { block("h\($0)", text, type: "heading", attrs: ["level": "2"]) }
+ let browser = try await browserLines(String(repeating: "<h2>\(text)</h2>", count: 4))
+ let native = nativeLines(nodes)
+ XCTAssertEqual(native.count, browser.count)
+ for (lhs, rhs) in zip(native, browser) {
+ XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76)
+ }
+ }
+
+ func testNestedBlockSpacingMatchesBrowser() async throws {
+ let text = "A repeated heading inside a quote"
+ let nodes = (0..<5).map { index in CRDTNode(id: "quote\(index)", type: "blockquote", children: [block("nestedHeading\(index)", text, type: "heading", attrs: ["level": "2"])]) }
+ let html = String(repeating: "<blockquote><h2>\(text)</h2></blockquote>", count: 5)
+ let browser = try await browserLines(html, includeBounds: true)
+ let native = nativeLines(nodes, includeBounds: true)
+ XCTAssertEqual(native.map(\.text), browser.map(\.text))
+ for (lhs, rhs) in zip(native, browser) {
+ if lhs.text == "[bottom]" { XCTAssertEqual(lhs.y, rhs.y, accuracy: 0.76) }
+ else { XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76) }
+ }
+ }
+
+ func testPageBreaksMatchBrowserAndPreserveWholeBlocks() async throws {
+ let prose = "A page keeps this whole paragraph together. "
+ let nodes = (0..<18).map { block("p\($0)", String(repeating: prose, count: $0 == 0 ? 160 : 8)) }
+ let html = nodes.map { "<p>\($0.plainText)</p>" }.joined()
+ let browser = try await browserLines(html, paged: true)
+ let native = nativeLines(nodes, paged: true)
+ XCTAssertEqual(native.map(\.text), browser.map(\.text))
+ XCTAssertGreaterThan(native.filter { $0.text == "[page]" }.count, 1)
+ for (lhs, rhs) in zip(native, browser) {
+ if lhs.text == "[page]" {
+ XCTAssertEqual(lhs.y - 72, rhs.y, accuracy: 1.5, "page separator")
+ } else {
+ XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 1.5, lhs.text)
+ }
+ }
+ }
+
+ private func block(_ id: String, _ text: String, type: String = "paragraph", attrs: [String: String]? = nil, marks: [String: JSONValue]? = nil) -> CRDTNode {
+ CRDTNode(id: id, type: type, attrs: attrs, children: [CRDTNode(id: id + "t", type: "text", runs: [CRDTRun(insert: text, marks: marks)])])
+ }
+
+ struct Line: Decodable { var text: String; var x: Double; var y: Double }
+ private func nativeLines(_ nodes: [CRDTNode], paged: Bool = false, includeBounds: Bool = false) -> [Line] {
+ let storage = NSTextStorage(attributedString: NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false)))
+ let manager = DocsLayoutManager()
+ let container = DocsPageTextContainer(size: CGSize(width: 636, height: 100000))
+ container.geometry = DocsPageGeometry(viewportWidth: 636, paged: false)
+ container.lineFragmentPadding = 0
+ storage.addLayoutManager(manager); manager.addTextContainer(container)
+ manager.ensureLayout(for: container)
+ let pages = paged ? DocumentPageLayout.measure(storage: storage, manager: manager, container: container) : DocumentPageLayout.Result()
+ container.blockStarts = pages.starts
+ manager.ensureLayout(for: container)
+ var lines: [Line] = []
+ var contentBottom: CGFloat = 72
+ manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: manager.numberOfGlyphs)) { rect, _, _, range, _ in
+ contentBottom = max(contentBottom, rect.maxY)
+ let chars = manager.characterRange(forGlyphRange: range, actualGlyphRange: nil)
+ let text = (storage.string as NSString).substring(with: chars).trimmingCharacters(in: .whitespacesAndNewlines)
+ let location = manager.location(forGlyphAt: range.location)
+ lines.append(Line(text: text, x: rect.minX + location.x - container.geometry.textX, y: rect.minY))
+ }
+ lines += pages.separators.map { Line(text: "[page]", x: 0, y: $0) }
+ if includeBounds { lines.append(Line(text: "[bottom]", x: 0, y: contentBottom - 72)) }
+ return lines
+ }
+
+ private func browserLines(_ html: String, paged: Bool = false, includeBounds: Bool = false) async throws -> [Line] {
+ let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ let source = try String(contentsOf: root.appendingPathComponent("internal/docs/views/show.html"), encoding: .utf8)
+ let start = try XCTUnwrap(source.range(of: "<style>"))
+ let end = try XCTUnwrap(source.range(of: "</style>", range: start.upperBound..<source.endIndex))
+ var css = String(source[start.upperBound..<end.lowerBound])
+ css = css.replacingOccurrences(of: #"\{\{range \.FontFaces\}\}[\s\S]*?\{\{end\}\}"#, with: "", options: .regularExpression)
+ for family in ["Sans", "Serif", "Mono"] {
+ for face in ["Regular", "Bold", "Italic", "BoldItalic"] {
+ let font = try XCTUnwrap(DocsResources.bundle.url(forResource: "Liberation\(family)-\(face)", withExtension: "ttf"))
+ let data = try Data(contentsOf: font).base64EncodedString()
+ css += "@font-face{font-family:'Docs \(family)';src:url(data:font/ttf;base64,\(data));font-weight:\(face.contains("Bold") ? 700 : 400);font-style:\(face.contains("Italic") ? "italic" : "normal");}"
+ }
+ }
+ let pageSource = try String(contentsOf: root.appendingPathComponent("fixtures/pages.mjs"), encoding: .utf8)
+ let measureStart = try XCTUnwrap(pageSource.range(of: "function breakPositions(view)"))
+ let measureEnd = try XCTUnwrap(pageSource.range(of: "// pagesPlugin draws"))
+ let gapStart = try XCTUnwrap(pageSource.range(of: "function gapElement(number, pageLabel)"))
+ let pageScript = "const PAGE_CONTENT_PX=864;" + pageSource[measureStart.lowerBound..<measureEnd.lowerBound] + pageSource[gapStart.lowerBound...]
+ let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 1000, height: 1200))
+ let navigation = Navigation()
+ view.navigationDelegate = navigation
+ await withCheckedContinuation { continuation in
+ navigation.loaded = { continuation.resume() }
+ view.loadHTMLString("<!doctype html><meta charset='utf-8'><style>\(css) body{margin:0} [docs-text]{width:624px;display:flow-root;--rst-sp-6:24px}</style><main docs-text>\(html)</main>", baseURL: nil)
+ }
+ let script = """
+ await document.fonts.ready;
+ const root = document.querySelector('[docs-text]'), origin = root.getBoundingClientRect();
+ \(paged ? pageScript : "")
+ if (\(paged ? "true" : "false")) {
+ const blocks = [...root.children];
+ const view = {state:{doc:{forEach:callback=>blocks.forEach((node,i)=>callback(node,i))}},nodeDOM:i=>blocks[i]};
+ let previous = '';
+ for (let pass=0; pass<10; pass++) {
+ const positions=breakPositions(view), key=positions.join(',');
+ if (key===previous) break;
+ previous=key;
+ root.querySelectorAll('[docs-page-gap]').forEach(el=>el.remove());
+ positions.forEach((position,i)=>root.insertBefore(gapElement(i+2),blocks[position]));
+ if (pass===9) throw new Error('Pagination did not converge');
+ }
+ }
+ const lines = [];
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let node;
+ while (node = walker.nextNode()) {
+ if (node.parentElement.closest('[docs-page-gap]')) continue;
+ for (let i = 0; i < node.length; i++) {
+ const range = document.createRange(); range.setStart(node,i); range.setEnd(node,i+1);
+ const box = range.getBoundingClientRect(), previous = lines.at(-1);
+ if (!previous || box.y >= previous.bottom - .1 || box.bottom <= previous.top + .1)
+ lines.push({text:node.textContent[i], x:(box.x-origin.x)*.75,y:(box.y-origin.y)*.75,top:box.y,bottom:box.bottom});
+ else { previous.text += node.textContent[i]; previous.top=Math.min(previous.top,box.y); previous.bottom=Math.max(previous.bottom,box.bottom); }
+ }
+ }
+ for (const line of lines) line.text = line.text.trim();
+ for (const gap of root.querySelectorAll('[docs-page-gap]')) lines.push({text:'[page]',x:0,y:(gap.getBoundingClientRect().y-origin.y)*.75});
+ if (\(includeBounds ? "true" : "false")) lines.push({text:"[bottom]",x:0,y:root.getBoundingClientRect().height*.75});
+ return JSON.stringify(lines);
+ """
+ let json = try await view.callAsyncJavaScript(script, contentWorld: .page) as! String
+ return try JSONDecoder().decode([Line].self, from: Data(json.utf8))
+ }
+
+ private final class Navigation: NSObject, WKNavigationDelegate {
+ var loaded: (() -> Void)?
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { loaded?(); loaded = nil }
+ }
+}
+#endif
diff --git a/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift
new file mode 100644
index 0000000..b6d63a8
--- /dev/null
+++ b/native/apple/Tests/DocsKitTests/DocumentPageEditingTests.swift
@@ -0,0 +1,46 @@
+#if os(macOS)
+import AppKit
+import XCTest
+@testable import DocsKit
+
+@MainActor final class DocumentPageEditingTests: XCTestCase {
+ func testOptimisticAndMarkedEditsInvalidatePageOffsets() throws {
+ let nodes = (0..<100).map { index in
+ CRDTNode(id: "p\(index)", type: "paragraph", children: [CRDTNode(id: "t\(index)", type: "text", runs: [CRDTRun(insert: "A paragraph kept together when pages are recalculated.")])])
+ }
+ let view = DocsPagedTextView(frame: CGRect(x: 0, y: 0, width: 636, height: 800))
+ let storage = try XCTUnwrap(view.textStorage)
+ storage.setAttributedString(NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false)))
+ view.paged = true
+ view.layout()
+ let container = try XCTUnwrap(view.textContainer as? DocsPageTextContainer)
+ XCTAssertFalse(container.blockStarts.isEmpty)
+ let initial = container.blockStarts
+ let insertion = String(repeating: "Inserted text before the boundary. ", count: 20)
+ storage.replaceCharacters(in: NSRange(location: 0, length: 0), with: insertion)
+ XCTAssertTrue(container.blockStarts.isEmpty, "No stale UTF-16 map can reach TextKit after an edit")
+ view.layout()
+ XCTAssertNotEqual(initial, container.blockStarts)
+ assertBlockStarts(container, storage)
+ view.setSelectedRange(NSRange(location: 0, length: 0))
+ view.setMarkedText("日本語", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: 0, length: 0))
+ XCTAssertTrue(container.blockStarts.isEmpty)
+ view.layout()
+ assertBlockStarts(container, storage)
+ view.unmarkText()
+ storage.replaceCharacters(in: NSRange(location: 0, length: 3 + insertion.utf16.count), with: "")
+ XCTAssertTrue(container.blockStarts.isEmpty)
+ view.layout()
+ XCTAssertEqual(container.blockStarts, initial)
+ XCTAssertEqual(storage.string, nodes.map(\.plainText).joined(separator: "\n") + "\n")
+ }
+
+ private func assertBlockStarts(_ container: DocsPageTextContainer, _ storage: NSTextStorage) {
+ for location in container.blockStarts.keys {
+ var range = NSRange()
+ XCTAssertNotNil(storage.attribute(.docsTopBlock, at: location, effectiveRange: &range))
+ XCTAssertEqual(range.location, location, "A separator must never move into a paragraph")
+ }
+ }
+}
+#endif