package webui import ( "bytes" "fmt" "strconv" "strings" "testing" ) // ---- a structural validator for the PDFs we emit ------------------------- // // The point of these tests is the cross-reference table. An xref whose offsets do // not land exactly on the objects they claim to is the classic way a hand-rolled // PDF writer breaks: the file still "looks fine" (it starts with %PDF-, it ends // with %%EOF, it is full of plausible text) and every reader rejects it. So the // validator below walks the xref the way a reader does — arithmetically, 20 bytes // per entry — and insists each offset points at " 0 obj". type pdfInfo struct { objects int // objects declared by the xref (excluding the free head) pageCount int // /Count in the Pages node pageObjs int // page objects actually present xrefAt int } func checkPDF(data []byte) (pdfInfo, error) { var info pdfInfo if !bytes.HasPrefix(data, []byte("%PDF-")) { return info, fmt.Errorf("does not start with %%PDF-") } if !bytes.HasSuffix(bytes.TrimRight(data, "\r\n"), []byte("%%EOF")) { return info, fmt.Errorf("does not end with %%%%EOF") } // startxref -> the byte offset of the xref table. i := bytes.LastIndex(data, []byte("startxref")) if i < 0 { return info, fmt.Errorf("no startxref") } fields := strings.Fields(string(data[i+len("startxref"):])) if len(fields) == 0 { return info, fmt.Errorf("startxref has no offset") } xrefAt, err := strconv.Atoi(fields[0]) if err != nil { return info, fmt.Errorf("startxref offset %q: %v", fields[0], err) } if xrefAt < 0 || xrefAt >= len(data) { return info, fmt.Errorf("startxref offset %d out of range (len %d)", xrefAt, len(data)) } info.xrefAt = xrefAt p := xrefAt if !bytes.HasPrefix(data[p:], []byte("xref\n")) { return info, fmt.Errorf("startxref does not point at an xref table (found %q)", peek(data, p)) } p += len("xref\n") // Subsection header: "0 ". nl := bytes.IndexByte(data[p:], '\n') if nl < 0 { return info, fmt.Errorf("truncated xref subsection header") } head := strings.Fields(string(data[p : p+nl])) p += nl + 1 if len(head) != 2 || head[0] != "0" { return info, fmt.Errorf("unexpected xref subsection header %q", head) } size, err := strconv.Atoi(head[1]) if err != nil { return info, fmt.Errorf("xref size %q: %v", head[1], err) } info.objects = size - 1 if p+20*size > len(data) { return info, fmt.Errorf("xref table is truncated: needs %d bytes, %d left", 20*size, len(data)-p) } // Entry 0 is the head of the free list, and is required to look exactly so. if got := string(data[p : p+20]); got != "0000000000 65535 f \n" { return info, fmt.Errorf("bad free entry %q", got) } for n := 1; n < size; n++ { entry := string(data[p+20*n : p+20*(n+1)]) if len(entry) != 20 || entry[10] != ' ' || entry[17] != 'n' { return info, fmt.Errorf("object %d: malformed xref entry %q", n, entry) } off, err := strconv.Atoi(entry[:10]) if err != nil { return info, fmt.Errorf("object %d: bad offset %q", n, entry[:10]) } if off <= 0 || off >= len(data) { return info, fmt.Errorf("object %d: offset %d out of range (len %d)", n, off, len(data)) } want := []byte(strconv.Itoa(n) + " 0 obj") if !bytes.HasPrefix(data[off:], want) { return info, fmt.Errorf("object %d: xref offset %d points at %q, not %q", n, off, peek(data, off), want) } } p += 20 * size if !bytes.HasPrefix(data[p:], []byte("trailer")) { return info, fmt.Errorf("no trailer after the xref table (found %q)", peek(data, p)) } if !bytes.Contains(data[p:], []byte("/Size "+strconv.Itoa(size))) { return info, fmt.Errorf("trailer /Size disagrees with the xref subsection (%d)", size) } if !bytes.Contains(data[p:], []byte("/Root 1 0 R")) { return info, fmt.Errorf("trailer has no /Root") } // Every object the xref promises must actually be there. for n := 1; n <= info.objects; n++ { if !bytes.Contains(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n")) { return info, fmt.Errorf("object %d is declared but absent", n) } } info.pageObjs = bytes.Count(data, []byte("/Type /Page /Parent")) if i := bytes.Index(data, []byte("/Type /Pages /Kids")); i >= 0 { if c := bytes.Index(data[i:], []byte("/Count ")); c >= 0 { f := strings.Fields(string(data[i+c+len("/Count "):])) if len(f) > 0 { info.pageCount, _ = strconv.Atoi(strings.TrimRight(f[0], ">")) } } } else { return info, fmt.Errorf("no /Pages node") } if info.pageCount != info.pageObjs { return info, fmt.Errorf("/Count says %d pages, but %d page objects exist", info.pageCount, info.pageObjs) } // catalog + pages + 2 fonts + (page, content) per page. if want := 4 + 2*info.pageObjs; want != info.objects { return info, fmt.Errorf("object count is %d, want %d for %d pages", info.objects, want, info.pageObjs) } return info, nil } func peek(data []byte, at int) string { end := min(at+24, len(data)) return string(data[at:end]) } func mustCheckPDF(t *testing.T, data []byte) pdfInfo { t.Helper() info, err := checkPDF(data) if err != nil { t.Fatalf("invalid PDF: %v", err) } return info } // ---- the writer ---------------------------------------------------------- func TestPDFStructure(t *testing.T) { p := NewPDF(PDFOptions{}) for i := range 3 { p.AddPage() p.Text(40, 500, "Page "+strconv.Itoa(i), PDFTextStyle{Size: 10, Color: PDFGray(0.1)}) p.Line(40, 480, 500, 480, 1, PDFGray(0.8)) p.Rect(40, 400, 200, 40, PDFGray(0.95)) } data := p.Bytes() info := mustCheckPDF(t, data) if info.pageObjs != 3 { t.Fatalf("page objects = %d, want 3", info.pageObjs) } if info.objects != 10 { // 4 fixed + 2 per page t.Fatalf("objects = %d, want 10", info.objects) } } // The validator is only worth anything if it actually fails on a broken xref. // Shifting every object by a byte (without rewriting the table) is precisely the // bug a naive writer ships. func TestCheckPDFCatchesABrokenXref(t *testing.T) { p := NewPDF(PDFOptions{}) p.AddPage() p.Text(40, 40, "hi", PDFTextStyle{Size: 10}) good := p.Bytes() if _, err := checkPDF(good); err != nil { t.Fatalf("the good document must pass first: %v", err) } // Insert a byte after the file header: every object now sits one byte later // than the xref claims. broken := append([]byte{}, good[:9]...) broken = append(broken, '\n') broken = append(broken, good[9:]...) if _, err := checkPDF(broken); err == nil { t.Fatal("a document whose objects have all shifted by a byte must NOT validate") } } func TestPDFEmptyDocumentStillHasAPage(t *testing.T) { // A PDF with zero pages is not a valid PDF, so Bytes() must not emit one. info := mustCheckPDF(t, NewPDF(PDFOptions{}).Bytes()) if info.pageObjs != 1 { t.Fatalf("page objects = %d, want 1", info.pageObjs) } } func TestPDFOrientation(t *testing.T) { land := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_LANDSCAPE}) if land.Width() != 792 || land.Height() != 612 { t.Fatalf("landscape = %vx%v, want 792x612", land.Width(), land.Height()) } port := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_PORTRAIT}) if port.Width() != 612 || port.Height() != 792 { t.Fatalf("portrait = %vx%v, want 612x792", port.Width(), port.Height()) } land.AddPage() if !bytes.Contains(land.Bytes(), []byte("/MediaBox [0 0 792 612]")) { t.Error("landscape MediaBox is wrong") } port.AddPage() if !bytes.Contains(port.Bytes(), []byte("/MediaBox [0 0 612 792]")) { t.Error("portrait MediaBox is wrong") } } func TestPDFSetPageDrawsOnAnEarlierPage(t *testing.T) { // This is what the "Page 1 of 7" footer needs: you cannot know the total until // every page exists, so the footer is stamped on afterwards. p := NewPDF(PDFOptions{}) p.AddPage() p.AddPage() p.SetPage(0) p.Text(40, 20, "footer-marker", PDFTextStyle{Size: 8}) p.SetPage(99) // out of range: a no-op, not a panic data := p.Bytes() mustCheckPDF(t, data) if n := bytes.Count(data, []byte("(footer-marker)")); n != 1 { t.Fatalf("footer text appears %d times, want 1", n) } // It has to be in the FIRST page's content stream: object 6 (page 0's content), // not object 8. first := objectBody(t, data, 6) if !bytes.Contains(first, []byte("(footer-marker)")) { t.Error("SetPage(0) did not draw on the first page") } } // objectBody returns the bytes of object n, from its header to "endobj". func objectBody(t *testing.T, data []byte, n int) []byte { t.Helper() start := bytes.Index(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n")) if start < 0 { t.Fatalf("object %d not found", n) } end := bytes.Index(data[start:], []byte("endobj")) if end < 0 { t.Fatalf("object %d has no endobj", n) } return data[start : start+end] } func TestPDFStringEscaping(t *testing.T) { p := NewPDF(PDFOptions{}) p.AddPage() p.Text(10, 10, `a(b)c\d`, PDFTextStyle{Size: 8}) p.Text(10, 20, "café ¥", PDFTextStyle{Size: 8}) p.Text(10, 30, "漢字", PDFTextStyle{Size: 8}) data := p.Bytes() mustCheckPDF(t, data) body := string(objectBody(t, data, 6)) if !strings.Contains(body, `(a\(b\)c\\d)`) { t.Errorf("delimiters/backslash not escaped:\n%s", body) } // é is WinAnsi 0xE9 = octal 351, ¥ is 0xA5 = octal 245. if !strings.Contains(body, `(caf\351 \245)`) { t.Errorf("high bytes not octal-escaped:\n%s", body) } // The standard fonts have no CJK glyph; each rune degrades to '?' rather than // silently vanishing (which would misalign the column). if !strings.Contains(body, "(??)") { t.Errorf("unmappable runes not replaced:\n%s", body) } } func TestPDFNumberFormatting(t *testing.T) { // PDF has no exponent notation: a coordinate like 1e-07 is a syntax error. for _, tc := range []struct { in float64 want string }{ {0, "0"}, {0.0000001, "0"}, {1, "1"}, {-0.5, "-0.5"}, {123.456789, "123.457"}, {792, "792"}, } { if got := pdfNum(tc.in); got != tc.want { t.Errorf("pdfNum(%v) = %q, want %q", tc.in, got, tc.want) } } } // ---- the Helvetica width tables ------------------------------------------ // A typo anywhere in the width table silently misaligns every column that uses // the glyph, so pin the values that are easy to get wrong: the two fonts differ, // 'i' is narrow and 'W' is wide, and under WinAnsi code 39 is quotesingle (191), // NOT quoteright (222). func TestHelveticaWidths(t *testing.T) { // Measured at size 1000, a width in points equals the AFM value directly. cases := []struct { s string bold bool want float64 }{ {" ", false, 278}, {"A", false, 667}, {"W", false, 944}, {"i", false, 222}, {"l", false, 222}, {"m", false, 833}, {"0", false, 556}, {"9", false, 556}, {"$", false, 556}, {"@", false, 1015}, {"'", false, 191}, // quotesingle under WinAnsi {"`", false, 333}, // grave under WinAnsi {"…", false, 1000}, // the truncation marker {"é", false, 556}, // composite: the width of 'e' {"€", false, 556}, {" ", true, 278}, {"A", true, 722}, {"W", true, 944}, {"i", true, 278}, {"m", true, 889}, {"0", true, 556}, {"'", true, 238}, {"z", true, 500}, } for _, c := range cases { if got := PDFTextWidth(c.s, 1000, c.bold); got != c.want { t.Errorf("PDFTextWidth(%q, bold=%v) = %v, want %v", c.s, c.bold, got, c.want) } } // Widths scale linearly with the font size, and add up across a string. if got, want := PDFTextWidth("AW", 10, false), (667.0+944.0)*10/1000; got != want { t.Errorf("PDFTextWidth(\"AW\", 10) = %v, want %v", got, want) } if got := PDFTextWidth("", 10, false); got != 0 { t.Errorf("the empty string measures %v, want 0", got) } } func TestPDFTruncate(t *testing.T) { const size = 8 long := "Supercalifragilisticexpialidocious" full := PDFTextWidth(long, size, false) // It fits: untouched. if got := PDFTruncate(long, full, size, false); got != long { t.Errorf("a string that fits was truncated: %q", got) } // It does not: truncated, ellipsized, and — the part that matters — the RESULT // actually fits, ellipsis included. got := PDFTruncate(long, full/2, size, false) if !strings.HasSuffix(got, "…") { t.Errorf("truncated %q has no ellipsis", got) } if w := PDFTextWidth(got, size, false); w > full/2 { t.Errorf("truncated %q measures %v, over the %v limit", got, w, full/2) } if len([]rune(got)) >= len([]rune(long)) { t.Errorf("truncated %q is not shorter than the original", got) } // Narrower than the ellipsis itself: nothing can be drawn, and saying so is // better than overflowing the column. if got := PDFTruncate(long, 1, size, false); got != "" { t.Errorf("PDFTruncate(_, 1pt) = %q, want \"\"", got) } }