#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"]), "

\(prose)

"), ("list", CRDTNode(id: "list", type: "bullet_list", children: [CRDTNode(id: "item", type: "list_item", children: [block("li", prose)])]), ""), ("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)])])])]), ""), ("quote", CRDTNode(id: "quote", type: "blockquote", children: [block("qp", prose)]), "

\(prose)

"), ("serif", block("serif", prose, marks: ["font_serif": .object([:])]), "

\(prose)

"), ("mono", block("mono", prose, marks: ["font_mono": .object([:])]), "

\(prose)

"), ("large", block("large", prose, marks: ["size_24": .object([:])]), "

\(prose)

"), ("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)])]), "

Large \(prose) \(prose)

"), ("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([:])])])]), "

\(String(repeating: "Wide words ", count: 7))WRAPPEDCODE \(prose)

"), ("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.")])]), "

Before 🧪 \(prose) after.

"), ("code", block("code", prose, marks: ["code": .object([:])]), "

\(prose)

"), ] 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: "

\(text)

", 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: "

\(text)

", 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 { "

\($0.plainText)

" }.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: "", range: start.upperBound..
\(html)
", 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