Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

391
go/webui/pdf_test.go Normal file
View File

@@ -0,0 +1,391 @@
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 "<n> 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 <size>".
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)
}
}