392 lines
12 KiB
Go
392 lines
12 KiB
Go
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)
|
|
}
|
|
}
|