| 1 | package export_test |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "sort" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/docs/internal/export" |
| 9 | ) |
| 10 | |
| 11 | // The whole point of PageOne is that it is the SAME layout the PDF |
| 12 | // runs, not a second one that agrees with it by inspection. These |
| 13 | // tests hold it to that: the geometry it reports has to be geometry |
| 14 | // the exporter would really have drawn. |
| 15 | |
| 16 | func TestPageOneReportsALineForEachLineOfText(t *testing.T) { |
| 17 | js := `{"type":"doc","content":[ |
| 18 | {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"A heading"}]}, |
| 19 | {"type":"paragraph","content":[{"type":"text","text":"One line of body text."}]} |
| 20 | ]}` |
| 21 | // Three lines: the title (set as an h1 by PDF and PageOne alike), |
| 22 | // the heading, and the paragraph. |
| 23 | lines := export.PageOne(mustParse(t, "Notes", js)) |
| 24 | if len(lines) != 3 { |
| 25 | t.Fatalf("got %d lines, want 3: %+v", len(lines), lines) |
| 26 | } |
| 27 | for i, l := range lines { |
| 28 | if l.Height <= 0 || l.Width <= 0 { |
| 29 | t.Errorf("line %d has no extent: %+v", i, l) |
| 30 | } |
| 31 | if l.Top < export.MarginPt { |
| 32 | t.Errorf("line %d starts above the top margin: %+v", i, l) |
| 33 | } |
| 34 | if l.Left < export.MarginPt { |
| 35 | t.Errorf("line %d starts left of the margin: %+v", i, l) |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | // A document with no content at all still has its title, and nothing |
| 41 | // else — an empty document must not report a page full of phantom |
| 42 | // lines. |
| 43 | func TestPageOneOfAnEmptyDocumentIsJustItsTitle(t *testing.T) { |
| 44 | lines := export.PageOne(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`)) |
| 45 | if len(lines) != 1 { |
| 46 | t.Fatalf("got %d lines, want 1 (the title): %+v", len(lines), lines) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // An untitled, contentless document reports nothing, which is what |
| 51 | // lets the list render its empty box rather than a one-bar preview. |
| 52 | func TestPageOneOfATrulyEmptyDocumentIsEmpty(t *testing.T) { |
| 53 | lines := export.PageOne(mustParse(t, "", `{"type":"doc","content":[]}`)) |
| 54 | if len(lines) != 0 { |
| 55 | t.Fatalf("got %d lines, want none: %+v", len(lines), lines) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // The bound that makes this affordable on a list page: a document of |
| 60 | // any length costs one page of layout, not all of it. |
| 61 | func TestPageOneStopsAtTheEndOfTheFirstPage(t *testing.T) { |
| 62 | js := `{"type":"doc","content":[` |
| 63 | for i := 0; i < 400; i++ { |
| 64 | if i > 0 { |
| 65 | js += "," |
| 66 | } |
| 67 | js += `{"type":"paragraph","content":[{"type":"text","text":"A paragraph."}]}` |
| 68 | } |
| 69 | js += `]}` |
| 70 | |
| 71 | lines := export.PageOne(mustParse(t, "Long", js)) |
| 72 | if len(lines) == 0 { |
| 73 | t.Fatal("no lines at all") |
| 74 | } |
| 75 | // 648pt of content box at 16.8pt a line is 38-ish; the exact |
| 76 | // number is the layout's business, but 400 paragraphs must not |
| 77 | // all be here and none may sit below the bottom margin. |
| 78 | if len(lines) > 60 { |
| 79 | t.Errorf("got %d lines — that is more than one page's worth", len(lines)) |
| 80 | } |
| 81 | for i, l := range lines { |
| 82 | if l.Top+l.Height > export.PageHeightPt-export.MarginPt+0.01 { |
| 83 | t.Errorf("line %d runs past the bottom margin: %+v", i, l) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | // A heading's line reports a bigger size than a body line's. This is |
| 89 | // what lets a thumbnail draw a heading heavier without the drawing |
| 90 | // code having to know what a heading is. |
| 91 | func TestPageOneReportsTheSizeOfEachLine(t *testing.T) { |
| 92 | js := `{"type":"doc","content":[ |
| 93 | {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Big"}]}, |
| 94 | {"type":"paragraph","content":[{"type":"text","text":"Small"}]} |
| 95 | ]}` |
| 96 | lines := export.PageOne(mustParse(t, "", js)) |
| 97 | if len(lines) != 2 { |
| 98 | t.Fatalf("got %d lines, want 2: %+v", len(lines), lines) |
| 99 | } |
| 100 | if lines[0].Size <= lines[1].Size { |
| 101 | t.Errorf("heading size %v is not bigger than body size %v", lines[0].Size, lines[1].Size) |
| 102 | } |
| 103 | if lines[1].Size != export.BodySizePt { |
| 104 | t.Errorf("body line size %v, want %v", lines[1].Size, float64(export.BodySizePt)) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // A centred line is reported where it was actually placed, not at the |
| 109 | // margin — the thumbnail shows alignment because the layout already |
| 110 | // knows it. |
| 111 | func TestPageOneReportsAlignment(t *testing.T) { |
| 112 | js := `{"type":"doc","content":[ |
| 113 | {"type":"paragraph","content":[{"type":"text","text":"Left."}]}, |
| 114 | {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Middle."}]} |
| 115 | ]}` |
| 116 | lines := export.PageOne(mustParse(t, "", js)) |
| 117 | if len(lines) != 2 { |
| 118 | t.Fatalf("got %d lines, want 2: %+v", len(lines), lines) |
| 119 | } |
| 120 | if lines[0].Left != export.MarginPt { |
| 121 | t.Errorf("left-aligned line at %v, want the margin %v", lines[0].Left, float64(export.MarginPt)) |
| 122 | } |
| 123 | if lines[1].Left <= lines[0].Left { |
| 124 | t.Errorf("centred line at %v is not indented past the left-aligned one at %v", lines[1].Left, lines[0].Left) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // A test named TestPageOneDoesNotDisturbPDFExport stood here. It |
| 129 | // exported a document, ran PageOne, exported it again and compared the |
| 130 | // bytes — and it could not fail. PDF and PageOne build separate layout |
| 131 | // values sharing no state, so only a package-level global could ever |
| 132 | // have reddened it; a deliberate `l.cursor += 100` inside drawLine's |
| 133 | // sink block left it green while reddening the agreement test below. |
| 134 | // |
| 135 | // Deleted rather than repaired, because a check that cannot |
| 136 | // distinguish "no" from "I can't see it" is worse than no check: two |
| 137 | // comments and a commit message cited it as the thing holding the sink |
| 138 | // additive, which it never was. The exporter's own suite covers its |
| 139 | // output, and TestPageOneMatchesTheFirstPageOfThePDF covers the |
| 140 | // direction that can actually drift. |
| 141 | |
| 142 | // The test PageOne's own comment promises, and the one that makes "the |
| 143 | // same layout, not a second opinion" a mechanism rather than a claim: |
| 144 | // every line PageOne reports has to be a line the PDF exporter really |
| 145 | // drew, at the same place. |
| 146 | // |
| 147 | // It reads the PDF back rather than trusting the layout twice. A run's |
| 148 | // text matrix gives the baseline it was drawn at and the x it started |
| 149 | // from; PageOne reports the top of the line box and its left edge. The |
| 150 | // baseline must fall inside that box, and the left edges must be equal. |
| 151 | // |
| 152 | // Deliberately no list in the fixture: a list marker is drawn at an x |
| 153 | // to the LEFT of its item's text (in the list's own indent), so the |
| 154 | // leftmost run on that line is the marker rather than the line itself, |
| 155 | // and this test's "leftmost run starts where the line starts" would be |
| 156 | // comparing the wrong two numbers. That is a real gap, named rather |
| 157 | // than papered over — a list's own geometry is not held by anything |
| 158 | // here, and the pretext that another test covered it was false. |
| 159 | func TestPageOneMatchesTheFirstPageOfThePDF(t *testing.T) { |
| 160 | js := `{"type":"doc","content":[ |
| 161 | {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading that is long enough to be interesting"}]}, |
| 162 | {"type":"paragraph","content":[{"type":"text","text":"A paragraph long enough that it has to wrap onto a second line, which is the case where an estimator and a real layout would disagree about where the break falls."}]}, |
| 163 | {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Centred."}]}, |
| 164 | {"type":"paragraph","attrs":{"align":"right"},"content":[{"type":"text","text":"Right."}]} |
| 165 | ]}` |
| 166 | doc := mustParse(t, "Notes", js) |
| 167 | |
| 168 | lines := export.PageOne(doc) |
| 169 | data, _ := mustPDF(t, doc) |
| 170 | f := readPDF(t, data) |
| 171 | pages := f.pages(t) |
| 172 | if len(pages) != 1 { |
| 173 | t.Fatalf("fixture spilled onto %d pages; it is meant to fit on one", len(pages)) |
| 174 | } |
| 175 | |
| 176 | // Group the drawn runs into lines by baseline, keeping the leftmost |
| 177 | // x on each. |
| 178 | type drawn struct{ baseline, left float64 } |
| 179 | var byBaseline []drawn |
| 180 | for _, r := range f.runs(t, pages[0]) { |
| 181 | baseline := float64(export.PageHeightPt) - r.y |
| 182 | found := false |
| 183 | for i := range byBaseline { |
| 184 | if math.Abs(byBaseline[i].baseline-baseline) < 0.01 { |
| 185 | if r.x < byBaseline[i].left { |
| 186 | byBaseline[i].left = r.x |
| 187 | } |
| 188 | found = true |
| 189 | break |
| 190 | } |
| 191 | } |
| 192 | if !found { |
| 193 | byBaseline = append(byBaseline, drawn{baseline: baseline, left: r.x}) |
| 194 | } |
| 195 | } |
| 196 | sort.Slice(byBaseline, func(i, j int) bool { return byBaseline[i].baseline < byBaseline[j].baseline }) |
| 197 | |
| 198 | if len(byBaseline) != len(lines) { |
| 199 | t.Fatalf("PageOne reports %d lines, the PDF drew %d", len(lines), len(byBaseline)) |
| 200 | } |
| 201 | for i, d := range byBaseline { |
| 202 | l := lines[i] |
| 203 | if d.baseline < l.Top || d.baseline > l.Top+l.Height { |
| 204 | t.Errorf("line %d: baseline %.2f is outside the reported box [%.2f, %.2f]", |
| 205 | i, d.baseline, l.Top, l.Top+l.Height) |
| 206 | } |
| 207 | if math.Abs(d.left-l.Left) > 0.01 { |
| 208 | t.Errorf("line %d: the PDF drew it at x=%.2f, PageOne reports Left=%.2f", i, d.left, l.Left) |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | |