#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)
\(prose)
"), ("serif", block("serif", prose, marks: ["font_serif": .object([:])]), "\(prose)
\(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)
Before 🧪 \(prose) after.
\(prose)
", 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 { "\(text)
\($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..