oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package export_test
2
3import (
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
16func 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.
29func 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.
61func 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.
108func 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
133func 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.
145func 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.
175func 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.
201func 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.
215func 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.
229func 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.
257func 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.
285func 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
317func 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.
344func 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.
374func 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.
387func 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.
398func 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.
434func 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.
486func 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.
518func 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.
550const 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.
560func 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.
581func 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.
646func 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.
688func 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