From 34eeca7b9ff35973fd609d036ff5918ecfbcafde Mon Sep 17 00:00:00 2001 From: Patrick Gundlach Date: Fri, 26 Jun 2026 11:47:55 +0200 Subject: [PATCH 1/2] Add page-tree navigation API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the page tree as a public, read-only navigation layer on top of the existing object model. Until now the API surfaced the catalog but offered no direct page access — this fills that gap so the reader can back page-importer and layout tooling. New in page.go: - Reader.PageCount / Pages / Page(index) — 0-based, backed by a cached, cycle- and depth-guarded page-tree walk (no /Count prealloc; leaf detection keys off /Kids, so a missing /Type is tolerated). - *Page handle with Box, Boxes, Rotation, Resources, ContentStreams and Content. Inheritable attributes (boxes, /Rotate, /Resources) are resolved along the /Parent chain per PDF 32000-1:2008 §7.7.3.4, with the same seen-set + depth guard used elsewhere in the codebase. - Value types: BoxName (the five boundary boxes), Rect (normalised, with Width/Height). Content is delivered, never interpreted: ContentStreams flattens single or array /Contents to the underlying streams, Content concatenates the decoded bytes. Stream.RawBytes is added so callers can also get raw, undecoded bytes. No spec-default box substitution is applied — accessors report what the document actually declares. Tests: two golden fixtures (multi-level attribute inheritance; /Contents as an array) plus page_test.go covering inheritance and overrides, rotation normalisation, malformed boxes, index bounds, and cyclic /Kids and /Parent termination. New examples/pageinfo demonstrates the API end to end. Docs: README and doc.go "In scope" lists updated. --- README.md | 10 +- doc.go | 8 +- examples/pageinfo/main.go | 71 ++++ examples/pageinfo/main_test.go | 108 ++++++ filter.go | 12 + object.go | 7 + page.go | 334 ++++++++++++++++++ page_test.go | 309 ++++++++++++++++ reader.go | 5 + testdata/fixtures/generate.go | 52 +++ .../fixtures/page-contents-array/golden.json | 112 ++++++ .../fixtures/page-contents-array/input.pdf | Bin 0 -> 547 bytes .../fixtures/page-inheritance/golden.json | 165 +++++++++ testdata/fixtures/page-inheritance/input.pdf | Bin 0 -> 702 bytes 14 files changed, 1186 insertions(+), 7 deletions(-) create mode 100644 examples/pageinfo/main.go create mode 100644 examples/pageinfo/main_test.go create mode 100644 page.go create mode 100644 page_test.go create mode 100644 testdata/fixtures/page-contents-array/golden.json create mode 100644 testdata/fixtures/page-contents-array/input.pdf create mode 100644 testdata/fixtures/page-inheritance/golden.json create mode 100644 testdata/fixtures/page-inheritance/input.pdf diff --git a/README.md b/README.md index 7b978b0..0c43d87 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ or too thin (rsc/pdf, ledongthuc/pdf). pdfdisassembler targets PDF 1.x and In scope: PDF 1.x and 2.0 reading, classical xref and xref streams, indirect-object resolution, stream filters (FlateDecode, ASCII85, ASCIIHex, LZW, RunLength), text-string decoding (PDFDocEncoding, UTF-16BE BOM, UTF-8 -BOM), catalog + page tree, DocumentInfo, XMP metadata access, structure -tree traversal, `/Standard` security handler (V2, V4, V5), defensive -parsing. +BOM), catalog + page-tree navigation (page boxes, rotation, resources and +content streams, with inherited attributes resolved along the `/Parent` +chain), DocumentInfo, XMP metadata access, structure tree traversal, +`/Standard` security handler (V2, V4, V5), defensive parsing. Out of scope: writing PDFs, image filters (DCTDecode/JBIG2/JPX/CCITTFax), image rendering, font internals, XFA, public-key encryption, signature @@ -83,7 +84,8 @@ for entry := range r.Objects() { More complete examples live under [`examples/`](examples): `inspect` prints a summary of a PDF, `structtree` walks the `/StructTreeRoot` as a -starting point for accessibility tooling. +starting point for accessibility tooling, and `pageinfo` reports per-page +boxes, rotation, resources and content size via the page-tree API. ## Testing diff --git a/doc.go b/doc.go index 9f2b52b..1a40919 100644 --- a/doc.go +++ b/doc.go @@ -10,9 +10,11 @@ // In scope: PDF 1.x and 2.0 reading, classical xref and xref streams, // indirect-object resolution, stream filters (FlateDecode, ASCII85, // ASCIIHex, LZW, RunLength), text-string decoding (PDFDocEncoding, -// UTF-16BE BOM, UTF-8 BOM), catalog + page tree, DocumentInfo, XMP -// metadata access, structure-tree traversal, the /Standard security -// handler (V2, V4, V5), defensive xref recovery. +// UTF-16BE BOM, UTF-8 BOM), catalog + page-tree navigation (page boxes, +// rotation, resources and content streams, with inherited attributes +// resolved along the /Parent chain), DocumentInfo, XMP metadata access, +// structure-tree traversal, the /Standard security handler (V2, V4, V5), +// defensive xref recovery. // // Out of scope: writing PDFs, image filters (DCTDecode/JBIG2/JPX/CCITTFax), // image rendering, font internals, XFA, public-key encryption, signature diff --git a/examples/pageinfo/main.go b/examples/pageinfo/main.go new file mode 100644 index 0000000..1db2ff9 --- /dev/null +++ b/examples/pageinfo/main.go @@ -0,0 +1,71 @@ +// Command pageinfo prints per-page geometry and content info for a PDF: +// page count and, for each page, its boxes, rotation, resource categories, +// and decoded content size. Intended as a starting point for page-importer +// and layout tooling. +package main + +import ( + "fmt" + "io" + "log" + "os" + "sort" + + "github.com/speedata/pdfdisassembler" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: pageinfo ") + os.Exit(2) + } + r, err := pdfdisassembler.OpenFile(os.Args[1]) + if err != nil { + log.Fatal(err) + } + defer r.Close() + + if err := report(os.Stdout, r); err != nil { + log.Fatal(err) + } +} + +// report writes a human-readable page summary for r to w. The page tree is +// walked once via Reader.Pages; inherited attributes (boxes, rotation, +// resources) are resolved by the Page accessors. +func report(w io.Writer, r *pdfdisassembler.Reader) error { + pages, err := r.Pages() + if err != nil { + return err + } + fmt.Fprintf(w, "Pages: %d\n", len(pages)) + + for _, p := range pages { + // Display pages 1-based, matching how readers number them; the API + // itself is 0-based (Page.Index). + fmt.Fprintf(w, "Page %d:\n", p.Index()+1) + + if box, ok := p.Box(pdfdisassembler.MediaBox); ok { + fmt.Fprintf(w, " MediaBox: %g x %g pt\n", box.Width(), box.Height()) + } + if box, ok := p.Box(pdfdisassembler.CropBox); ok { + fmt.Fprintf(w, " CropBox: [%g %g %g %g]\n", box.LLX, box.LLY, box.URX, box.URY) + } + if rot := p.Rotation(); rot != 0 { + fmt.Fprintf(w, " Rotation: %d\n", rot) + } + if res, ok := p.Resources(); ok { + keys := res.Keys() + sort.Strings(keys) + fmt.Fprintf(w, " Resources: %v\n", keys) + } + + content, err := p.Content() + if err != nil { + fmt.Fprintf(w, " Content: (error: %v)\n", err) + continue + } + fmt.Fprintf(w, " Content: %d bytes decoded\n", len(content)) + } + return nil +} diff --git a/examples/pageinfo/main_test.go b/examples/pageinfo/main_test.go new file mode 100644 index 0000000..59bd043 --- /dev/null +++ b/examples/pageinfo/main_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/speedata/pdfdisassembler" +) + +// buildObjPDF assembles 1-based object bodies into a PDF with a classical xref +// and the given trailer dictionary body (without the surrounding << >>). +func buildObjPDF(t *testing.T, objs []string, trailer string) []byte { + t.Helper() + var buf bytes.Buffer + off := func() int { return buf.Len() } + fmt.Fprint(&buf, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n") + offsets := make([]int, len(objs)+1) + for i, body := range objs { + offsets[i+1] = off() + fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body) + } + xrefOff := off() + fmt.Fprintf(&buf, "xref\n0 %d\n%010d %05d f \n", len(objs)+1, 0, 65535) + for i := 1; i <= len(objs); i++ { + fmt.Fprintf(&buf, "%010d %05d n \n", offsets[i], 0) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d %s >>\nstartxref\n%d\n%%%%EOF\n", + len(objs)+1, trailer, xrefOff) + return buf.Bytes() +} + +// buildPagesPDF builds a two-page document: page 1 inherits its MediaBox and +// Resources from the /Pages root and carries a content stream; page 2 overrides +// MediaBox, adds a CropBox and /Rotate, and has no content. +func buildPagesPDF(t *testing.T) []byte { + const content = "BT (Hi) Tj ET" // 13 bytes + return buildObjPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 2 /Kids [ 3 0 R 4 0 R ] /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /CropBox [5 5 195 195] /Rotate 90 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content), + }, "/Root 1 0 R") +} + +func TestReport(t *testing.T) { + r, err := pdfdisassembler.Open(bytes.NewReader(buildPagesPDF(t))) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + var out bytes.Buffer + if err := report(&out, r); err != nil { + t.Fatalf("report: %v", err) + } + got := out.String() + + wants := []string{ + "Pages: 2", + "Page 1:", + "MediaBox: 612 x 792 pt", // inherited from /Pages root + "Resources: [Font]", // inherited + "Content: 13 bytes decoded", + "Page 2:", + "MediaBox: 200 x 200 pt", // overridden locally + "CropBox: [5 5 195 195]", + "Rotation: 90", + "Content: 0 bytes decoded", // no /Contents + } + for _, w := range wants { + if !strings.Contains(got, w) { + t.Errorf("output missing %q; got:\n%s", w, got) + } + } + + // Page 1 has no rotation, so no Rotation line should appear before "Page 2". + page1 := got[strings.Index(got, "Page 1:"):strings.Index(got, "Page 2:")] + if strings.Contains(page1, "Rotation:") { + t.Errorf("page 1 should not print a Rotation line; got:\n%s", page1) + } +} + +// TestReportCyclicKidsTerminates feeds a page tree whose /Kids cycles back on +// itself; report must return rather than recurse until the stack overflows. +func TestReportCyclicKidsTerminates(t *testing.T) { + data := buildObjPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Pages /Kids [ 2 0 R ] >>", // cycle back to obj 2 + }, "/Root 1 0 R") + r, err := pdfdisassembler.Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + var out bytes.Buffer + if err := report(&out, r); err != nil { + t.Fatalf("report: %v", err) + } + if got := out.String(); !strings.Contains(got, "Pages: 0") { + t.Errorf("want \"Pages: 0\", got:\n%s", got) + } +} diff --git a/filter.go b/filter.go index c8c8a6a..ff347c8 100644 --- a/filter.go +++ b/filter.go @@ -17,6 +17,18 @@ func (r *Reader) decodeStream(s *Stream) ([]byte, error) { return r.applyFilters(s, raw, true) } +// rawStreamBytes returns a copy of the stream's raw bytes from the file buffer, +// applying the same bounds check decodeStream uses. +func (r *Reader) rawStreamBytes(s *Stream) ([]byte, error) { + if s.rawOffset < 0 || s.rawOffset+s.rawLength > int64(len(r.buf)) { + return nil, fmt.Errorf("pdfdisassembler: stream %d %d R: bytes out of range", s.objNumber, s.objGeneration) + } + raw := r.buf[s.rawOffset : s.rawOffset+s.rawLength] + out := make([]byte, len(raw)) + copy(out, raw) + return out, nil +} + // applyFilters decrypts (if encrypted is true and an encryption context // exists) and runs the filter chain declared on the stream dict. func (r *Reader) applyFilters(s *Stream, raw []byte, encrypted bool) ([]byte, error) { diff --git a/object.go b/object.go index b65acc2..c2aba1c 100644 --- a/object.go +++ b/object.go @@ -297,6 +297,13 @@ func (s *Stream) RawLength() int64 { return s.rawLength } +// RawBytes returns a copy of the stream's raw, undecoded bytes exactly as they +// appear in the file — before any filter or decryption is applied. For the +// decoded content, use Content. +func (s *Stream) RawBytes() ([]byte, error) { + return s.reader.rawStreamBytes(s) +} + // ObjectEntry is yielded by Reader.Objects: an in-use indirect object plus // its resolved value. type ObjectEntry struct { diff --git a/page.go b/page.go new file mode 100644 index 0000000..b8248d3 --- /dev/null +++ b/page.go @@ -0,0 +1,334 @@ +package pdfdisassembler + +import ( + "bytes" + "errors" + "fmt" +) + +// maxPageTreeDepth bounds both the page-tree (/Kids) descent and the +// inheritance (/Parent) walk so a hostile or cyclic structure can't loop +// forever or overflow the stack. +const maxPageTreeDepth = 1000 + +// BoxName identifies one of the page boundary boxes (PDF 32000-1:2008 +// §14.11.2). +type BoxName string + +// The five page boundary boxes, in the spec's containment order (MediaBox is +// the largest, ArtBox the smallest). +const ( + MediaBox BoxName = "MediaBox" + CropBox BoxName = "CropBox" + BleedBox BoxName = "BleedBox" + TrimBox BoxName = "TrimBox" + ArtBox BoxName = "ArtBox" +) + +// boxNames is the canonical box list iterated by Page.Boxes. +var boxNames = []BoxName{MediaBox, CropBox, BleedBox, TrimBox, ArtBox} + +// Rect is a PDF rectangle in default user-space units (points), normalised so +// LLX <= URX and LLY <= URY regardless of the corner order written in the file. +type Rect struct { + LLX, LLY, URX, URY float64 +} + +// Width returns the rectangle's horizontal extent. +func (r Rect) Width() float64 { return r.URX - r.LLX } + +// Height returns the rectangle's vertical extent. +func (r Rect) Height() float64 { return r.URY - r.LLY } + +// Page is a handle to a single leaf page (/Type /Page) of the page tree. Its +// accessors resolve the inheritable attributes — boxes, /Rotate, /Resources — +// by walking the /Parent chain per PDF 32000-1:2008 §7.7.3.4. Obtain one via +// Reader.Page or Reader.Pages. +type Page struct { + reader *Reader + dict *Dict + index int +} + +// Index returns the page's 0-based position in display order. +func (p *Page) Index() int { return p.index } + +// Dict returns the page's own dictionary, without inherited attributes +// flattened in. Use the Box, Rotation, and Resources accessors for values that +// may be inherited from an ancestor /Pages node. +func (p *Page) Dict() *Dict { return p.dict } + +// PageCount returns the number of leaf pages in the document. +func (r *Reader) PageCount() (int, error) { + pages, err := r.loadPages() + if err != nil { + return 0, err + } + return len(pages), nil +} + +// Pages returns every leaf page in display (reading) order. +func (r *Reader) Pages() ([]*Page, error) { + pages, err := r.loadPages() + if err != nil { + return nil, err + } + out := make([]*Page, len(pages)) + copy(out, pages) + return out, nil +} + +// Page returns the leaf page at the given 0-based index in display order. +func (r *Reader) Page(index int) (*Page, error) { + pages, err := r.loadPages() + if err != nil { + return nil, err + } + if index < 0 || index >= len(pages) { + return nil, fmt.Errorf("pdfdisassembler: page index %d out of range (%d pages)", index, len(pages)) + } + return pages[index], nil +} + +// loadPages walks the page tree once and caches the flat leaf-page list (and +// any error) for subsequent calls. +func (r *Reader) loadPages() ([]*Page, error) { + if r.pagesLoaded { + return r.pages, r.pagesErr + } + r.pagesLoaded = true + r.pages, r.pagesErr = r.buildPages() + return r.pages, r.pagesErr +} + +func (r *Reader) buildPages() ([]*Page, error) { + cat, err := r.Catalog() + if err != nil { + return nil, err + } + rootRef, ok := cat.Get("Pages") + if !ok { + return nil, errors.New("pdfdisassembler: catalog has no /Pages") + } + root, err := r.ResolveDict(rootRef) + if err != nil { + return nil, fmt.Errorf("pdfdisassembler: resolve /Pages: %w", err) + } + seen := map[Reference]struct{}{} + if ref, ok := rootRef.(Reference); ok { + seen[ref] = struct{}{} + } + var out []*Page + r.collectPages(root, seen, 0, &out) + return out, nil +} + +// collectPages descends the page tree depth-first, appending each leaf page to +// out in display order. A node is treated as an intermediate /Pages node when +// it carries /Kids, otherwise as a leaf /Page — /Type is only a hint, since +// some producers omit it. seen guards against cyclic /Kids references and depth +// bounds the descent. +func (r *Reader) collectPages(node *Dict, seen map[Reference]struct{}, depth int, out *[]*Page) { + if node == nil || depth > maxPageTreeDepth { + return + } + kids, ok := node.Array("Kids") + if !ok { + *out = append(*out, &Page{reader: r, dict: node, index: len(*out)}) + return + } + for _, kid := range kids { + if ref, ok := kid.(Reference); ok { + if _, dup := seen[ref]; dup { + continue + } + seen[ref] = struct{}{} + } + child, err := r.ResolveDict(kid) + if err != nil { + continue + } + r.collectPages(child, seen, depth+1, out) + } +} + +// inherited walks the /Parent chain starting at the page dictionary and returns +// the resolved value of the first ancestor that carries key. ok is false when +// no ancestor defines it. Resolution is cached, so the same /Parent reference +// yields the same *Dict pointer — pointer identity (plus the depth bound) +// terminates a cyclic chain. +func (p *Page) inherited(key string) (Object, bool) { + seen := map[*Dict]struct{}{} + node := p.dict + for depth := 0; node != nil && depth <= maxPageTreeDepth; depth++ { + if _, dup := seen[node]; dup { + return nil, false + } + seen[node] = struct{}{} + if v, ok := node.resolved(key); ok { + return v, true + } + parent, ok := node.Dict("Parent") + if !ok { + return nil, false + } + node = parent + } + return nil, false +} + +// Box returns the named page boundary box, resolved through inheritance along +// the /Parent chain. ok is false when neither the page nor any ancestor defines +// the box, or its value is not a well-formed array of four numbers. +func (p *Page) Box(name BoxName) (Rect, bool) { + v, ok := p.inherited(string(name)) + if !ok { + return Rect{}, false + } + arr, ok := v.(Array) + if !ok { + return Rect{}, false + } + return rectFromArray(p.reader, arr) +} + +// Boxes returns every boundary box defined for the page (after inheritance), +// keyed by name. Boxes absent from both the page and its ancestors are omitted. +// No spec-default substitution (e.g. a missing CropBox defaulting to MediaBox) +// is applied: this reports what the document actually declares. +func (p *Page) Boxes() map[BoxName]Rect { + out := map[BoxName]Rect{} + for _, name := range boxNames { + if rect, ok := p.Box(name); ok { + out[name] = rect + } + } + return out +} + +// Rotation returns the page's clockwise display rotation in degrees, resolved +// through inheritance and normalised to one of 0, 90, 180, 270. A missing, +// non-integer, or non-multiple-of-90 /Rotate yields 0. +func (p *Page) Rotation() int { + v, ok := p.inherited("Rotate") + if !ok { + return 0 + } + n, ok := v.(Integer) + if !ok { + return 0 + } + deg := int(n) % 360 + if deg < 0 { + deg += 360 + } + if deg%90 != 0 { + return 0 + } + return deg +} + +// Resources returns the page's resource dictionary, resolved through +// inheritance along the /Parent chain. ok is false when neither the page nor +// any ancestor defines /Resources. +func (p *Page) Resources() (*Dict, bool) { + v, ok := p.inherited("Resources") + if !ok { + return nil, false + } + d, ok := v.(*Dict) + return d, ok +} + +// ContentStreams returns the page's content streams in order. /Contents may be +// a single stream or an array of streams (PDF 32000-1:2008 §7.7.3.3); both are +// flattened to the underlying Stream objects. It returns nil (no error) when +// the page has no /Contents. Non-stream entries are skipped defensively. +func (p *Page) ContentStreams() ([]*Stream, error) { + v, ok := p.dict.Get("Contents") + if !ok { + return nil, nil + } + resolved, err := p.reader.Resolve(v) + if err != nil { + return nil, fmt.Errorf("pdfdisassembler: resolve /Contents: %w", err) + } + var out []*Stream + switch t := resolved.(type) { + case *Stream: + out = append(out, t) + case Array: + for _, e := range t { + s, err := p.reader.Resolve(e) + if err != nil { + continue + } + if stm, ok := s.(*Stream); ok { + out = append(out, stm) + } + } + } + return out, nil +} + +// Content returns the page's decoded content: every content stream decoded via +// its filter chain and concatenated with a single newline between streams (a +// token cannot span a stream boundary, per PDF 32000-1:2008 §7.8.2). The result +// is the raw drawing-instruction byte stream; this library does not interpret +// it (see package contentstream for tokenisation). +func (p *Page) Content() ([]byte, error) { + streams, err := p.ContentStreams() + if err != nil { + return nil, err + } + parts := make([][]byte, 0, len(streams)) + for _, s := range streams { + data, err := s.Content() + if err != nil { + return nil, err + } + parts = append(parts, data) + } + return bytes.Join(parts, []byte{'\n'}), nil +} + +// rectFromArray converts a 4-element PDF array [llx lly urx ury] to a +// normalised Rect. Entries may be Integer or Real and may be indirect +// references. ok is false for any other shape. +func rectFromArray(r *Reader, arr Array) (Rect, bool) { + if len(arr) != 4 { + return Rect{}, false + } + var v [4]float64 + for i, e := range arr { + f, ok := numberValue(r, e) + if !ok { + return Rect{}, false + } + v[i] = f + } + rect := Rect{LLX: v[0], LLY: v[1], URX: v[2], URY: v[3]} + if rect.LLX > rect.URX { + rect.LLX, rect.URX = rect.URX, rect.LLX + } + if rect.LLY > rect.URY { + rect.LLY, rect.URY = rect.URY, rect.LLY + } + return rect, true +} + +// numberValue resolves obj and returns it as a float64 if it is an Integer or +// Real. +func numberValue(r *Reader, obj Object) (float64, bool) { + resolved, err := r.Resolve(obj) + if err != nil { + return 0, false + } + switch n := resolved.(type) { + case Integer: + return float64(n), true + case Real: + return float64(n), true + } + return 0, false +} diff --git a/page_test.go b/page_test.go new file mode 100644 index 0000000..7b7c253 --- /dev/null +++ b/page_test.go @@ -0,0 +1,309 @@ +package pdfdisassembler + +import ( + "bytes" + "path/filepath" + "testing" +) + +func TestPageInheritanceFixture(t *testing.T) { + r, err := OpenFile(filepath.Join("testdata", "fixtures", "page-inheritance", "input.pdf")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + n, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if n != 2 { + t.Fatalf("PageCount = %d, want 2", n) + } + + // Page 0 inherits everything: MediaBox/Rotate/Resources two levels up + // (the /Pages root), CropBox one level up. + p0, err := r.Page(0) + if err != nil { + t.Fatalf("Page(0): %v", err) + } + if box, ok := p0.Box(MediaBox); !ok || box != (Rect{0, 0, 612, 792}) { + t.Errorf("page 0 MediaBox = %+v ok=%v, want {0 0 612 792}", box, ok) + } + if box, ok := p0.Box(CropBox); !ok || box != (Rect{10, 10, 602, 782}) { + t.Errorf("page 0 CropBox = %+v ok=%v, want {10 10 602 782}", box, ok) + } + if rot := p0.Rotation(); rot != 90 { + t.Errorf("page 0 Rotation = %d, want 90", rot) + } + res, ok := p0.Resources() + if !ok { + t.Fatalf("page 0 Resources not found") + } + if _, ok := res.Dict("Font"); !ok { + t.Errorf("page 0 Resources missing /Font: keys %v", res.Keys()) + } + // Boxes that no ancestor defines are absent (no spec-default substitution). + if box, ok := p0.Box(BleedBox); ok { + t.Errorf("page 0 BleedBox = %+v, want absent", box) + } + + // Page 1 overrides MediaBox and Rotate locally, still inherits CropBox. + p1, err := r.Page(1) + if err != nil { + t.Fatalf("Page(1): %v", err) + } + if box, ok := p1.Box(MediaBox); !ok || box != (Rect{0, 0, 200, 200}) { + t.Errorf("page 1 MediaBox = %+v ok=%v, want {0 0 200 200}", box, ok) + } + if box, ok := p1.Box(CropBox); !ok || box != (Rect{10, 10, 602, 782}) { + t.Errorf("page 1 CropBox = %+v ok=%v, want inherited {10 10 602 782}", box, ok) + } + if rot := p1.Rotation(); rot != 0 { + t.Errorf("page 1 Rotation = %d, want 0 (override)", rot) + } +} + +func TestPageContentsArrayFixture(t *testing.T) { + r, err := OpenFile(filepath.Join("testdata", "fixtures", "page-contents-array", "input.pdf")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + + p, err := r.Page(0) + if err != nil { + t.Fatalf("Page(0): %v", err) + } + streams, err := p.ContentStreams() + if err != nil { + t.Fatalf("ContentStreams: %v", err) + } + if len(streams) != 2 { + t.Fatalf("got %d content streams, want 2", len(streams)) + } + if raw, err := streams[0].RawBytes(); err != nil || string(raw) != "q 1 0 0 1 50 50 cm" { + t.Errorf("stream 0 RawBytes = %q err=%v", raw, err) + } + + content, err := p.Content() + if err != nil { + t.Fatalf("Content: %v", err) + } + want := "q 1 0 0 1 50 50 cm\nBT /F1 12 Tf (Hello) Tj ET" + if string(content) != want { + t.Errorf("Content = %q, want %q", content, want) + } +} + +func TestPageContentSingleStream(t *testing.T) { + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Length 5 >>\nstream\nhello\nendstream", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, err := r.Page(0) + if err != nil { + t.Fatalf("Page(0): %v", err) + } + if got, err := p.Content(); err != nil || string(got) != "hello" { + t.Errorf("Content = %q err=%v, want \"hello\"", got, err) + } +} + +func TestPageContentNone(t *testing.T) { + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, _ := r.Page(0) + streams, err := p.ContentStreams() + if err != nil || streams != nil { + t.Errorf("ContentStreams = %v err=%v, want nil", streams, err) + } + if got, err := p.Content(); err != nil || len(got) != 0 { + t.Errorf("Content = %q err=%v, want empty", got, err) + } +} + +func TestPageRotationNormalization(t *testing.T) { + cases := []struct { + rotate string + want int + }{ + {"450", 90}, + {"-90", 270}, + {"360", 0}, + {"270", 270}, + {"45", 0}, // not a multiple of 90 → defensive 0 + {"(x)", 0}, // wrong type → 0 + } + for _, tc := range cases { + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /Rotate " + tc.rotate + " >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open(%s): %v", tc.rotate, err) + } + p, _ := r.Page(0) + if got := p.Rotation(); got != tc.want { + t.Errorf("Rotate %s → %d, want %d", tc.rotate, got, tc.want) + } + r.Close() + } +} + +func TestPageBoxNormalization(t *testing.T) { + // Corners written in the wrong order must come back normalised, and the + // box may be a Real-valued array. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [612.0 792.0 0 0] >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, _ := r.Page(0) + box, ok := p.Box(MediaBox) + if !ok || box != (Rect{0, 0, 612, 792}) { + t.Fatalf("MediaBox = %+v ok=%v, want {0 0 612 792}", box, ok) + } + if box.Width() != 612 || box.Height() != 792 { + t.Errorf("Width/Height = %v/%v, want 612/792", box.Width(), box.Height()) + } +} + +func TestPageBoxMalformed(t *testing.T) { + // A box that is not a 4-number array must report absent, not panic. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612] /CropBox (nope) >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, _ := r.Page(0) + if box, ok := p.Box(MediaBox); ok { + t.Errorf("short MediaBox = %+v, want absent", box) + } + if box, ok := p.Box(CropBox); ok { + t.Errorf("string CropBox = %+v, want absent", box) + } + if boxes := p.Boxes(); len(boxes) != 0 { + t.Errorf("Boxes = %v, want empty", boxes) + } +} + +func TestPageIndexOutOfRange(t *testing.T) { + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + if _, err := r.Page(-1); err == nil { + t.Error("Page(-1) = nil error, want out-of-range error") + } + if _, err := r.Page(1); err == nil { + t.Error("Page(1) = nil error, want out-of-range error") + } +} + +func TestPageCyclicKidsTerminates(t *testing.T) { + // obj 3's /Kids points back to obj 2; the walk must terminate. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Pages /Kids [ 2 0 R ] >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + n, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if n != 0 { + t.Errorf("PageCount = %d, want 0", n) + } +} + +func TestPageCyclicParentTerminates(t *testing.T) { + // The leaf page's /Parent points to itself; inheritance must terminate. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 3 0 R >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, err := r.Page(0) + if err != nil { + t.Fatalf("Page(0): %v", err) + } + if box, ok := p.Box(MediaBox); ok { + t.Errorf("MediaBox = %+v, want absent", box) + } + if rot := p.Rotation(); rot != 0 { + t.Errorf("Rotation = %d, want 0", rot) + } + if _, ok := p.Resources(); ok { + t.Errorf("Resources found, want none") + } +} + +func TestPagesMissingType(t *testing.T) { + // Neither the intermediate node nor the leaf declares /Type; the walk + // keys off /Kids presence instead. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Kids [ 3 0 R ] /Count 1 >>", + "<< /Parent 2 0 R /MediaBox [0 0 100 100] >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + n, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if n != 1 { + t.Fatalf("PageCount = %d, want 1", n) + } + p, _ := r.Page(0) + if box, ok := p.Box(MediaBox); !ok || box != (Rect{0, 0, 100, 100}) { + t.Errorf("MediaBox = %+v ok=%v, want {0 0 100 100}", box, ok) + } +} diff --git a/reader.go b/reader.go index 0e0d43e..bf49014 100644 --- a/reader.go +++ b/reader.go @@ -48,6 +48,11 @@ type Reader struct { objCache map[Reference]Object // resolveStack guards against indirect-reference cycles. resolveStack map[Reference]struct{} + + // Page-tree cache, populated lazily by loadPages. + pages []*Page + pagesLoaded bool + pagesErr error } // xrefEntry describes a single in-use object. diff --git a/testdata/fixtures/generate.go b/testdata/fixtures/generate.go index 6bf1c78..dd0d51e 100644 --- a/testdata/fixtures/generate.go +++ b/testdata/fixtures/generate.go @@ -25,6 +25,8 @@ func main() { write("minimal", minimalPDF()) write("xref-stream", xrefStreamPDF()) write("flate-stream", flateStreamPDF()) + write("page-inheritance", pageInheritancePDF()) + write("page-contents-array", pageContentsArrayPDF()) } func write(name string, data []byte) { @@ -102,6 +104,56 @@ func xrefStreamPDF() []byte { return buf.Bytes() } +// classicalPDF assembles objs (1-based bodies) into a PDF with a classical +// xref table and the given trailer dictionary body (without the surrounding +// << >>). +func classicalPDF(version string, objs []string, trailer string) []byte { + var buf bytes.Buffer + fmt.Fprintf(&buf, "%%PDF-%s\n%%\xE2\xE3\xCF\xD3\n", version) + offsets := make([]int, len(objs)+1) + for i, body := range objs { + offsets[i+1] = buf.Len() + fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body) + } + xrefOff := buf.Len() + fmt.Fprintf(&buf, "xref\n0 %d\n%010d %05d f \n", len(objs)+1, 0, 65535) + for i := 1; i <= len(objs); i++ { + fmt.Fprintf(&buf, "%010d %05d n \n", offsets[i], 0) + } + fmt.Fprintf(&buf, "trailer\n<< /Size %d %s >>\nstartxref\n%d\n%%%%EOF\n", + len(objs)+1, trailer, xrefOff) + return buf.Bytes() +} + +// pageInheritancePDF builds a three-level page tree where leaf pages inherit +// /MediaBox and /Rotate two levels up (from the /Pages root), /CropBox and +// /Resources one level up, and the second leaf overrides /MediaBox and /Rotate +// locally. It exercises attribute inheritance per PDF 32000-1:2008 §7.7.3.4. +func pageInheritancePDF() []byte { + return classicalPDF("1.7", []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 2 /Kids [ 3 0 R ] /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Rotate 90 >>", + "<< /Type /Pages /Count 2 /Parent 2 0 R /Kids [ 4 0 R 5 0 R ] /CropBox [10 10 602 782] >>", + "<< /Type /Page /Parent 3 0 R >>", + "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 200 200] /Rotate 0 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + }, "/Root 1 0 R") +} + +// pageContentsArrayPDF builds a single page whose /Contents is an array of two +// uncompressed content streams that must be concatenated. +func pageContentsArrayPDF() []byte { + const c1 = "q 1 0 0 1 50 50 cm" + const c2 = "BT /F1 12 Tf (Hello) Tj ET" + return classicalPDF("1.7", []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents [ 4 0 R 5 0 R ] >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(c1), c1), + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(c2), c2), + }, "/Root 1 0 R") +} + func flateStreamPDF() []byte { const payload = "Hello, stream!" var zbuf bytes.Buffer diff --git a/testdata/fixtures/page-contents-array/golden.json b/testdata/fixtures/page-contents-array/golden.json new file mode 100644 index 0000000..3028856 --- /dev/null +++ b/testdata/fixtures/page-contents-array/golden.json @@ -0,0 +1,112 @@ +{ + "version": "1.7", + "xref_format": "classical", + "encrypted": false, + "trailer": { + "dict": { + "Size": { + "int": 6 + }, + "Root": { + "ref": "1 0" + } + } + }, + "objects": { + "1 0": { + "dict": { + "Type": { + "name": "Catalog" + }, + "Pages": { + "ref": "2 0" + } + } + }, + "2 0": { + "dict": { + "Type": { + "name": "Pages" + }, + "Count": { + "int": 1 + }, + "Kids": { + "array": [ + { + "ref": "3 0" + } + ] + } + } + }, + "3 0": { + "dict": { + "Type": { + "name": "Page" + }, + "Parent": { + "ref": "2 0" + }, + "MediaBox": { + "array": [ + { + "int": 0 + }, + { + "int": 0 + }, + { + "int": 200 + }, + { + "int": 200 + } + ] + }, + "Contents": { + "array": [ + { + "ref": "4 0" + }, + { + "ref": "5 0" + } + ] + } + } + }, + "4 0": { + "stream": { + "dict": { + "Length": { + "int": 18 + } + }, + "raw_length": 18, + "filters": [], + "decoded": { + "length": 18, + "sha256": "ac399976dc867824f50e13f5f86d9f5b9be76d49ae538a01d7567552914e9f80", + "preview_utf8": "q 1 0 0 1 50 50 cm" + } + } + }, + "5 0": { + "stream": { + "dict": { + "Length": { + "int": 26 + } + }, + "raw_length": 26, + "filters": [], + "decoded": { + "length": 26, + "sha256": "9dd85240377330fc34370ec0a297930e7cba5a30efbaab71f21337dbfc1d864e", + "preview_utf8": "BT /F1 12 Tf (Hello) Tj ET" + } + } + } + } +} diff --git a/testdata/fixtures/page-contents-array/input.pdf b/testdata/fixtures/page-contents-array/input.pdf new file mode 100644 index 0000000000000000000000000000000000000000..76a6970b2af234d3a03a830f89f11d9a51ad30de GIT binary patch literal 547 zcmZWmO;5rw7{2#cJeMVU(5@Y;90&&xVxmR@yAcl66&x9>ZZ$#wqzC_j{t^3*2{@Ye z(5KJG`}Pec*W(LudBuY7pU*EQ5W+sLS+574yuTRmp>{gAEAWY4nF`Vqrs(&XDN-WR z{l8$x<3syaI0*3DEUhp{R0|hJQbJCp6jT~7-6ipRlV*Bgx0r{XFT?sQd|kG1o<`Q4 z+B!Z7Ap9@j&J-*64AG?mDpwl4VXwYCG8KqJ+D{wms(EbsBiELPR7W0z1bi$YB$6f0 zZcLurb0lkwl2fm3X_hR6V&Q&4#c>p4i9IHKA}tgLO!-b)n2`SaT9}l+n>N<%9xzwx iEH`C6-C6cwpunfr(wLg5R!*0$t}{{{76hY*G5ZA`K#iyX literal 0 HcmV?d00001 diff --git a/testdata/fixtures/page-inheritance/golden.json b/testdata/fixtures/page-inheritance/golden.json new file mode 100644 index 0000000..0922e2e --- /dev/null +++ b/testdata/fixtures/page-inheritance/golden.json @@ -0,0 +1,165 @@ +{ + "version": "1.7", + "xref_format": "classical", + "encrypted": false, + "trailer": { + "dict": { + "Size": { + "int": 7 + }, + "Root": { + "ref": "1 0" + } + } + }, + "objects": { + "1 0": { + "dict": { + "Type": { + "name": "Catalog" + }, + "Pages": { + "ref": "2 0" + } + } + }, + "2 0": { + "dict": { + "Type": { + "name": "Pages" + }, + "Count": { + "int": 2 + }, + "Kids": { + "array": [ + { + "ref": "3 0" + } + ] + }, + "MediaBox": { + "array": [ + { + "int": 0 + }, + { + "int": 0 + }, + { + "int": 612 + }, + { + "int": 792 + } + ] + }, + "Resources": { + "dict": { + "Font": { + "dict": { + "F1": { + "ref": "6 0" + } + } + } + } + }, + "Rotate": { + "int": 90 + } + } + }, + "3 0": { + "dict": { + "Type": { + "name": "Pages" + }, + "Count": { + "int": 2 + }, + "Parent": { + "ref": "2 0" + }, + "Kids": { + "array": [ + { + "ref": "4 0" + }, + { + "ref": "5 0" + } + ] + }, + "CropBox": { + "array": [ + { + "int": 10 + }, + { + "int": 10 + }, + { + "int": 602 + }, + { + "int": 782 + } + ] + } + } + }, + "4 0": { + "dict": { + "Type": { + "name": "Page" + }, + "Parent": { + "ref": "3 0" + } + } + }, + "5 0": { + "dict": { + "Type": { + "name": "Page" + }, + "Parent": { + "ref": "3 0" + }, + "MediaBox": { + "array": [ + { + "int": 0 + }, + { + "int": 0 + }, + { + "int": 200 + }, + { + "int": 200 + } + ] + }, + "Rotate": { + "int": 0 + } + } + }, + "6 0": { + "dict": { + "Type": { + "name": "Font" + }, + "Subtype": { + "name": "Type1" + }, + "BaseFont": { + "name": "Helvetica" + } + } + } + } +} diff --git a/testdata/fixtures/page-inheritance/input.pdf b/testdata/fixtures/page-inheritance/input.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e293decdd272cb4c6e8a93c2afe65e6c6ed3feec GIT binary patch literal 702 zcmah{%TB{E5WM><_L3vm&a;s!4lPtj2vN1%Dh_VDt%9n$a$HpSNe=u0{0O@aG<`^5 zIgvA&-JMx)G`t>M=*vPxpI`4ELW2T3eHPs=$j8?Wg6tb_R(1(;XqH$*OoS0!T?t&z znaJPA0S!;~?RM>nF7K*&4HHO0;1kGuoL8o2cQ8=|%EK4$2z#h{io)@fJEGPdbm@bPmEKxK?C%h!>oMCBPwwQOOTA>SrH?H0g a3hsdQz!Mn;uJ*?Hpgk>15k)r-1Mv-4KDQ?T literal 0 HcmV?d00001 From 023992a29c0562d32530d76719d9d0cab2cd65b1 Mon Sep 17 00:00:00 2001 From: Patrick Gundlach Date: Fri, 26 Jun 2026 14:11:12 +0200 Subject: [PATCH 2/2] Address review: box inheritance, content truncation, phantom leaves - Box/Boxes: only MediaBox and CropBox inherit along /Parent (PDF 32000-1:2008 Table 30). BleedBox/TrimBox/ArtBox are page-level only and are now read from the page object, so an ancestor /Pages node carrying one no longer leaks a wrong clip/trim box into descendant leaves. - ContentStreams: an array /Contents entry that resolves to null (missing object) or a non-stream now returns an error instead of being dropped, so Content no longer reports a silently truncated stream as success. - collectPages: a node with no resolvable /Kids array but /Type /Pages, /Count, or an unresolvable /Kids key is treated as a broken/empty branch rather than emitted as a phantom leaf page. - filter: factor the shared bounds check into rawStreamSlice; decodeStream and rawStreamBytes now share one check and one error message. Tests added for each: non-inheritable boxes, broken /Contents array entry, and the phantom-leaf guard. --- filter.go | 22 +++++++++---- page.go | 48 +++++++++++++++++++++------- page_test.go | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/filter.go b/filter.go index ff347c8..14767de 100644 --- a/filter.go +++ b/filter.go @@ -10,20 +10,28 @@ import ( // the file, applies decryption if enabled, then runs the declared filter // chain. func (r *Reader) decodeStream(s *Stream) ([]byte, error) { - if s.rawOffset < 0 || s.rawOffset+s.rawLength > int64(len(r.buf)) { - return nil, fmt.Errorf("pdfdisassembler: stream %d %d R: bytes out of range", s.objNumber, s.objGeneration) + raw, err := r.rawStreamSlice(s) + if err != nil { + return nil, err } - raw := r.buf[s.rawOffset : s.rawOffset+s.rawLength] return r.applyFilters(s, raw, true) } -// rawStreamBytes returns a copy of the stream's raw bytes from the file buffer, -// applying the same bounds check decodeStream uses. -func (r *Reader) rawStreamBytes(s *Stream) ([]byte, error) { +// rawStreamSlice returns the stream's raw bytes as a sub-slice of the file +// buffer (no copy), after bounds-checking the declared extent. +func (r *Reader) rawStreamSlice(s *Stream) ([]byte, error) { if s.rawOffset < 0 || s.rawOffset+s.rawLength > int64(len(r.buf)) { return nil, fmt.Errorf("pdfdisassembler: stream %d %d R: bytes out of range", s.objNumber, s.objGeneration) } - raw := r.buf[s.rawOffset : s.rawOffset+s.rawLength] + return r.buf[s.rawOffset : s.rawOffset+s.rawLength], nil +} + +// rawStreamBytes returns a copy of the stream's raw bytes (see Stream.RawBytes). +func (r *Reader) rawStreamBytes(s *Stream) ([]byte, error) { + raw, err := r.rawStreamSlice(s) + if err != nil { + return nil, err + } out := make([]byte, len(raw)) copy(out, raw) return out, nil diff --git a/page.go b/page.go index b8248d3..cb47a7c 100644 --- a/page.go +++ b/page.go @@ -134,6 +134,16 @@ func (r *Reader) collectPages(node *Dict, seen map[Reference]struct{}, depth int } kids, ok := node.Array("Kids") if !ok { + // No resolvable /Kids array. A node that still looks like an + // intermediate /Pages node — it declares /Type /Pages, /Count, or a + // /Kids key that failed to resolve — is a broken or empty branch, not a + // leaf page; emitting it would invent a phantom page. + if node.Has("Kids") || node.Has("Count") { + return + } + if t, ok := node.Name("Type"); ok && t == "Pages" { + return + } *out = append(*out, &Page{reader: r, dict: node, index: len(*out)}) return } @@ -177,11 +187,21 @@ func (p *Page) inherited(key string) (Object, bool) { return nil, false } -// Box returns the named page boundary box, resolved through inheritance along -// the /Parent chain. ok is false when neither the page nor any ancestor defines -// the box, or its value is not a well-formed array of four numbers. +// Box returns the named page boundary box. MediaBox and CropBox are resolved +// through inheritance along the /Parent chain; BleedBox, TrimBox and ArtBox are +// read from the page object only, since only MediaBox and CropBox carry the +// (Inheritable) marker in PDF 32000-1:2008 Table 30 (§14.11.2). ok is false +// when the box is not defined there, or its value is not a well-formed array of +// four numbers. No spec-default substitution (e.g. a missing box defaulting to +// CropBox) is applied. func (p *Page) Box(name BoxName) (Rect, bool) { - v, ok := p.inherited(string(name)) + var v Object + var ok bool + if name == MediaBox || name == CropBox { + v, ok = p.inherited(string(name)) + } else { + v, ok = p.dict.resolved(string(name)) + } if !ok { return Rect{}, false } @@ -192,10 +212,11 @@ func (p *Page) Box(name BoxName) (Rect, bool) { return rectFromArray(p.reader, arr) } -// Boxes returns every boundary box defined for the page (after inheritance), -// keyed by name. Boxes absent from both the page and its ancestors are omitted. -// No spec-default substitution (e.g. a missing CropBox defaulting to MediaBox) -// is applied: this reports what the document actually declares. +// Boxes returns every boundary box defined for the page, keyed by name, with +// the same per-box inheritance rules as Box (MediaBox and CropBox may be +// inherited from an ancestor; BleedBox/TrimBox/ArtBox are page-level only). +// Boxes that are not defined are omitted; no spec-default substitution (e.g. a +// missing CropBox defaulting to MediaBox) is applied. func (p *Page) Boxes() map[BoxName]Rect { out := map[BoxName]Rect{} for _, name := range boxNames { @@ -261,11 +282,16 @@ func (p *Page) ContentStreams() ([]*Stream, error) { for _, e := range t { s, err := p.reader.Resolve(e) if err != nil { - continue + return nil, fmt.Errorf("pdfdisassembler: resolve /Contents entry: %w", err) } - if stm, ok := s.(*Stream); ok { - out = append(out, stm) + stm, ok := s.(*Stream) + if !ok { + // A missing entry resolves to Null, not an error; dropping it + // would silently truncate the page's drawing instructions, so + // fail loudly instead. + return nil, fmt.Errorf("pdfdisassembler: /Contents array entry resolved to %T, want stream", s) } + out = append(out, stm) } } return out, nil diff --git a/page_test.go b/page_test.go index 7b7c253..4d03986 100644 --- a/page_test.go +++ b/page_test.go @@ -215,6 +215,96 @@ func TestPageBoxMalformed(t *testing.T) { } } +func TestPageNonInheritableBoxes(t *testing.T) { + // An intermediate /Pages node carrying BleedBox/TrimBox/ArtBox must NOT + // leak those into descendant leaves — only MediaBox/CropBox are inheritable + // (PDF 32000-1:2008 Table 30). The leaf's own TrimBox is still reported. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] /MediaBox [0 0 612 792] /CropBox [1 1 611 791] /BleedBox [2 2 610 790] /TrimBox [3 3 609 789] /ArtBox [4 4 608 788] >>", + "<< /Type /Page /Parent 2 0 R /TrimBox [9 9 100 100] >>", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, _ := r.Page(0) + + // Inheritable: come from the ancestor /Pages node. + if box, ok := p.Box(MediaBox); !ok || box != (Rect{0, 0, 612, 792}) { + t.Errorf("MediaBox = %+v ok=%v, want inherited {0 0 612 792}", box, ok) + } + if box, ok := p.Box(CropBox); !ok || box != (Rect{1, 1, 611, 791}) { + t.Errorf("CropBox = %+v ok=%v, want inherited {1 1 611 791}", box, ok) + } + // Non-inheritable: ancestor values must not appear. + if box, ok := p.Box(BleedBox); ok { + t.Errorf("BleedBox = %+v, want absent (not inheritable)", box) + } + if box, ok := p.Box(ArtBox); ok { + t.Errorf("ArtBox = %+v, want absent (not inheritable)", box) + } + // The leaf's own TrimBox is reported, not the ancestor's. + if box, ok := p.Box(TrimBox); !ok || box != (Rect{9, 9, 100, 100}) { + t.Errorf("TrimBox = %+v ok=%v, want page-level {9 9 100 100}", box, ok) + } + if boxes := p.Boxes(); len(boxes) != 3 { + t.Errorf("Boxes = %v, want 3 (Media, Crop, Trim)", boxes) + } +} + +func TestPageContentsArrayBrokenEntryErrors(t *testing.T) { + // /Contents references object 5, which is absent from the xref and so + // resolves to null. ContentStreams/Content must fail rather than silently + // return a truncated stream. + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>", + "<< /Type /Page /Parent 2 0 R /Contents [ 4 0 R 5 0 R ] >>", + "<< /Length 5 >>\nstream\nhello\nendstream", + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer r.Close() + p, _ := r.Page(0) + if streams, err := p.ContentStreams(); err == nil { + t.Errorf("ContentStreams = %v, want error for missing entry", streams) + } + if got, err := p.Content(); err == nil { + t.Errorf("Content = %q, want error for missing entry", got) + } +} + +func TestPagesPhantomLeafGuard(t *testing.T) { + // The root /Pages node declares /Count but its /Kids fails to resolve + // (object 9 is absent). It must not be emitted as a phantom leaf page. + for _, root := range []string{ + "<< /Type /Pages /Count 1 /Kids 9 0 R >>", // unresolvable /Kids ref + "<< /Type /Pages /Count 1 >>", // no /Kids at all + "<< /Type /Pages >>", // /Type-only intermediate + } { + data := buildDictPDF(t, []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + root, + }) + r, err := Open(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Open(%s): %v", root, err) + } + n, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount(%s): %v", root, err) + } + if n != 0 { + t.Errorf("root %s: PageCount = %d, want 0 (no phantom leaf)", root, n) + } + r.Close() + } +} + func TestPageIndexOutOfRange(t *testing.T) { data := buildDictPDF(t, []string{ "<< /Type /Catalog /Pages 2 0 R >>",