oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2import AppKit
3import WebKit
4import XCTest
5@testable import DocsKit
6
7/// Measures the production stylesheet, not a second copy of its typography rules.
8@MainActor final class BrowserTypographyTests: XCTestCase {
9 func testRichTextLineBreaksMatchBrowser() async throws {
10 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."
11 let cases: [(String, CRDTNode, String)] = [
12 ("heading4", block("h4", prose, type: "heading", attrs: ["level": "4"]), "<h4>\(prose)</h4>"),
13 ("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>"),
14 ("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>"),
15 ("quote", CRDTNode(id: "quote", type: "blockquote", children: [block("qp", prose)]), "<blockquote><p>\(prose)</p></blockquote>"),
16 ("serif", block("serif", prose, marks: ["font_serif": .object([:])]), "<p><span class='docs-font-serif'>\(prose)</span></p>"),
17 ("mono", block("mono", prose, marks: ["font_mono": .object([:])]), "<p><span class='docs-font-mono'>\(prose)</span></p>"),
18 ("large", block("large", prose, marks: ["size_24": .object([:])]), "<p><span class='docs-size-24'>\(prose)</span></p>"),
19 ("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>"),
20 ("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>"),
21 ("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>"),
22 ("code", block("code", prose, marks: ["code": .object([:])]), "<p><code>\(prose)</code></p>"),
23 ]
24 for (name, node, html) in cases {
25 let browser = try await browserLines(html)
26 let native = nativeLines([node])
27 XCTAssertEqual(native.map(\.text), browser.map(\.text), name)
28 if name == "mixed-size", native.count > 2, browser.count > 2 {
29 XCTAssertEqual(native.last!.y - native[native.count - 2].y, browser.last!.y - browser[browser.count - 2].y, accuracy: 0.76)
30 }
31 for (lhs, rhs) in zip(native, browser) {
32 XCTAssertEqual(lhs.x, rhs.x, accuracy: 0.76, "\(name): text origin")
33 if !name.hasPrefix("mixed") { XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76, "\(name): line spacing") }
34 }
35 }
36 }
37
38 func testAdjacentHeadingMarginsCollapse() async throws {
39 let text = "A heading with a predictable measure"
40 let nodes = (0..<4).map { block("h\($0)", text, type: "heading", attrs: ["level": "2"]) }
41 let browser = try await browserLines(String(repeating: "<h2>\(text)</h2>", count: 4))
42 let native = nativeLines(nodes)
43 XCTAssertEqual(native.count, browser.count)
44 for (lhs, rhs) in zip(native, browser) {
45 XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76)
46 }
47 }
48
49 func testNestedBlockSpacingMatchesBrowser() async throws {
50 let text = "A repeated heading inside a quote"
51 let nodes = (0..<5).map { index in CRDTNode(id: "quote\(index)", type: "blockquote", children: [block("nestedHeading\(index)", text, type: "heading", attrs: ["level": "2"])]) }
52 let html = String(repeating: "<blockquote><h2>\(text)</h2></blockquote>", count: 5)
53 let browser = try await browserLines(html, includeBounds: true)
54 let native = nativeLines(nodes, includeBounds: true)
55 XCTAssertEqual(native.map(\.text), browser.map(\.text))
56 for (lhs, rhs) in zip(native, browser) {
57 if lhs.text == "[bottom]" { XCTAssertEqual(lhs.y, rhs.y, accuracy: 0.76) }
58 else { XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 0.76) }
59 }
60 }
61
62 func testPageBreaksMatchBrowserAndPreserveWholeBlocks() async throws {
63 let prose = "A page keeps this whole paragraph together. "
64 let nodes = (0..<18).map { block("p\($0)", String(repeating: prose, count: $0 == 0 ? 160 : 8)) }
65 let html = nodes.map { "<p>\($0.plainText)</p>" }.joined()
66 let browser = try await browserLines(html, paged: true)
67 let native = nativeLines(nodes, paged: true)
68 XCTAssertEqual(native.map(\.text), browser.map(\.text))
69 XCTAssertGreaterThan(native.filter { $0.text == "[page]" }.count, 1)
70 for (lhs, rhs) in zip(native, browser) {
71 if lhs.text == "[page]" {
72 XCTAssertEqual(lhs.y - 72, rhs.y, accuracy: 1.5, "page separator")
73 } else {
74 XCTAssertEqual(lhs.y - native[0].y, rhs.y - browser[0].y, accuracy: 1.5, lhs.text)
75 }
76 }
77 }
78
79 private func block(_ id: String, _ text: String, type: String = "paragraph", attrs: [String: String]? = nil, marks: [String: JSONValue]? = nil) -> CRDTNode {
80 CRDTNode(id: id, type: type, attrs: attrs, children: [CRDTNode(id: id + "t", type: "text", runs: [CRDTRun(insert: text, marks: marks)])])
81 }
82
83 struct Line: Decodable { var text: String; var x: Double; var y: Double }
84 private func nativeLines(_ nodes: [CRDTNode], paged: Bool = false, includeBounds: Bool = false) -> [Line] {
85 let storage = NSTextStorage(attributedString: NativeTextProjection.render(CRDTProjection(children: nodes, canUndo: false, canRedo: false)))
86 let manager = DocsLayoutManager()
87 let container = DocsPageTextContainer(size: CGSize(width: 636, height: 100000))
88 container.geometry = DocsPageGeometry(viewportWidth: 636, paged: false)
89 container.lineFragmentPadding = 0
90 storage.addLayoutManager(manager); manager.addTextContainer(container)
91 manager.ensureLayout(for: container)
92 let pages = paged ? DocumentPageLayout.measure(storage: storage, manager: manager, container: container) : DocumentPageLayout.Result()
93 container.blockStarts = pages.starts
94 manager.ensureLayout(for: container)
95 var lines: [Line] = []
96 var contentBottom: CGFloat = 72
97 manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: manager.numberOfGlyphs)) { rect, _, _, range, _ in
98 contentBottom = max(contentBottom, rect.maxY)
99 let chars = manager.characterRange(forGlyphRange: range, actualGlyphRange: nil)
100 let text = (storage.string as NSString).substring(with: chars).trimmingCharacters(in: .whitespacesAndNewlines)
101 let location = manager.location(forGlyphAt: range.location)
102 lines.append(Line(text: text, x: rect.minX + location.x - container.geometry.textX, y: rect.minY))
103 }
104 lines += pages.separators.map { Line(text: "[page]", x: 0, y: $0) }
105 if includeBounds { lines.append(Line(text: "[bottom]", x: 0, y: contentBottom - 72)) }
106 return lines
107 }
108
109 private func browserLines(_ html: String, paged: Bool = false, includeBounds: Bool = false) async throws -> [Line] {
110 let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
111 let source = try String(contentsOf: root.appendingPathComponent("internal/docs/views/show.html"), encoding: .utf8)
112 let start = try XCTUnwrap(source.range(of: "<style>"))
113 let end = try XCTUnwrap(source.range(of: "</style>", range: start.upperBound..<source.endIndex))
114 var css = String(source[start.upperBound..<end.lowerBound])
115 css = css.replacingOccurrences(of: #"\{\{range \.FontFaces\}\}[\s\S]*?\{\{end\}\}"#, with: "", options: .regularExpression)
116 for family in ["Sans", "Serif", "Mono"] {
117 for face in ["Regular", "Bold", "Italic", "BoldItalic"] {
118 let font = try XCTUnwrap(DocsResources.bundle.url(forResource: "Liberation\(family)-\(face)", withExtension: "ttf"))
119 let data = try Data(contentsOf: font).base64EncodedString()
120 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");}"
121 }
122 }
123 let pageSource = try String(contentsOf: root.appendingPathComponent("fixtures/pages.mjs"), encoding: .utf8)
124 let measureStart = try XCTUnwrap(pageSource.range(of: "function breakPositions(view)"))
125 let measureEnd = try XCTUnwrap(pageSource.range(of: "// pagesPlugin draws"))
126 let gapStart = try XCTUnwrap(pageSource.range(of: "function gapElement(number, pageLabel)"))
127 let pageScript = "const PAGE_CONTENT_PX=864;" + pageSource[measureStart.lowerBound..<measureEnd.lowerBound] + pageSource[gapStart.lowerBound...]
128 let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 1000, height: 1200))
129 let navigation = Navigation()
130 view.navigationDelegate = navigation
131 await withCheckedContinuation { continuation in
132 navigation.loaded = { continuation.resume() }
133 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)
134 }
135 let script = """
136 await document.fonts.ready;
137 const root = document.querySelector('[docs-text]'), origin = root.getBoundingClientRect();
138 \(paged ? pageScript : "")
139 if (\(paged ? "true" : "false")) {
140 const blocks = [...root.children];
141 const view = {state:{doc:{forEach:callback=>blocks.forEach((node,i)=>callback(node,i))}},nodeDOM:i=>blocks[i]};
142 let previous = '';
143 for (let pass=0; pass<10; pass++) {
144 const positions=breakPositions(view), key=positions.join(',');
145 if (key===previous) break;
146 previous=key;
147 root.querySelectorAll('[docs-page-gap]').forEach(el=>el.remove());
148 positions.forEach((position,i)=>root.insertBefore(gapElement(i+2),blocks[position]));
149 if (pass===9) throw new Error('Pagination did not converge');
150 }
151 }
152 const lines = [];
153 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
154 let node;
155 while (node = walker.nextNode()) {
156 if (node.parentElement.closest('[docs-page-gap]')) continue;
157 for (let i = 0; i < node.length; i++) {
158 const range = document.createRange(); range.setStart(node,i); range.setEnd(node,i+1);
159 const box = range.getBoundingClientRect(), previous = lines.at(-1);
160 if (!previous || box.y >= previous.bottom - .1 || box.bottom <= previous.top + .1)
161 lines.push({text:node.textContent[i], x:(box.x-origin.x)*.75,y:(box.y-origin.y)*.75,top:box.y,bottom:box.bottom});
162 else { previous.text += node.textContent[i]; previous.top=Math.min(previous.top,box.y); previous.bottom=Math.max(previous.bottom,box.bottom); }
163 }
164 }
165 for (const line of lines) line.text = line.text.trim();
166 for (const gap of root.querySelectorAll('[docs-page-gap]')) lines.push({text:'[page]',x:0,y:(gap.getBoundingClientRect().y-origin.y)*.75});
167 if (\(includeBounds ? "true" : "false")) lines.push({text:"[bottom]",x:0,y:root.getBoundingClientRect().height*.75});
168 return JSON.stringify(lines);
169 """
170 let json = try await view.callAsyncJavaScript(script, contentWorld: .page) as! String
171 return try JSONDecoder().decode([Line].self, from: Data(json.utf8))
172 }
173
174 private final class Navigation: NSObject, WKNavigationDelegate {
175 var loaded: (() -> Void)?
176 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { loaded?(); loaded = nil }
177 }
178}
179#endif
180