| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import WebKit |
| 4 | import 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 | |