| 1 | package export_test |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "math" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/docs/internal/export" |
| 14 | ) |
| 15 | |
| 16 | func mustPDF(t *testing.T, doc export.Document) ([]byte, export.Report) { |
| 17 | t.Helper() |
| 18 | out, report, err := export.PDF(doc) |
| 19 | if err != nil { |
| 20 | t.Fatalf("PDF: %v", err) |
| 21 | } |
| 22 | return out, report |
| 23 | } |
| 24 | |
| 25 | // The one that matters: the words a person typed have to be the words |
| 26 | // they get when they select the exported PDF and copy it. Everything |
| 27 | // between — glyph ids, the subset, the CMap — is machinery in service |
| 28 | // of this. |
| 29 | func TestPDFTextCanBeCopiedBackOut(t *testing.T) { |
| 30 | js := `{"type":"doc","content":[ |
| 31 | {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading"}]}, |
| 32 | {"type":"paragraph","content":[ |
| 33 | {"type":"text","text":"Plain then "}, |
| 34 | {"type":"text","text":"bold","marks":[{"type":"strong"}]}, |
| 35 | {"type":"text","text":" and a caf"}, |
| 36 | {"type":"text","text":"é."} |
| 37 | ]} |
| 38 | ]}` |
| 39 | data, report := mustPDF(t, mustParse(t, "Notes", js)) |
| 40 | if !report.Empty() { |
| 41 | t.Errorf("unexpected substitutions: %s", report) |
| 42 | } |
| 43 | |
| 44 | f := readPDF(t, data) |
| 45 | pages := f.pages(t) |
| 46 | if len(pages) != 1 { |
| 47 | t.Fatalf("got %d pages, want 1", len(pages)) |
| 48 | } |
| 49 | got := f.text(t, pages[0]) |
| 50 | |
| 51 | for _, want := range []string{"Notes", "A heading", "Plain then", "bold", "café."} { |
| 52 | if !strings.Contains(got, want) { |
| 53 | t.Errorf("copied text is missing %q; got %q", want, got) |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | // Bold and italic are separate font programs, not a synthesised slant, |
| 59 | // so a document using both must embed both and a page must declare |
| 60 | // both in its resources. |
| 61 | func TestPDFEmbedsAFaceForEachStyleUsed(t *testing.T) { |
| 62 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 63 | {"type":"text","text":"regular "}, |
| 64 | {"type":"text","text":"bold ","marks":[{"type":"strong"}]}, |
| 65 | {"type":"text","text":"italic ","marks":[{"type":"em"}]}, |
| 66 | {"type":"text","text":"mono","marks":[{"type":"font_mono"}]} |
| 67 | ]}]}` |
| 68 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 69 | f := readPDF(t, data) |
| 70 | |
| 71 | var descriptors, files int |
| 72 | for _, o := range f.objects { |
| 73 | if strings.Contains(o.dict, "/Type /FontDescriptor") { |
| 74 | descriptors++ |
| 75 | if !strings.Contains(o.dict, "/FontFile2") { |
| 76 | t.Error("a font descriptor with no embedded font file") |
| 77 | } |
| 78 | } |
| 79 | if strings.Contains(o.dict, "/Length1") { |
| 80 | files++ |
| 81 | } |
| 82 | } |
| 83 | // Regular, bold, italic and mono: four distinct faces. (The title |
| 84 | // is empty here, so nothing pulls in a bold serif of its own.) |
| 85 | if descriptors != 4 { |
| 86 | t.Errorf("got %d font descriptors, want 4 (regular, bold, italic, mono)", descriptors) |
| 87 | } |
| 88 | if files != descriptors { |
| 89 | t.Errorf("got %d embedded font files for %d descriptors", files, descriptors) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | // The face a run is actually drawn in, read back off the paper. |
| 94 | // |
| 95 | // Everything else about the default face is checked one step short of |
| 96 | // the PDF: internal/docs's TestPageGeometryMatchesTheStylesheet compares |
| 97 | // the name styleFor resolves to against the stylesheet, which proves the |
| 98 | // two agree but not that the agreed face is what gets embedded and |
| 99 | // selected. This reads /BaseFont out of the file, which is the last |
| 100 | // place a mistake could still be hiding. |
| 101 | // |
| 102 | // Both halves matter and they fail in opposite directions. An unmarked |
| 103 | // run must be the DEFAULT face — that is most of the text in most |
| 104 | // documents, and it is what moved. A font_serif run must be serif |
| 105 | // whatever the default is — that is the mark existing at all, and if |
| 106 | // styleFor did not handle it the run would fall through to the default |
| 107 | // and this test would see one face where it wants two. |
| 108 | func TestPDFDrawsTheDefaultFaceAndTheSerifMark(t *testing.T) { |
| 109 | js := `{"type":"doc","content":[ |
| 110 | {"type":"paragraph","content":[{"type":"text","text":"Unmarked"}]}, |
| 111 | {"type":"paragraph","content":[{"type":"text","text":"Serif","marks":[{"type":"font_serif"}]}]} |
| 112 | ]}` |
| 113 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 114 | f := readPDF(t, data) |
| 115 | |
| 116 | faces := map[string]bool{} |
| 117 | for _, o := range f.objects { |
| 118 | for _, m := range regexp.MustCompile(`/BaseFont /[A-Z]{6}\+([^ />]+)`).FindAllStringSubmatch(o.dict, -1) { |
| 119 | faces[m[1]] = true |
| 120 | } |
| 121 | } |
| 122 | if len(faces) == 0 { |
| 123 | t.Fatal("the PDF names no faces at all — the font dictionaries were restructured and this test can no longer see what it is checking") |
| 124 | } |
| 125 | if !faces["LiberationSans-Regular"] { |
| 126 | t.Errorf("an unmarked run is not drawn in Liberation Sans; the PDF uses %v", sortedKeys(faces)) |
| 127 | } |
| 128 | if !faces["LiberationSerif-Regular"] { |
| 129 | t.Errorf("a font_serif run is not drawn in Liberation Serif; the PDF uses %v", sortedKeys(faces)) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func sortedKeys(m map[string]bool) []string { |
| 134 | out := make([]string, 0, len(m)) |
| 135 | for k := range m { |
| 136 | out = append(out, k) |
| 137 | } |
| 138 | sort.Strings(out) |
| 139 | return out |
| 140 | } |
| 141 | |
| 142 | // The embedded font must be a subset, not the whole face — an export |
| 143 | // that embeds four full faces is a megabyte and a half of font for a |
| 144 | // page of text. |
| 145 | func TestPDFEmbedsSubsetsNotWholeFaces(t *testing.T) { |
| 146 | js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}` |
| 147 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 148 | f := readPDF(t, data) |
| 149 | |
| 150 | found := false |
| 151 | for _, o := range f.objects { |
| 152 | if !strings.Contains(o.dict, "/Length1") { |
| 153 | continue |
| 154 | } |
| 155 | found = true |
| 156 | // Liberation Serif is about 385KB whole. A subset holding the |
| 157 | // glyphs of "Hello" plus loca and hmtx is far below a tenth of |
| 158 | // that; the threshold is deliberately loose so this fails on a |
| 159 | // subsetter that stopped subsetting, not on one that got a bit |
| 160 | // less efficient. |
| 161 | if len(o.stream) > 100_000 { |
| 162 | t.Errorf("embedded font is %d bytes — that is not a subset", len(o.stream)) |
| 163 | } |
| 164 | if len(o.stream) < 1_000 { |
| 165 | t.Errorf("embedded font is %d bytes — that is too small to be a font", len(o.stream)) |
| 166 | } |
| 167 | } |
| 168 | if !found { |
| 169 | t.Fatal("no embedded font file in the PDF") |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | // Text longer than a page has to become more pages, and no text may be |
| 174 | // lost at the break. |
| 175 | func TestPDFPaginatesLongDocuments(t *testing.T) { |
| 176 | var paras []string |
| 177 | for i := 0; i < 120; i++ { |
| 178 | paras = append(paras, `{"type":"paragraph","content":[{"type":"text","text":"Paragraph number `+ |
| 179 | strings.Repeat("x", 3)+` of a document long enough to need more than one page."}]}`) |
| 180 | } |
| 181 | js := `{"type":"doc","content":[` + strings.Join(paras, ",") + `]}` |
| 182 | |
| 183 | data, _ := mustPDF(t, mustParse(t, "Long", js)) |
| 184 | f := readPDF(t, data) |
| 185 | pages := f.pages(t) |
| 186 | if len(pages) < 3 { |
| 187 | t.Fatalf("120 paragraphs produced %d pages", len(pages)) |
| 188 | } |
| 189 | |
| 190 | var all strings.Builder |
| 191 | for _, p := range pages { |
| 192 | all.WriteString(f.text(t, p)) |
| 193 | } |
| 194 | if n := strings.Count(all.String(), "Paragraph number"); n != 120 { |
| 195 | t.Errorf("found %d paragraphs across %d pages, want 120", n, len(pages)) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // A word wider than the text column must be broken rather than run off |
| 200 | // the edge of the paper — a URL is the ordinary case. |
| 201 | func TestPDFBreaksAWordTooLongForTheLine(t *testing.T) { |
| 202 | long := strings.Repeat("supercalifragilistic", 12) |
| 203 | js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + long + `"}]}]}` |
| 204 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 205 | f := readPDF(t, data) |
| 206 | got := f.text(t, f.pages(t)[0]) |
| 207 | if len(got) < len(long) { |
| 208 | t.Errorf("the long word lost characters: %d of %d survived", len(got), len(long)) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // A character no core face has a glyph for must be reported, not |
| 213 | // silently dropped — §11.2's degrade-loudly rule, and the only form of |
| 214 | // infidelity this exporter can currently produce. |
| 215 | func TestPDFReportsCharactersItHasNoGlyphFor(t *testing.T) { |
| 216 | js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello 漢字"}]}]}` |
| 217 | _, report := mustPDF(t, mustParse(t, "", js)) |
| 218 | if report.Empty() { |
| 219 | t.Fatal("CJK is outside the core font set (§11.1) and must be reported as a substitution") |
| 220 | } |
| 221 | if !strings.Contains(report.String(), "漢") { |
| 222 | t.Errorf("report does not name the character: %s", report) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // The same document exported twice must produce the same bytes. Go's |
| 227 | // map iteration is randomised, and a PDF whose object order varies run |
| 228 | // to run cannot be diffed, cached or compared by anyone. |
| 229 | func TestPDFIsDeterministic(t *testing.T) { |
| 230 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 231 | {"type":"text","text":"one "}, |
| 232 | {"type":"text","text":"two","marks":[{"type":"strong"}]}, |
| 233 | {"type":"text","text":" three","marks":[{"type":"font_sans"}]} |
| 234 | ]}]}` |
| 235 | doc := mustParse(t, "Same", js) |
| 236 | a, _ := mustPDF(t, doc) |
| 237 | b, _ := mustPDF(t, doc) |
| 238 | // bytes.Equal, NOT strings.EqualFold. This test compared with |
| 239 | // EqualFold and could not see what it claimed to: a PDF is mostly |
| 240 | // Flate-compressed, which is mostly invalid UTF-8, and every |
| 241 | // invalid byte decodes to U+FFFD on both sides — so two files whose |
| 242 | // compressed bodies differed byte-for-byte compared equal, in the |
| 243 | // test whose whole subject is bytes. The check answered "yes" |
| 244 | // because it could not see, which is the failure shape AGENTS.md |
| 245 | // names, in the one test written to rule it out. |
| 246 | if !bytes.Equal(a, b) { |
| 247 | t.Error("two exports of the same document produced different bytes") |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | // A run carrying two size marks at once — which ProseMirror's schema |
| 252 | // forbids but a client posting raw Yjs bytes can still produce — must |
| 253 | // resolve to the SAME size every time. styleFor picked one by ranging |
| 254 | // a map and breaking, and Go randomises map order, so the same document |
| 255 | // exported twice could differ. The test above cannot catch it (the |
| 256 | // fixture has no such run), which is why this is its own case. |
| 257 | func TestPDFIsDeterministicForARunWithTwoSizeMarks(t *testing.T) { |
| 258 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 259 | {"type":"text","text":"ambiguous","marks":[{"type":"size_sm"},{"type":"size_xl"}]} |
| 260 | ]}]}` |
| 261 | doc := mustParse(t, "Ambiguous", js) |
| 262 | first, _ := mustPDF(t, doc) |
| 263 | for i := 0; i < 8; i++ { |
| 264 | again, _ := mustPDF(t, doc) |
| 265 | if !bytes.Equal(first, again) { |
| 266 | t.Fatalf("export %d differed: a run with two size marks resolves nondeterministically", i+2) |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // Every indirect reference in the file must resolve, and the trailer |
| 272 | // must agree with the cross-reference table about how many objects |
| 273 | // there are. |
| 274 | // |
| 275 | // This is where the honesty of this package's PDF testing sits. No |
| 276 | // third-party reader has opened these files — none is available in |
| 277 | // this build — so the reader in pdfread_test.go is both the writer's |
| 278 | // only checker and something written by the same hand, which is |
| 279 | // exactly the arrangement that can be consistently wrong. What this |
| 280 | // test adds is the set of properties that do not depend on agreeing |
| 281 | // with that reader: a dangling reference, an off-by-one /Size, or an |
| 282 | // xref offset that points at the wrong object are the three ways a |
| 283 | // hand-written PDF writer breaks a real reader, and none of them can |
| 284 | // be true here without failing. |
| 285 | func TestPDFStructureIsInternallyConsistent(t *testing.T) { |
| 286 | js := `{"type":"doc","content":[ |
| 287 | {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"H"}]}, |
| 288 | {"type":"paragraph","content":[{"type":"text","text":"body","marks":[{"type":"strong"}]}]}, |
| 289 | {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"q"}]}]} |
| 290 | ]}` |
| 291 | data, _ := mustPDF(t, mustParse(t, "Structure", js)) |
| 292 | |
| 293 | // readPDF already follows every xref offset and fails if one does |
| 294 | // not land on the object it claims. |
| 295 | f := readPDF(t, data) |
| 296 | |
| 297 | refRe := regexp.MustCompile(`(\d+) 0 R`) |
| 298 | for num, o := range f.objects { |
| 299 | for _, m := range refRe.FindAllStringSubmatch(o.dict, -1) { |
| 300 | target, _ := strconv.Atoi(m[1]) |
| 301 | if _, ok := f.objects[target]; !ok { |
| 302 | t.Errorf("object %d references object %d, which does not exist", num, target) |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | var size int |
| 308 | if _, err := fmt.Sscanf( |
| 309 | string(data[bytes.LastIndex(data, []byte("trailer")):]), "trailer\n<< /Size %d", &size); err != nil { |
| 310 | t.Fatalf("unreadable trailer: %v", err) |
| 311 | } |
| 312 | if want := len(f.objects) + 1; size != want { |
| 313 | t.Errorf("trailer says /Size %d, the xref lists %d objects plus the free head", size, want) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func TestPDFCarriesTheTitleAsMetadata(t *testing.T) { |
| 318 | data, _ := mustPDF(t, mustParse(t, "Café notes", `{"type":"doc","content":[]}`)) |
| 319 | f := readPDF(t, data) |
| 320 | found := false |
| 321 | for _, o := range f.objects { |
| 322 | if strings.Contains(o.dict, "/Producer") { |
| 323 | found = true |
| 324 | // UTF-16BE with a BOM: "C" is 0043. |
| 325 | if !strings.Contains(o.dict, "/Title <FEFF0043") { |
| 326 | t.Errorf("title is not UTF-16BE encoded: %s", o.dict) |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | if !found { |
| 331 | t.Error("no document information dictionary") |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | // Every item of an ordered list keeps its number, including the item |
| 336 | // whose first line falls on a new page. |
| 337 | // |
| 338 | // That item used to lose its marker entirely: the marker was placed at |
| 339 | // a cursor remembered before the item was laid out, guarded by a |
| 340 | // comparison between an offset on the old page and one on the new, |
| 341 | // which is meaningless — so when the guard failed nothing was drawn at |
| 342 | // all. A missing "7." in a numbered list is a document saying something |
| 343 | // its author did not write. |
| 344 | func TestPDFNumbersEveryListItemAcrossAPageBreak(t *testing.T) { |
| 345 | const items = 70 |
| 346 | var li []string |
| 347 | for i := 0; i < items; i++ { |
| 348 | li = append(li, `{"type":"list_item","content":[{"type":"paragraph","content":[`+ |
| 349 | `{"type":"text","text":"An item with enough words in it that the list is taller than one page."}]}]}`) |
| 350 | } |
| 351 | js := `{"type":"doc","content":[{"type":"ordered_list","content":[` + strings.Join(li, ",") + `]}]}` |
| 352 | |
| 353 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 354 | f := readPDF(t, data) |
| 355 | pages := f.pages(t) |
| 356 | if len(pages) < 2 { |
| 357 | t.Fatalf("%d items produced %d pages; the test needs a page break to be meaningful", items, len(pages)) |
| 358 | } |
| 359 | |
| 360 | var all strings.Builder |
| 361 | for _, p := range pages { |
| 362 | all.WriteString(f.text(t, p)) |
| 363 | } |
| 364 | text := all.String() |
| 365 | for n := 1; n <= items; n++ { |
| 366 | if !strings.Contains(text, strconv.Itoa(n)+".") { |
| 367 | t.Errorf("list item %d has no number in the exported PDF", n) |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | // Text inside a node this build has never heard of must reach the PDF, |
| 373 | // the same guarantee the core and the Markdown writer make. |
| 374 | func TestPDFKeepsTextInsideUnknownNodes(t *testing.T) { |
| 375 | js := `{"type":"doc","content":[ |
| 376 | {"type":"table","content":[{"type":"paragraph","content":[{"type":"text","text":"kept"}]}]} |
| 377 | ]}` |
| 378 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 379 | f := readPDF(t, data) |
| 380 | if got := f.text(t, f.pages(t)[0]); !strings.Contains(got, "kept") { |
| 381 | t.Errorf("text inside an unknown node was lost: %q", got) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | // An empty document still has to be a valid one-page PDF rather than |
| 386 | // an error or a zero-page file no reader will open. |
| 387 | func TestPDFOfAnEmptyDocument(t *testing.T) { |
| 388 | data, _ := mustPDF(t, mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) |
| 389 | f := readPDF(t, data) |
| 390 | if n := len(f.pages(t)); n != 1 { |
| 391 | t.Errorf("got %d pages, want 1", n) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | // Lists and quotes have to reach the page with their markers and their |
| 396 | // text — a bullet drawn at a negative x, or a marker skipped, is |
| 397 | // invisible in a byte comparison. |
| 398 | func TestPDFDrawsListMarkersAndQuotedText(t *testing.T) { |
| 399 | js := `{"type":"doc","content":[ |
| 400 | {"type":"bullet_list","content":[ |
| 401 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"One"}]}]} |
| 402 | ]}, |
| 403 | {"type":"ordered_list","content":[ |
| 404 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"First"}]}]} |
| 405 | ]}, |
| 406 | {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted"}]}]} |
| 407 | ]}` |
| 408 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 409 | f := readPDF(t, data) |
| 410 | got := f.text(t, f.pages(t)[0]) |
| 411 | for _, want := range []string{"•", "One", "1.", "First", "Quoted"} { |
| 412 | if !strings.Contains(got, want) { |
| 413 | t.Errorf("missing %q from the page; got %q", want, got) |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // Where the ink actually starts. |
| 419 | // |
| 420 | // Every other test in this file asks what the PDF says; none of them |
| 421 | // asked where it says it, and a whole-page horizontal offset shipped |
| 422 | // through all of them — body text was drawn at x=0, flush against the |
| 423 | // left edge of the paper, while line breaking still used the correct |
| 424 | // 6.5in column, so every page also carried a two-inch gutter on the |
| 425 | // right. The words all copied back out perfectly. That is exactly the |
| 426 | // shape of defect a text-only assertion cannot see, which is why this |
| 427 | // reads the text matrix (pdfread_test.go's runs) rather than the text. |
| 428 | // |
| 429 | // The three x-coordinates the layout computes are all here: the text |
| 430 | // draw, the blockquote's rule, and the list marker. A list marker is |
| 431 | // the one thing deliberately drawn to the LEFT of its own text — it |
| 432 | // sits in the list's indent — so it is the tightest check that "no ink |
| 433 | // in the margin" is a real bound rather than a slack one. |
| 434 | func TestPDFStartsTextAtTheLeftMargin(t *testing.T) { |
| 435 | js := `{"type":"doc","content":[ |
| 436 | {"type":"paragraph","content":[{"type":"text","text":"Body text."}]}, |
| 437 | {"type":"bullet_list","content":[ |
| 438 | {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Item"}]}]} |
| 439 | ]}, |
| 440 | {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted"}]}]} |
| 441 | ]}` |
| 442 | // No title: the first thing drawn has to be the first paragraph, |
| 443 | // with nothing above it to borrow a position from. |
| 444 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 445 | f := readPDF(t, data) |
| 446 | page := f.pages(t)[0] |
| 447 | |
| 448 | runs := f.runs(t, page) |
| 449 | if len(runs) == 0 { |
| 450 | t.Fatal("nothing was drawn on the page") |
| 451 | } |
| 452 | if got := runs[0].text; got != "Body" { |
| 453 | t.Fatalf("the first drawn run is %q, want %q — this test is no longer looking at the paragraph it thinks it is", got, "Body") |
| 454 | } |
| 455 | if math.Abs(runs[0].x-export.MarginPt) > 0.01 { |
| 456 | t.Errorf("body text starts at x=%v, want the page margin %v", runs[0].x, export.MarginPt) |
| 457 | } |
| 458 | |
| 459 | rules := f.rules(t, page) |
| 460 | if len(rules) != 1 { |
| 461 | t.Fatalf("got %d filled rectangles, want 1 (the blockquote's edge)", len(rules)) |
| 462 | } |
| 463 | if math.Abs(rules[0].x-export.MarginPt) > 0.01 { |
| 464 | t.Errorf("the blockquote rule starts at x=%v, want the page margin %v", rules[0].x, export.MarginPt) |
| 465 | } |
| 466 | |
| 467 | // Nothing at all may be drawn in the margin — including the bullet, |
| 468 | // which sits left of its own text and is the closest thing to the |
| 469 | // edge on the page. |
| 470 | for _, r := range runs { |
| 471 | if r.x < export.MarginPt-0.01 { |
| 472 | t.Errorf("%q is drawn at x=%v, inside the %v page margin", r.text, r.x, export.MarginPt) |
| 473 | } |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // Shift+Enter's line break has to end the line in the PDF too — and |
| 478 | // end only the line, not the block. |
| 479 | // |
| 480 | // This reads the positions rather than the text for the same reason |
| 481 | // the margin test does: a dropped break loses no words at all, it just |
| 482 | // runs two lines together, so a text-only assertion sees nothing wrong. |
| 483 | // The two halves of the paragraph have to sit on different baselines |
| 484 | // and both start at the left margin; the break must not indent, split |
| 485 | // or paginate anything. |
| 486 | func TestPDFEndsTheLineAtAHardBreak(t *testing.T) { |
| 487 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 488 | {"type":"text","text":"first"}, |
| 489 | {"type":"hard_break"}, |
| 490 | {"type":"text","text":"second"} |
| 491 | ]}]}` |
| 492 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 493 | f := readPDF(t, data) |
| 494 | runs := f.runs(t, f.pages(t)[0]) |
| 495 | if len(runs) != 2 { |
| 496 | t.Fatalf("got %d drawn runs, want 2 (%v)", len(runs), runs) |
| 497 | } |
| 498 | if runs[0].text != "first" || runs[1].text != "second" { |
| 499 | t.Fatalf("drew %q then %q, want %q then %q", runs[0].text, runs[1].text, "first", "second") |
| 500 | } |
| 501 | if runs[0].y == runs[1].y { |
| 502 | t.Errorf("both halves are on the same baseline (y=%v) — the break was dropped", runs[0].y) |
| 503 | } |
| 504 | // Down the page, by exactly one line, and back to the left margin: |
| 505 | // PDF's y grows upward, so the second line's baseline is lower. |
| 506 | if gap := runs[0].y - runs[1].y; math.Abs(gap-export.BodySizePt*export.BodyLeading) > 0.01 { |
| 507 | t.Errorf("the second line is %v below the first, want one line of %v", |
| 508 | gap, export.BodySizePt*export.BodyLeading) |
| 509 | } |
| 510 | if math.Abs(runs[1].x-export.MarginPt) > 0.01 { |
| 511 | t.Errorf("the line after the break starts at x=%v, want the margin %v", runs[1].x, export.MarginPt) |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | // Two breaks in a row leave a blank line between the two halves, the |
| 516 | // way `<br><br>` does on screen — the second one has an empty line to |
| 517 | // end, and ending an empty line is what a blank line is. |
| 518 | func TestPDFDrawsABlankLineForConsecutiveHardBreaks(t *testing.T) { |
| 519 | js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| 520 | {"type":"text","text":"first"}, |
| 521 | {"type":"hard_break"}, |
| 522 | {"type":"hard_break"}, |
| 523 | {"type":"text","text":"second"} |
| 524 | ]}]}` |
| 525 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 526 | f := readPDF(t, data) |
| 527 | runs := f.runs(t, f.pages(t)[0]) |
| 528 | if len(runs) != 2 { |
| 529 | t.Fatalf("got %d drawn runs, want 2 (%v)", len(runs), runs) |
| 530 | } |
| 531 | want := 2 * export.BodySizePt * export.BodyLeading |
| 532 | if gap := runs[0].y - runs[1].y; math.Abs(gap-want) > 0.01 { |
| 533 | t.Errorf("the two halves are %v apart, want two lines (%v)", gap, want) |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | // --- paragraph alignment --- |
| 538 | |
| 539 | // alignmentLine is the three paragraphs these tests lay out: the same |
| 540 | // tokens three times, once per alignment, so every position below is |
| 541 | // read off the file rather than recomputed from pdf.go's own |
| 542 | // arithmetic. A test that restated the formula would agree with the |
| 543 | // code by construction and would have agreed with the wrong formula |
| 544 | // just as readily. |
| 545 | // |
| 546 | // Two identical words with a space between them is deliberate: it makes |
| 547 | // three drawn runs per line, and three runs are what let the left- |
| 548 | // aligned copy hand over the line's width without this file measuring a |
| 549 | // font. See lineWidthFrom. |
| 550 | const alignmentLine = `{"type":"text","text":"Widthful Widthful"}` |
| 551 | |
| 552 | // lineWidthFrom derives the drawn width of an alignmentLine from a |
| 553 | // LEFT-aligned copy of it, using nothing but the positions in the file. |
| 554 | // |
| 555 | // The three runs are the word, the space and the word again, each drawn |
| 556 | // at the x the one before it ended at. So the first gap is the word's |
| 557 | // width and the second is the space's, and the line is word + space + |
| 558 | // word wide. Nothing here knows what face or size that was set in, |
| 559 | // which is the point. |
| 560 | func lineWidthFrom(t *testing.T, line []run) float64 { |
| 561 | t.Helper() |
| 562 | if len(line) != 3 { |
| 563 | t.Fatalf("expected 3 runs (word, space, word), got %d: %v", len(line), line) |
| 564 | } |
| 565 | word := line[1].x - line[0].x |
| 566 | space := line[2].x - line[1].x |
| 567 | return word + space + word |
| 568 | } |
| 569 | |
| 570 | // Alignment in a PDF is arithmetic, not a property: a centred line has |
| 571 | // to be measured and offset by hand. This is the test for the thing |
| 572 | // that arithmetic is most likely to get wrong — offsetting against the |
| 573 | // PAGE rather than against the text column. |
| 574 | // |
| 575 | // The two are an inch apart on each side, so a page-relative |
| 576 | // implementation puts a right-aligned line's right edge an inch off the |
| 577 | // right of the paper and every word of it into (or past) the margin. |
| 578 | // Every assertion below is therefore about the content column: the box |
| 579 | // between the two margins, which is the box the screen wraps text in |
| 580 | // and the box `contentWidthPt` names. |
| 581 | func TestPDFAlignsWithinTheContentWidthNotThePage(t *testing.T) { |
| 582 | js := `{"type":"doc","content":[ |
| 583 | {"type":"paragraph","content":[` + alignmentLine + `]}, |
| 584 | {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, |
| 585 | {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} |
| 586 | ]}` |
| 587 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 588 | f := readPDF(t, data) |
| 589 | runs := f.runs(t, f.pages(t)[0]) |
| 590 | if len(runs) != 9 { |
| 591 | t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) |
| 592 | } |
| 593 | left, centre, right := runs[0:3], runs[3:6], runs[6:9] |
| 594 | |
| 595 | width := lineWidthFrom(t, left) |
| 596 | content := export.PageWidthPt - 2*export.MarginPt |
| 597 | if width <= 0 || width >= content { |
| 598 | t.Fatalf("the sample line measures %v against a %v content width — it must fit on one line, unwrapped, for the offsets below to mean anything", width, content) |
| 599 | } |
| 600 | |
| 601 | // Left is unchanged: the default alignment is where text already |
| 602 | // was, and the whole feature has to cost an unaligned paragraph |
| 603 | // nothing. |
| 604 | if math.Abs(left[0].x-export.MarginPt) > 0.01 { |
| 605 | t.Errorf("the left-aligned line starts at x=%v, want the page margin %v", left[0].x, export.MarginPt) |
| 606 | } |
| 607 | |
| 608 | // Centred: equal air on both sides of the text column. Stated as |
| 609 | // the two gaps rather than as a formula, because "the same distance |
| 610 | // from each margin" is what centred MEANS and a formula is only one |
| 611 | // way of arriving at it. |
| 612 | leftGap := centre[0].x - export.MarginPt |
| 613 | rightGap := (export.PageWidthPt - export.MarginPt) - (centre[0].x + width) |
| 614 | if math.Abs(leftGap-rightGap) > 0.01 { |
| 615 | t.Errorf("the centred line has %v of air on its left and %v on its right — centred against the page, not the text column?", leftGap, rightGap) |
| 616 | } |
| 617 | |
| 618 | // Right: the line's right edge sits exactly on the right margin. |
| 619 | // This is the assertion a page-relative implementation fails by a |
| 620 | // whole inch. |
| 621 | if edge := right[0].x + width; math.Abs(edge-(export.PageWidthPt-export.MarginPt)) > 0.01 { |
| 622 | t.Errorf("the right-aligned line ends at x=%v, want the right margin %v", edge, export.PageWidthPt-export.MarginPt) |
| 623 | } |
| 624 | |
| 625 | // And nothing ends up in either margin, on any of the three. |
| 626 | for _, r := range runs { |
| 627 | if r.x < export.MarginPt-0.01 { |
| 628 | t.Errorf("%q is drawn at x=%v, inside the %v left margin", r.text, r.x, export.MarginPt) |
| 629 | } |
| 630 | if r.x > export.PageWidthPt-export.MarginPt+0.01 { |
| 631 | t.Errorf("%q is drawn at x=%v, past the right margin %v", r.text, r.x, export.PageWidthPt-export.MarginPt) |
| 632 | } |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | // The column a line is centred in is the one it WRAPS in, which inside |
| 637 | // a blockquote or a list is narrower than the content width and starts |
| 638 | // further in. Getting this wrong is invisible at the top level and |
| 639 | // obvious the moment anything is indented: text centred against the |
| 640 | // full column inside a quote sits left of centre and can cross the |
| 641 | // quote's own rule. |
| 642 | // |
| 643 | // The indent is read off the file too — it is whatever the quote's |
| 644 | // left-aligned line was drawn at — so this stays a test of the |
| 645 | // relationship rather than a second copy of the constant. |
| 646 | func TestPDFAlignsInsideAQuoteAgainstTheIndentedColumn(t *testing.T) { |
| 647 | js := `{"type":"doc","content":[{"type":"blockquote","content":[ |
| 648 | {"type":"paragraph","content":[` + alignmentLine + `]}, |
| 649 | {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, |
| 650 | {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} |
| 651 | ]}]}` |
| 652 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 653 | f := readPDF(t, data) |
| 654 | runs := f.runs(t, f.pages(t)[0]) |
| 655 | if len(runs) != 9 { |
| 656 | t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) |
| 657 | } |
| 658 | left, centre, right := runs[0:3], runs[3:6], runs[6:9] |
| 659 | |
| 660 | width := lineWidthFrom(t, left) |
| 661 | indent := left[0].x - export.MarginPt |
| 662 | if indent <= 0 { |
| 663 | t.Fatalf("the quoted line starts at x=%v, which is not indented past the %v margin — this test can no longer see what it is checking", left[0].x, export.MarginPt) |
| 664 | } |
| 665 | // The column runs from the indented left edge to the right margin. |
| 666 | columnStart := export.MarginPt + indent |
| 667 | columnEnd := export.PageWidthPt - export.MarginPt |
| 668 | |
| 669 | leftGap := centre[0].x - columnStart |
| 670 | rightGap := columnEnd - (centre[0].x + width) |
| 671 | if math.Abs(leftGap-rightGap) > 0.01 { |
| 672 | t.Errorf("inside the quote the centred line has %v of air on its left and %v on its right — centred against the full content width rather than the indented column?", leftGap, rightGap) |
| 673 | } |
| 674 | if edge := right[0].x + width; math.Abs(edge-columnEnd) > 0.01 { |
| 675 | t.Errorf("inside the quote the right-aligned line ends at x=%v, want the right margin %v", edge, columnEnd) |
| 676 | } |
| 677 | if centre[0].x < columnStart-0.01 { |
| 678 | t.Errorf("the centred line starts at x=%v, left of the quote's own indent %v", centre[0].x, columnStart) |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | // The exporter is a second binary's worth of trust away from the core, |
| 683 | // exactly as headingLevel's second clamp is — the JSON has crossed the |
| 684 | // ABI by the time it gets here, and this package decodes whatever it is |
| 685 | // handed. An alignment it does not know is dropped, and a dropped |
| 686 | // alignment is left: the words land where they would have with no |
| 687 | // attribute at all. |
| 688 | func TestPDFDropsAnAlignmentItDoesNotKnow(t *testing.T) { |
| 689 | js := `{"type":"doc","content":[ |
| 690 | {"type":"paragraph","attrs":{"align":"justify"},"content":[` + alignmentLine + `]}, |
| 691 | {"type":"paragraph","attrs":{"align":"CENTER"},"content":[` + alignmentLine + `]}, |
| 692 | {"type":"paragraph","attrs":{"align":"left"},"content":[` + alignmentLine + `]} |
| 693 | ]}` |
| 694 | data, _ := mustPDF(t, mustParse(t, "", js)) |
| 695 | f := readPDF(t, data) |
| 696 | runs := f.runs(t, f.pages(t)[0]) |
| 697 | if len(runs) != 9 { |
| 698 | t.Fatalf("got %d drawn runs, want 9: %v", len(runs), runs) |
| 699 | } |
| 700 | for i, line := range [][]run{runs[0:3], runs[3:6], runs[6:9]} { |
| 701 | if math.Abs(line[0].x-export.MarginPt) > 0.01 { |
| 702 | t.Errorf("line %d starts at x=%v, want the page margin %v — an alignment this build does not know must lay out as left", i, line[0].x, export.MarginPt) |
| 703 | } |
| 704 | } |
| 705 | } |
| 706 | |