// Package pdftest builds small, deterministic PDFs for tests: text pages in Helvetica, an optional // outline (bookmarks), and image-only ("scanned") pages. It exists so the P5a tests need no binary // fixtures from elsewhere. It is test support: nothing outside _test.go files imports it. package pdftest import ( "bytes" "fmt" "strings" ) // Outline is one bookmark: a title that points at a 1-based page, nested at Level (1 = top). // Build supports one level; Level is kept so tests can state what they expect back. type Outline struct { Title string Page int Level int } // Doc describes a PDF to build. Pages holds each page's text; a page's lines are split on "\n" // and set one per line (long lines are not wrapped: callers keep them under ~90 characters). // Scanned pages (1-based) carry a small image and no text at all. type Doc struct { Title string Pages []string Outline []Outline Scanned []int // NamedDests writes the outline the way LaTeX's hyperref and Acrobat do: each item is a // /GoTo action to a named destination (resolved through the catalog's /Names /Dests tree) // and its /Title is an indirect object. Without it items carry a direct /Dest array. NamedDests bool } // Build returns the PDF bytes for d. It panics on a Doc it cannot express (an outline entry // pointing past the last page), since that is a bug in the test that built it. func Build(d Doc) []byte { var b bytes.Buffer var offsets []int obj := func(body string) int { offsets = append(offsets, b.Len()) n := len(offsets) fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", n, body) return n } b.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") // Object numbers are fixed by position: 1 catalog, 2 pages, 3 font, 4 image, 5 info, then // two per page (page, content), then the outline. nPages := len(d.Pages) firstPage := 6 pageObj := func(i int) int { return firstPage + 2*i } outlineRoot := firstPage + 2*nPages scanned := map[int]bool{} for _, p := range d.Scanned { scanned[p] = true } // With NamedDests the outline items are followed by one title object per item and then // the name tree, so the numbers below stay fixed by position. nameTree := outlineRoot + 1 + 2*len(d.Outline) catalog := "<< /Type /Catalog /Pages 2 0 R" if len(d.Outline) > 0 { catalog += fmt.Sprintf(" /Outlines %d 0 R /PageMode /UseOutlines", outlineRoot) if d.NamedDests { catalog += fmt.Sprintf(" /Names << /Dests %d 0 R >>", nameTree) } } obj(catalog + " >>") var kids []string for i := range d.Pages { kids = append(kids, fmt.Sprintf("%d 0 R", pageObj(i))) } obj(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), nPages)) obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") img := []byte{0x00, 0x80, 0x80, 0x00} // 2x2 grey pixels obj(fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length %d >>\nstream\n%s\nendstream", len(img), img)) obj(fmt.Sprintf("<< /Title (%s) /Producer (sift pdftest) >>", esc(d.Title))) for i, text := range d.Pages { var content string if scanned[i+1] { content = "q 400 0 0 500 100 150 cm /Im1 Do Q" } else { var c strings.Builder c.WriteString("BT /F1 11 Tf 13 TL 60 760 Td\n") for _, line := range strings.Split(text, "\n") { fmt.Fprintf(&c, "(%s) Tj T*\n", esc(line)) } c.WriteString("ET") content = c.String() } obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 4 0 R >> >> /Contents %d 0 R >>", pageObj(i)+1)) obj(fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content)) } if len(d.Outline) > 0 { first := outlineRoot + 1 last := outlineRoot + len(d.Outline) obj(fmt.Sprintf("<< /Type /Outlines /First %d 0 R /Last %d 0 R /Count %d >>", first, last, len(d.Outline))) for i, o := range d.Outline { if o.Page < 1 || o.Page > nPages { panic(fmt.Sprintf("pdftest: outline %q points at page %d of %d", o.Title, o.Page, nPages)) } n := first + i item := fmt.Sprintf("<< /Title (%s) /Parent %d 0 R /Dest [%d 0 R /XYZ null null null]", esc(o.Title), outlineRoot, pageObj(o.Page-1)) if d.NamedDests { item = fmt.Sprintf("<< /Title %d 0 R /Parent %d 0 R /A << /S /GoTo /D (dest.%d) >>", last+1+i, outlineRoot, i) } if i > 0 { item += fmt.Sprintf(" /Prev %d 0 R", n-1) } if n < last { item += fmt.Sprintf(" /Next %d 0 R", n+1) } obj(item + " >>") } if d.NamedDests { var names []string for i, o := range d.Outline { obj(fmt.Sprintf("(%s)", esc(o.Title))) names = append(names, fmt.Sprintf("(dest.%d) [%d 0 R /XYZ null null null]", i, pageObj(o.Page-1))) } obj(fmt.Sprintf("<< /Names [%s] >>", strings.Join(names, " "))) } } xref := b.Len() fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1) for _, off := range offsets { fmt.Fprintf(&b, "%010d 00000 n \n", off) } fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R /Info 5 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref) return b.Bytes() } // Lorem returns n lines of distinct, searchable filler: "p l …", so a test can tell // which page a line came from. func Lorem(page, n int) string { words := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"} var lines []string for i := 1; i <= n; i++ { var w []string for j := 0; j < 10; j++ { w = append(w, words[(page*7+i*3+j)%len(words)]) } lines = append(lines, fmt.Sprintf("p%d l%d %s", page, i, strings.Join(w, " "))) } return strings.Join(lines, "\n") } func esc(s string) string { r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`) return r.Replace(s) }