package export_test import ( "bytes" "fmt" "math" "regexp" "sort" "strconv" "strings" "testing" "github.com/carlosframework/docs/internal/export" ) func mustPDF(t *testing.T, doc export.Document) ([]byte, export.Report) { t.Helper() out, report, err := export.PDF(doc) if err != nil { t.Fatalf("PDF: %v", err) } return out, report } // The one that matters: the words a person typed have to be the words // they get when they select the exported PDF and copy it. Everything // between — glyph ids, the subset, the CMap — is machinery in service // of this. func TestPDFTextCanBeCopiedBackOut(t *testing.T) { js := `{"type":"doc","content":[ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading"}]}, {"type":"paragraph","content":[ {"type":"text","text":"Plain then "}, {"type":"text","text":"bold","marks":[{"type":"strong"}]}, {"type":"text","text":" and a caf"}, {"type":"text","text":"é."} ]} ]}` data, report := mustPDF(t, mustParse(t, "Notes", js)) if !report.Empty() { t.Errorf("unexpected substitutions: %s", report) } f := readPDF(t, data) pages := f.pages(t) if len(pages) != 1 { t.Fatalf("got %d pages, want 1", len(pages)) } got := f.text(t, pages[0]) for _, want := range []string{"Notes", "A heading", "Plain then", "bold", "café."} { if !strings.Contains(got, want) { t.Errorf("copied text is missing %q; got %q", want, got) } } } // Bold and italic are separate font programs, not a synthesised slant, // so a document using both must embed both and a page must declare // both in its resources. func TestPDFEmbedsAFaceForEachStyleUsed(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"regular "}, {"type":"text","text":"bold ","marks":[{"type":"strong"}]}, {"type":"text","text":"italic ","marks":[{"type":"em"}]}, {"type":"text","text":"mono","marks":[{"type":"font_mono"}]} ]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) var descriptors, files int for _, o := range f.objects { if strings.Contains(o.dict, "/Type /FontDescriptor") { descriptors++ if !strings.Contains(o.dict, "/FontFile2") { t.Error("a font descriptor with no embedded font file") } } if strings.Contains(o.dict, "/Length1") { files++ } } // Regular, bold, italic and mono: four distinct faces. (The title // is empty here, so nothing pulls in a bold serif of its own.) if descriptors != 4 { t.Errorf("got %d font descriptors, want 4 (regular, bold, italic, mono)", descriptors) } if files != descriptors { t.Errorf("got %d embedded font files for %d descriptors", files, descriptors) } } // The face a run is actually drawn in, read back off the paper. // // Everything else about the default face is checked one step short of // the PDF: internal/docs's TestPageGeometryMatchesTheStylesheet compares // the name styleFor resolves to against the stylesheet, which proves the // two agree but not that the agreed face is what gets embedded and // selected. This reads /BaseFont out of the file, which is the last // place a mistake could still be hiding. // // Both halves matter and they fail in opposite directions. An unmarked // run must be the DEFAULT face — that is most of the text in most // documents, and it is what moved. A font_serif run must be serif // whatever the default is — that is the mark existing at all, and if // styleFor did not handle it the run would fall through to the default // and this test would see one face where it wants two. func TestPDFDrawsTheDefaultFaceAndTheSerifMark(t *testing.T) { js := `{"type":"doc","content":[ {"type":"paragraph","content":[{"type":"text","text":"Unmarked"}]}, {"type":"paragraph","content":[{"type":"text","text":"Serif","marks":[{"type":"font_serif"}]}]} ]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) faces := map[string]bool{} for _, o := range f.objects { for _, m := range regexp.MustCompile(`/BaseFont /[A-Z]{6}\+([^ />]+)`).FindAllStringSubmatch(o.dict, -1) { faces[m[1]] = true } } if len(faces) == 0 { 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") } if !faces["LiberationSans-Regular"] { t.Errorf("an unmarked run is not drawn in Liberation Sans; the PDF uses %v", sortedKeys(faces)) } if !faces["LiberationSerif-Regular"] { t.Errorf("a font_serif run is not drawn in Liberation Serif; the PDF uses %v", sortedKeys(faces)) } } func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // The embedded font must be a subset, not the whole face — an export // that embeds four full faces is a megabyte and a half of font for a // page of text. func TestPDFEmbedsSubsetsNotWholeFaces(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) found := false for _, o := range f.objects { if !strings.Contains(o.dict, "/Length1") { continue } found = true // Liberation Serif is about 385KB whole. A subset holding the // glyphs of "Hello" plus loca and hmtx is far below a tenth of // that; the threshold is deliberately loose so this fails on a // subsetter that stopped subsetting, not on one that got a bit // less efficient. if len(o.stream) > 100_000 { t.Errorf("embedded font is %d bytes — that is not a subset", len(o.stream)) } if len(o.stream) < 1_000 { t.Errorf("embedded font is %d bytes — that is too small to be a font", len(o.stream)) } } if !found { t.Fatal("no embedded font file in the PDF") } } // Text longer than a page has to become more pages, and no text may be // lost at the break. func TestPDFPaginatesLongDocuments(t *testing.T) { var paras []string for i := 0; i < 120; i++ { paras = append(paras, `{"type":"paragraph","content":[{"type":"text","text":"Paragraph number `+ strings.Repeat("x", 3)+` of a document long enough to need more than one page."}]}`) } js := `{"type":"doc","content":[` + strings.Join(paras, ",") + `]}` data, _ := mustPDF(t, mustParse(t, "Long", js)) f := readPDF(t, data) pages := f.pages(t) if len(pages) < 3 { t.Fatalf("120 paragraphs produced %d pages", len(pages)) } var all strings.Builder for _, p := range pages { all.WriteString(f.text(t, p)) } if n := strings.Count(all.String(), "Paragraph number"); n != 120 { t.Errorf("found %d paragraphs across %d pages, want 120", n, len(pages)) } } // A word wider than the text column must be broken rather than run off // the edge of the paper — a URL is the ordinary case. func TestPDFBreaksAWordTooLongForTheLine(t *testing.T) { long := strings.Repeat("supercalifragilistic", 12) js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"` + long + `"}]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) got := f.text(t, f.pages(t)[0]) if len(got) < len(long) { t.Errorf("the long word lost characters: %d of %d survived", len(got), len(long)) } } // A character no core face has a glyph for must be reported, not // silently dropped — §11.2's degrade-loudly rule, and the only form of // infidelity this exporter can currently produce. func TestPDFReportsCharactersItHasNoGlyphFor(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello 漢字"}]}]}` _, report := mustPDF(t, mustParse(t, "", js)) if report.Empty() { t.Fatal("CJK is outside the core font set (§11.1) and must be reported as a substitution") } if !strings.Contains(report.String(), "漢") { t.Errorf("report does not name the character: %s", report) } } // The same document exported twice must produce the same bytes. Go's // map iteration is randomised, and a PDF whose object order varies run // to run cannot be diffed, cached or compared by anyone. func TestPDFIsDeterministic(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"one "}, {"type":"text","text":"two","marks":[{"type":"strong"}]}, {"type":"text","text":" three","marks":[{"type":"font_sans"}]} ]}]}` doc := mustParse(t, "Same", js) a, _ := mustPDF(t, doc) b, _ := mustPDF(t, doc) // bytes.Equal, NOT strings.EqualFold. This test compared with // EqualFold and could not see what it claimed to: a PDF is mostly // Flate-compressed, which is mostly invalid UTF-8, and every // invalid byte decodes to U+FFFD on both sides — so two files whose // compressed bodies differed byte-for-byte compared equal, in the // test whose whole subject is bytes. The check answered "yes" // because it could not see, which is the failure shape AGENTS.md // names, in the one test written to rule it out. if !bytes.Equal(a, b) { t.Error("two exports of the same document produced different bytes") } } // A run carrying two size marks at once — which ProseMirror's schema // forbids but a client posting raw Yjs bytes can still produce — must // resolve to the SAME size every time. styleFor picked one by ranging // a map and breaking, and Go randomises map order, so the same document // exported twice could differ. The test above cannot catch it (the // fixture has no such run), which is why this is its own case. func TestPDFIsDeterministicForARunWithTwoSizeMarks(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"ambiguous","marks":[{"type":"size_sm"},{"type":"size_xl"}]} ]}]}` doc := mustParse(t, "Ambiguous", js) first, _ := mustPDF(t, doc) for i := 0; i < 8; i++ { again, _ := mustPDF(t, doc) if !bytes.Equal(first, again) { t.Fatalf("export %d differed: a run with two size marks resolves nondeterministically", i+2) } } } // Every indirect reference in the file must resolve, and the trailer // must agree with the cross-reference table about how many objects // there are. // // This is where the honesty of this package's PDF testing sits. No // third-party reader has opened these files — none is available in // this build — so the reader in pdfread_test.go is both the writer's // only checker and something written by the same hand, which is // exactly the arrangement that can be consistently wrong. What this // test adds is the set of properties that do not depend on agreeing // with that reader: a dangling reference, an off-by-one /Size, or an // xref offset that points at the wrong object are the three ways a // hand-written PDF writer breaks a real reader, and none of them can // be true here without failing. func TestPDFStructureIsInternallyConsistent(t *testing.T) { js := `{"type":"doc","content":[ {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"H"}]}, {"type":"paragraph","content":[{"type":"text","text":"body","marks":[{"type":"strong"}]}]}, {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"q"}]}]} ]}` data, _ := mustPDF(t, mustParse(t, "Structure", js)) // readPDF already follows every xref offset and fails if one does // not land on the object it claims. f := readPDF(t, data) refRe := regexp.MustCompile(`(\d+) 0 R`) for num, o := range f.objects { for _, m := range refRe.FindAllStringSubmatch(o.dict, -1) { target, _ := strconv.Atoi(m[1]) if _, ok := f.objects[target]; !ok { t.Errorf("object %d references object %d, which does not exist", num, target) } } } var size int if _, err := fmt.Sscanf( string(data[bytes.LastIndex(data, []byte("trailer")):]), "trailer\n<< /Size %d", &size); err != nil { t.Fatalf("unreadable trailer: %v", err) } if want := len(f.objects) + 1; size != want { t.Errorf("trailer says /Size %d, the xref lists %d objects plus the free head", size, want) } } func TestPDFCarriesTheTitleAsMetadata(t *testing.T) { data, _ := mustPDF(t, mustParse(t, "Café notes", `{"type":"doc","content":[]}`)) f := readPDF(t, data) found := false for _, o := range f.objects { if strings.Contains(o.dict, "/Producer") { found = true // UTF-16BE with a BOM: "C" is 0043. if !strings.Contains(o.dict, "/Title 0.01 { t.Errorf("body text starts at x=%v, want the page margin %v", runs[0].x, export.MarginPt) } rules := f.rules(t, page) if len(rules) != 1 { t.Fatalf("got %d filled rectangles, want 1 (the blockquote's edge)", len(rules)) } if math.Abs(rules[0].x-export.MarginPt) > 0.01 { t.Errorf("the blockquote rule starts at x=%v, want the page margin %v", rules[0].x, export.MarginPt) } // Nothing at all may be drawn in the margin — including the bullet, // which sits left of its own text and is the closest thing to the // edge on the page. for _, r := range runs { if r.x < export.MarginPt-0.01 { t.Errorf("%q is drawn at x=%v, inside the %v page margin", r.text, r.x, export.MarginPt) } } } // Shift+Enter's line break has to end the line in the PDF too — and // end only the line, not the block. // // This reads the positions rather than the text for the same reason // the margin test does: a dropped break loses no words at all, it just // runs two lines together, so a text-only assertion sees nothing wrong. // The two halves of the paragraph have to sit on different baselines // and both start at the left margin; the break must not indent, split // or paginate anything. func TestPDFEndsTheLineAtAHardBreak(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"first"}, {"type":"hard_break"}, {"type":"text","text":"second"} ]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) runs := f.runs(t, f.pages(t)[0]) if len(runs) != 2 { t.Fatalf("got %d drawn runs, want 2 (%v)", len(runs), runs) } if runs[0].text != "first" || runs[1].text != "second" { t.Fatalf("drew %q then %q, want %q then %q", runs[0].text, runs[1].text, "first", "second") } if runs[0].y == runs[1].y { t.Errorf("both halves are on the same baseline (y=%v) — the break was dropped", runs[0].y) } // Down the page, by exactly one line, and back to the left margin: // PDF's y grows upward, so the second line's baseline is lower. if gap := runs[0].y - runs[1].y; math.Abs(gap-export.BodySizePt*export.BodyLeading) > 0.01 { t.Errorf("the second line is %v below the first, want one line of %v", gap, export.BodySizePt*export.BodyLeading) } if math.Abs(runs[1].x-export.MarginPt) > 0.01 { t.Errorf("the line after the break starts at x=%v, want the margin %v", runs[1].x, export.MarginPt) } } // Two breaks in a row leave a blank line between the two halves, the // way `

` does on screen — the second one has an empty line to // end, and ending an empty line is what a blank line is. func TestPDFDrawsABlankLineForConsecutiveHardBreaks(t *testing.T) { js := `{"type":"doc","content":[{"type":"paragraph","content":[ {"type":"text","text":"first"}, {"type":"hard_break"}, {"type":"hard_break"}, {"type":"text","text":"second"} ]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) runs := f.runs(t, f.pages(t)[0]) if len(runs) != 2 { t.Fatalf("got %d drawn runs, want 2 (%v)", len(runs), runs) } want := 2 * export.BodySizePt * export.BodyLeading if gap := runs[0].y - runs[1].y; math.Abs(gap-want) > 0.01 { t.Errorf("the two halves are %v apart, want two lines (%v)", gap, want) } } // --- paragraph alignment --- // alignmentLine is the three paragraphs these tests lay out: the same // tokens three times, once per alignment, so every position below is // read off the file rather than recomputed from pdf.go's own // arithmetic. A test that restated the formula would agree with the // code by construction and would have agreed with the wrong formula // just as readily. // // Two identical words with a space between them is deliberate: it makes // three drawn runs per line, and three runs are what let the left- // aligned copy hand over the line's width without this file measuring a // font. See lineWidthFrom. const alignmentLine = `{"type":"text","text":"Widthful Widthful"}` // lineWidthFrom derives the drawn width of an alignmentLine from a // LEFT-aligned copy of it, using nothing but the positions in the file. // // The three runs are the word, the space and the word again, each drawn // at the x the one before it ended at. So the first gap is the word's // width and the second is the space's, and the line is word + space + // word wide. Nothing here knows what face or size that was set in, // which is the point. func lineWidthFrom(t *testing.T, line []run) float64 { t.Helper() if len(line) != 3 { t.Fatalf("expected 3 runs (word, space, word), got %d: %v", len(line), line) } word := line[1].x - line[0].x space := line[2].x - line[1].x return word + space + word } // Alignment in a PDF is arithmetic, not a property: a centred line has // to be measured and offset by hand. This is the test for the thing // that arithmetic is most likely to get wrong — offsetting against the // PAGE rather than against the text column. // // The two are an inch apart on each side, so a page-relative // implementation puts a right-aligned line's right edge an inch off the // right of the paper and every word of it into (or past) the margin. // Every assertion below is therefore about the content column: the box // between the two margins, which is the box the screen wraps text in // and the box `contentWidthPt` names. func TestPDFAlignsWithinTheContentWidthNotThePage(t *testing.T) { js := `{"type":"doc","content":[ {"type":"paragraph","content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} ]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) runs := f.runs(t, f.pages(t)[0]) if len(runs) != 9 { t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) } left, centre, right := runs[0:3], runs[3:6], runs[6:9] width := lineWidthFrom(t, left) content := export.PageWidthPt - 2*export.MarginPt if width <= 0 || width >= content { 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) } // Left is unchanged: the default alignment is where text already // was, and the whole feature has to cost an unaligned paragraph // nothing. if math.Abs(left[0].x-export.MarginPt) > 0.01 { t.Errorf("the left-aligned line starts at x=%v, want the page margin %v", left[0].x, export.MarginPt) } // Centred: equal air on both sides of the text column. Stated as // the two gaps rather than as a formula, because "the same distance // from each margin" is what centred MEANS and a formula is only one // way of arriving at it. leftGap := centre[0].x - export.MarginPt rightGap := (export.PageWidthPt - export.MarginPt) - (centre[0].x + width) if math.Abs(leftGap-rightGap) > 0.01 { 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) } // Right: the line's right edge sits exactly on the right margin. // This is the assertion a page-relative implementation fails by a // whole inch. if edge := right[0].x + width; math.Abs(edge-(export.PageWidthPt-export.MarginPt)) > 0.01 { t.Errorf("the right-aligned line ends at x=%v, want the right margin %v", edge, export.PageWidthPt-export.MarginPt) } // And nothing ends up in either margin, on any of the three. for _, r := range runs { if r.x < export.MarginPt-0.01 { t.Errorf("%q is drawn at x=%v, inside the %v left margin", r.text, r.x, export.MarginPt) } if r.x > export.PageWidthPt-export.MarginPt+0.01 { t.Errorf("%q is drawn at x=%v, past the right margin %v", r.text, r.x, export.PageWidthPt-export.MarginPt) } } } // The column a line is centred in is the one it WRAPS in, which inside // a blockquote or a list is narrower than the content width and starts // further in. Getting this wrong is invisible at the top level and // obvious the moment anything is indented: text centred against the // full column inside a quote sits left of centre and can cross the // quote's own rule. // // The indent is read off the file too — it is whatever the quote's // left-aligned line was drawn at — so this stays a test of the // relationship rather than a second copy of the constant. func TestPDFAlignsInsideAQuoteAgainstTheIndentedColumn(t *testing.T) { js := `{"type":"doc","content":[{"type":"blockquote","content":[ {"type":"paragraph","content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"center"},"content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"right"},"content":[` + alignmentLine + `]} ]}]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) runs := f.runs(t, f.pages(t)[0]) if len(runs) != 9 { t.Fatalf("got %d drawn runs, want 9 (three lines of three): %v", len(runs), runs) } left, centre, right := runs[0:3], runs[3:6], runs[6:9] width := lineWidthFrom(t, left) indent := left[0].x - export.MarginPt if indent <= 0 { 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) } // The column runs from the indented left edge to the right margin. columnStart := export.MarginPt + indent columnEnd := export.PageWidthPt - export.MarginPt leftGap := centre[0].x - columnStart rightGap := columnEnd - (centre[0].x + width) if math.Abs(leftGap-rightGap) > 0.01 { 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) } if edge := right[0].x + width; math.Abs(edge-columnEnd) > 0.01 { t.Errorf("inside the quote the right-aligned line ends at x=%v, want the right margin %v", edge, columnEnd) } if centre[0].x < columnStart-0.01 { t.Errorf("the centred line starts at x=%v, left of the quote's own indent %v", centre[0].x, columnStart) } } // The exporter is a second binary's worth of trust away from the core, // exactly as headingLevel's second clamp is — the JSON has crossed the // ABI by the time it gets here, and this package decodes whatever it is // handed. An alignment it does not know is dropped, and a dropped // alignment is left: the words land where they would have with no // attribute at all. func TestPDFDropsAnAlignmentItDoesNotKnow(t *testing.T) { js := `{"type":"doc","content":[ {"type":"paragraph","attrs":{"align":"justify"},"content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"CENTER"},"content":[` + alignmentLine + `]}, {"type":"paragraph","attrs":{"align":"left"},"content":[` + alignmentLine + `]} ]}` data, _ := mustPDF(t, mustParse(t, "", js)) f := readPDF(t, data) runs := f.runs(t, f.pages(t)[0]) if len(runs) != 9 { t.Fatalf("got %d drawn runs, want 9: %v", len(runs), runs) } for i, line := range [][]run{runs[0:3], runs[3:6], runs[6:9]} { if math.Abs(line[0].x-export.MarginPt) > 0.01 { 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) } } }