| 1 | import Foundation |
| 2 | import Testing |
| 3 | @testable import DocsKit |
| 4 | #if os(macOS) |
| 5 | import AppKit |
| 6 | #else |
| 7 | import UIKit |
| 8 | #endif |
| 9 | |
| 10 | private func field(_ value: JSONValue, _ key: String) -> JSONValue? { |
| 11 | if case .object(let values) = value { return values[key] } |
| 12 | return nil |
| 13 | } |
| 14 | private func array(_ value: JSONValue?) -> [JSONValue] { |
| 15 | if case .array(let values) = value { return values } |
| 16 | return [] |
| 17 | } |
| 18 | private func flattened(_ values: [JSONValue]) -> [JSONValue] { |
| 19 | values.flatMap { [$0] + flattened(array(field($0, "children"))) } |
| 20 | } |
| 21 | private func runs(_ values: [JSONValue]) -> [JSONValue] { |
| 22 | flattened(values).flatMap { array(field($0, "runs")) } |
| 23 | } |
| 24 | |
| 25 | @MainActor @Test func nativeRichClipboardPreservesTypographyAndParagraphStyle() { |
| 26 | let style = NSMutableParagraphStyle() |
| 27 | #if os(macOS) |
| 28 | style.headerLevel = 2 |
| 29 | #endif |
| 30 | style.alignment = .center |
| 31 | let source = NSAttributedString(string: "Résumé 🧪\n", attributes: [ |
| 32 | .font: DocsFonts.font(marks: ["strong": .object([:]), "em": .object([:]), "font_serif": .object([:]), "size_18": .object([:])], size: 12, heading: false), |
| 33 | .paragraphStyle: style, |
| 34 | .docsHeading: 2, |
| 35 | .link: URL(string: "https://example.com/doc")!, |
| 36 | ]) |
| 37 | let blocks = DocsRichClipboard.blocks(source) |
| 38 | #expect(blocks.count == 1) |
| 39 | #expect(field(blocks[0], "type") == .string("heading")) |
| 40 | #expect(field(field(blocks[0], "attrs")!, "level") == .string("2")) |
| 41 | #expect(field(field(blocks[0], "attrs")!, "align") == .string("center")) |
| 42 | let run = runs(blocks)[0] |
| 43 | #expect(field(run, "insert") == .string("Résumé 🧪")) |
| 44 | let marks = field(run, "marks")! |
| 45 | for name in ["strong", "em", "font_serif", "size_18"] { #expect(field(marks, name) == .object([:])) } |
| 46 | #expect(field(marks, "link") == .object(["href": .string("https://example.com/doc")])) |
| 47 | } |
| 48 | |
| 49 | @MainActor @Test func htmlClipboardKeepsListsLinksHardBreaksAndKnownEntities() { |
| 50 | let html = "<h2>Heading</h2><p><strong>Bold</strong> <a href='https://example.com/?x=1&y=2'>link</a><br>Next 🧪 é½</p><ol start='3'><li>First<ul><li>Nested</li></ul></li><li><em>Second</em></li></ol>" |
| 51 | let blocks = DocsHTMLClipboard.blocks(html) |
| 52 | #expect(blocks.count == 3) |
| 53 | #expect(field(blocks[0], "type") == .string("heading")) |
| 54 | #expect(field(blocks[2], "type") == .string("ordered_list")) |
| 55 | #expect(field(field(blocks[2], "attrs")!, "order") == .string("3")) |
| 56 | let nodes = flattened(blocks) |
| 57 | #expect(nodes.contains { field($0, "type") == .string("bullet_list") }) |
| 58 | #expect(nodes.contains { field($0, "type") == .string("hard_break") }) |
| 59 | let text = runs(blocks) |
| 60 | #expect(text.contains { field($0, "insert") == .string("Next 🧪 é½") }) |
| 61 | #expect(text.contains { field(field($0, "marks")!, "strong") == .object([:]) }) |
| 62 | #expect(text.contains { field(field($0, "marks")!, "link") == .object(["href": .string("https://example.com/?x=1&y=2")]) }) |
| 63 | } |
| 64 | |
| 65 | @MainActor @Test func htmlClipboardNeverImportsExecutableContentOrUnsafeLinks() { |
| 66 | let html = "<html><head><style>@import url(https://example.com/style);</style></head><body><script>secret()</script><p><a href='javascript:secret()'>Safe text</a><img src='file:///private/secret'><span style='font-family: Georgia; font-size:24px; font-weight:bold'>Large</span></p></body></html>" |
| 67 | let values = DocsHTMLClipboard.blocks(html) |
| 68 | let text = runs(values) |
| 69 | #expect(text.count == 2) |
| 70 | #expect(text.allSatisfy { field(field($0, "marks")!, "link") == nil }) |
| 71 | #expect(!flattened(values).contains { field($0, "type") == .string("image") }) |
| 72 | #expect(field(field(text[1], "marks")!, "font_serif") == .object([:])) |
| 73 | #expect(field(field(text[1], "marks")!, "size_18") == .object([:])) |
| 74 | } |
| 75 | |
| 76 | @MainActor @Test func htmlClipboardPreservesBlankParagraphsAndExplicitFontResets() { |
| 77 | let values = DocsHTMLClipboard.blocks("<p>Before</p><p></p><p><strong>Bold<span style='font-weight:normal'>normal</span></strong></p>") |
| 78 | #expect(values.count == 3) |
| 79 | let text = runs(values) |
| 80 | let normal = text.first { field($0, "insert") == .string("normal") } |
| 81 | #expect(normal != nil) |
| 82 | #expect(field(field(normal!, "marks")!, "strong") == nil) |
| 83 | } |
| 84 | |
| 85 | @MainActor @Test func webClipboardClassesAndGoogleBlankLinesRemainSemantic() { |
| 86 | let html = "<b style='font-weight:normal'><p class='docs-align-center docs-indent-2'><span class='docs-font-serif docs-size-18'>One</span></p><p>Two</p><br><p>Four</p></b><br class='Apple-interchange-newline'><br class='Apple-interchange-newline'>" |
| 87 | let values = DocsHTMLClipboard.blocks(html) |
| 88 | #expect(values.count == 4) |
| 89 | #expect(field(field(values[0], "attrs")!, "align") == .string("center")) |
| 90 | #expect(field(field(values[0], "attrs")!, "indent") == .string("2")) |
| 91 | let mark = field(runs(values)[0], "marks")! |
| 92 | #expect(field(mark, "font_serif") == .object([:])) |
| 93 | #expect(field(mark, "size_18") == .object([:])) |
| 94 | #expect(field(mark, "strong") == nil) |
| 95 | #expect(!flattened([values[2]]).contains { field($0, "type") == .string("hard_break") }) |
| 96 | } |
| 97 | |
| 98 | @MainActor @Test func nativeClipboardMetadataPreservesCodeImagesAndNestedContainers() throws { |
| 99 | let data = Data(""" |
| 100 | {"children":[{"id":"quote","type":"blockquote","children":[{"id":"list","type":"ordered_list","attrs":{"order":"3"},"children":[{"id":"item","type":"list_item","children":[{"id":"heading","type":"heading","attrs":{"level":"2"},"children":[{"id":"title","type":"text","runs":[{"insert":"Title"}]}]},{"id":"code","type":"code_block","children":[{"id":"code-text","type":"text","runs":[{"insert":"let x = 1\\nprint(x)"}]}]},{"id":"image-paragraph","type":"paragraph","children":[{"id":"image","type":"image","attrs":{"src":"https://example.com/image.png","alt":"A diagram"}}]}]}]}]}],"canUndo":false,"canRedo":false} |
| 101 | """.utf8) |
| 102 | let projection = try JSONDecoder().decode(CRDTProjection.self, from: data) |
| 103 | let content = NativeTextProjection.render(projection) |
| 104 | let values = DocsRichClipboard.blocks(content) |
| 105 | #expect(values.count == 1) |
| 106 | #expect(field(values[0], "type") == .string("blockquote")) |
| 107 | let nodes = flattened(values) |
| 108 | let lists = nodes.filter { field($0, "type") == .string("ordered_list") } |
| 109 | #expect(lists.count == 1) |
| 110 | #expect(field(field(lists[0], "attrs")!, "order") == .string("3")) |
| 111 | let code = nodes.filter { field($0, "type") == .string("code_block") } |
| 112 | #expect(code.count == 1) |
| 113 | #expect(field(runs(code)[0], "insert") == .string("let x = 1\nprint(x)")) |
| 114 | let images = nodes.filter { field($0, "type") == .string("image") } |
| 115 | #expect(images.count == 1) |
| 116 | #expect(field(field(images[0], "attrs")!, "src") == .string("https://example.com/image.png")) |
| 117 | #expect(runs(values).allSatisfy { field(field($0, "marks")!, "size_12") == nil }) |
| 118 | let roundTrip = DocsHTMLClipboard.blocks(DocsRichClipboard.html(values)) |
| 119 | #expect(flattened(roundTrip).contains { field($0, "type") == .string("code_block") }) |
| 120 | #expect(flattened(roundTrip).contains { field($0, "type") == .string("image") }) |
| 121 | } |
| 122 | |
| 123 | @MainActor @Test func richClipboardLinkTitleSurvivesHTMLRoundTrip() { |
| 124 | let first = DocsHTMLClipboard.blocks("<p><a href='https://example.com' title='A "quoted" title'>Link</a></p>") |
| 125 | let again = DocsHTMLClipboard.blocks(DocsRichClipboard.html(first)) |
| 126 | let link = field(field(runs(again)[0], "marks")!, "link")! |
| 127 | #expect(field(link, "title") == .string("A \"quoted\" title")) |
| 128 | } |
| 129 | |
| 130 | @MainActor @Test func nativeNumberedListUsesItsStoredStartingNumber() throws { |
| 131 | let data = Data(""" |
| 132 | {"children":[{"id":"list","type":"ordered_list","attrs":{"order":"7"},"children":[{"id":"item","type":"list_item","children":[{"id":"p","type":"paragraph","children":[{"id":"text","type":"text","runs":[{"insert":"Seventh"}]}]}]}]}],"canUndo":false,"canRedo":false} |
| 133 | """.utf8) |
| 134 | let content = NativeTextProjection.render(try JSONDecoder().decode(CRDTProjection.self, from: data)) |
| 135 | let style = content.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle |
| 136 | #expect(style?.textLists.first?.startingItemNumber == 7) |
| 137 | } |
| 138 | |
| 139 | @MainActor @Test func projectionClampsUntrustedHeadingAndIndentValues() throws { |
| 140 | let data = Data(""" |
| 141 | {"children":[{"id":"p","type":"heading","attrs":{"level":"9223372036854775807","indent":"9223372036854775807"},"children":[{"id":"text","type":"text","runs":[{"insert":"Bounded"}]}]}],"canUndo":false,"canRedo":false} |
| 142 | """.utf8) |
| 143 | let content = NativeTextProjection.render(try JSONDecoder().decode(CRDTProjection.self, from: data)) |
| 144 | let style = content.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle |
| 145 | #expect(style?.headIndent == 288) |
| 146 | #expect(content.attribute(.docsHeading, at: 0, effectiveRange: nil) as? Int == 6) |
| 147 | } |
| 148 | |