Match Mac rich-text layout and block pagination to web fixtures
11 files changed,
+555
−25
docs/superpowers/specs/2026-09-20-mac-document-windows.md+22 −6native/apple/Sources/DocsKit/DocsFonts.swift+1 −0native/apple/Sources/DocsKit/DocsLayoutManager.swift+71 −0native/apple/Sources/DocsKit/DocsPagedTextView.swift+33 −13native/apple/Sources/DocsKit/DocsPaging.swift+21 −1native/apple/Sources/DocsKit/DocumentBlockSpacing.swift+54 −0native/apple/Sources/DocsKit/DocumentInlineCode.swift+38 −0native/apple/Sources/DocsKit/DocumentPageLayout.swift+51 −0native/apple/Sources/DocsKit/NativeTextProjection.swift+39 −5native/apple/Tests/DocsKitTests/BrowserTypographyTests.swift+179 −0native/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 |