Skip to content

Commit 1bbfc44

Browse files
authored
Add page-tree navigation API
* Add page-tree navigation API 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. * 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. PR #22
1 parent b76486b commit 1bbfc44

14 files changed

Lines changed: 1312 additions & 9 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ or too thin (rsc/pdf, ledongthuc/pdf). pdfdisassembler targets PDF 1.x and
2929
In scope: PDF 1.x and 2.0 reading, classical xref and xref streams,
3030
indirect-object resolution, stream filters (FlateDecode, ASCII85, ASCIIHex,
3131
LZW, RunLength), text-string decoding (PDFDocEncoding, UTF-16BE BOM, UTF-8
32-
BOM), catalog + page tree, DocumentInfo, XMP metadata access, structure
33-
tree traversal, `/Standard` security handler (V2, V4, V5), defensive
34-
parsing.
32+
BOM), catalog + page-tree navigation (page boxes, rotation, resources and
33+
content streams, with inherited attributes resolved along the `/Parent`
34+
chain), DocumentInfo, XMP metadata access, structure tree traversal,
35+
`/Standard` security handler (V2, V4, V5), defensive parsing.
3536

3637
Out of scope: writing PDFs, image filters (DCTDecode/JBIG2/JPX/CCITTFax),
3738
image rendering, font internals, XFA, public-key encryption, signature
@@ -83,7 +84,8 @@ for entry := range r.Objects() {
8384

8485
More complete examples live under [`examples/`](examples): `inspect`
8586
prints a summary of a PDF, `structtree` walks the `/StructTreeRoot` as a
86-
starting point for accessibility tooling.
87+
starting point for accessibility tooling, and `pageinfo` reports per-page
88+
boxes, rotation, resources and content size via the page-tree API.
8789

8890
## Testing
8991

doc.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
// In scope: PDF 1.x and 2.0 reading, classical xref and xref streams,
1111
// indirect-object resolution, stream filters (FlateDecode, ASCII85,
1212
// ASCIIHex, LZW, RunLength), text-string decoding (PDFDocEncoding,
13-
// UTF-16BE BOM, UTF-8 BOM), catalog + page tree, DocumentInfo, XMP
14-
// metadata access, structure-tree traversal, the /Standard security
15-
// handler (V2, V4, V5), defensive xref recovery.
13+
// UTF-16BE BOM, UTF-8 BOM), catalog + page-tree navigation (page boxes,
14+
// rotation, resources and content streams, with inherited attributes
15+
// resolved along the /Parent chain), DocumentInfo, XMP metadata access,
16+
// structure-tree traversal, the /Standard security handler (V2, V4, V5),
17+
// defensive xref recovery.
1618
//
1719
// Out of scope: writing PDFs, image filters (DCTDecode/JBIG2/JPX/CCITTFax),
1820
// image rendering, font internals, XFA, public-key encryption, signature

examples/pageinfo/main.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Command pageinfo prints per-page geometry and content info for a PDF:
2+
// page count and, for each page, its boxes, rotation, resource categories,
3+
// and decoded content size. Intended as a starting point for page-importer
4+
// and layout tooling.
5+
package main
6+
7+
import (
8+
"fmt"
9+
"io"
10+
"log"
11+
"os"
12+
"sort"
13+
14+
"github.com/speedata/pdfdisassembler"
15+
)
16+
17+
func main() {
18+
if len(os.Args) < 2 {
19+
fmt.Fprintln(os.Stderr, "usage: pageinfo <file.pdf>")
20+
os.Exit(2)
21+
}
22+
r, err := pdfdisassembler.OpenFile(os.Args[1])
23+
if err != nil {
24+
log.Fatal(err)
25+
}
26+
defer r.Close()
27+
28+
if err := report(os.Stdout, r); err != nil {
29+
log.Fatal(err)
30+
}
31+
}
32+
33+
// report writes a human-readable page summary for r to w. The page tree is
34+
// walked once via Reader.Pages; inherited attributes (boxes, rotation,
35+
// resources) are resolved by the Page accessors.
36+
func report(w io.Writer, r *pdfdisassembler.Reader) error {
37+
pages, err := r.Pages()
38+
if err != nil {
39+
return err
40+
}
41+
fmt.Fprintf(w, "Pages: %d\n", len(pages))
42+
43+
for _, p := range pages {
44+
// Display pages 1-based, matching how readers number them; the API
45+
// itself is 0-based (Page.Index).
46+
fmt.Fprintf(w, "Page %d:\n", p.Index()+1)
47+
48+
if box, ok := p.Box(pdfdisassembler.MediaBox); ok {
49+
fmt.Fprintf(w, " MediaBox: %g x %g pt\n", box.Width(), box.Height())
50+
}
51+
if box, ok := p.Box(pdfdisassembler.CropBox); ok {
52+
fmt.Fprintf(w, " CropBox: [%g %g %g %g]\n", box.LLX, box.LLY, box.URX, box.URY)
53+
}
54+
if rot := p.Rotation(); rot != 0 {
55+
fmt.Fprintf(w, " Rotation: %d\n", rot)
56+
}
57+
if res, ok := p.Resources(); ok {
58+
keys := res.Keys()
59+
sort.Strings(keys)
60+
fmt.Fprintf(w, " Resources: %v\n", keys)
61+
}
62+
63+
content, err := p.Content()
64+
if err != nil {
65+
fmt.Fprintf(w, " Content: (error: %v)\n", err)
66+
continue
67+
}
68+
fmt.Fprintf(w, " Content: %d bytes decoded\n", len(content))
69+
}
70+
return nil
71+
}

examples/pageinfo/main_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"github.com/speedata/pdfdisassembler"
10+
)
11+
12+
// buildObjPDF assembles 1-based object bodies into a PDF with a classical xref
13+
// and the given trailer dictionary body (without the surrounding << >>).
14+
func buildObjPDF(t *testing.T, objs []string, trailer string) []byte {
15+
t.Helper()
16+
var buf bytes.Buffer
17+
off := func() int { return buf.Len() }
18+
fmt.Fprint(&buf, "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")
19+
offsets := make([]int, len(objs)+1)
20+
for i, body := range objs {
21+
offsets[i+1] = off()
22+
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", i+1, body)
23+
}
24+
xrefOff := off()
25+
fmt.Fprintf(&buf, "xref\n0 %d\n%010d %05d f \n", len(objs)+1, 0, 65535)
26+
for i := 1; i <= len(objs); i++ {
27+
fmt.Fprintf(&buf, "%010d %05d n \n", offsets[i], 0)
28+
}
29+
fmt.Fprintf(&buf, "trailer\n<< /Size %d %s >>\nstartxref\n%d\n%%%%EOF\n",
30+
len(objs)+1, trailer, xrefOff)
31+
return buf.Bytes()
32+
}
33+
34+
// buildPagesPDF builds a two-page document: page 1 inherits its MediaBox and
35+
// Resources from the /Pages root and carries a content stream; page 2 overrides
36+
// MediaBox, adds a CropBox and /Rotate, and has no content.
37+
func buildPagesPDF(t *testing.T) []byte {
38+
const content = "BT (Hi) Tj ET" // 13 bytes
39+
return buildObjPDF(t, []string{
40+
"<< /Type /Catalog /Pages 2 0 R >>",
41+
"<< /Type /Pages /Count 2 /Kids [ 3 0 R 4 0 R ] /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> >>",
42+
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>",
43+
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /CropBox [5 5 195 195] /Rotate 90 >>",
44+
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
45+
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
46+
}, "/Root 1 0 R")
47+
}
48+
49+
func TestReport(t *testing.T) {
50+
r, err := pdfdisassembler.Open(bytes.NewReader(buildPagesPDF(t)))
51+
if err != nil {
52+
t.Fatalf("Open: %v", err)
53+
}
54+
defer r.Close()
55+
56+
var out bytes.Buffer
57+
if err := report(&out, r); err != nil {
58+
t.Fatalf("report: %v", err)
59+
}
60+
got := out.String()
61+
62+
wants := []string{
63+
"Pages: 2",
64+
"Page 1:",
65+
"MediaBox: 612 x 792 pt", // inherited from /Pages root
66+
"Resources: [Font]", // inherited
67+
"Content: 13 bytes decoded",
68+
"Page 2:",
69+
"MediaBox: 200 x 200 pt", // overridden locally
70+
"CropBox: [5 5 195 195]",
71+
"Rotation: 90",
72+
"Content: 0 bytes decoded", // no /Contents
73+
}
74+
for _, w := range wants {
75+
if !strings.Contains(got, w) {
76+
t.Errorf("output missing %q; got:\n%s", w, got)
77+
}
78+
}
79+
80+
// Page 1 has no rotation, so no Rotation line should appear before "Page 2".
81+
page1 := got[strings.Index(got, "Page 1:"):strings.Index(got, "Page 2:")]
82+
if strings.Contains(page1, "Rotation:") {
83+
t.Errorf("page 1 should not print a Rotation line; got:\n%s", page1)
84+
}
85+
}
86+
87+
// TestReportCyclicKidsTerminates feeds a page tree whose /Kids cycles back on
88+
// itself; report must return rather than recurse until the stack overflows.
89+
func TestReportCyclicKidsTerminates(t *testing.T) {
90+
data := buildObjPDF(t, []string{
91+
"<< /Type /Catalog /Pages 2 0 R >>",
92+
"<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>",
93+
"<< /Type /Pages /Kids [ 2 0 R ] >>", // cycle back to obj 2
94+
}, "/Root 1 0 R")
95+
r, err := pdfdisassembler.Open(bytes.NewReader(data))
96+
if err != nil {
97+
t.Fatalf("Open: %v", err)
98+
}
99+
defer r.Close()
100+
101+
var out bytes.Buffer
102+
if err := report(&out, r); err != nil {
103+
t.Fatalf("report: %v", err)
104+
}
105+
if got := out.String(); !strings.Contains(got, "Pages: 0") {
106+
t.Errorf("want \"Pages: 0\", got:\n%s", got)
107+
}
108+
}

filter.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,31 @@ import (
1010
// the file, applies decryption if enabled, then runs the declared filter
1111
// chain.
1212
func (r *Reader) decodeStream(s *Stream) ([]byte, error) {
13+
raw, err := r.rawStreamSlice(s)
14+
if err != nil {
15+
return nil, err
16+
}
17+
return r.applyFilters(s, raw, true)
18+
}
19+
20+
// rawStreamSlice returns the stream's raw bytes as a sub-slice of the file
21+
// buffer (no copy), after bounds-checking the declared extent.
22+
func (r *Reader) rawStreamSlice(s *Stream) ([]byte, error) {
1323
if s.rawOffset < 0 || s.rawOffset+s.rawLength > int64(len(r.buf)) {
1424
return nil, fmt.Errorf("pdfdisassembler: stream %d %d R: bytes out of range", s.objNumber, s.objGeneration)
1525
}
16-
raw := r.buf[s.rawOffset : s.rawOffset+s.rawLength]
17-
return r.applyFilters(s, raw, true)
26+
return r.buf[s.rawOffset : s.rawOffset+s.rawLength], nil
27+
}
28+
29+
// rawStreamBytes returns a copy of the stream's raw bytes (see Stream.RawBytes).
30+
func (r *Reader) rawStreamBytes(s *Stream) ([]byte, error) {
31+
raw, err := r.rawStreamSlice(s)
32+
if err != nil {
33+
return nil, err
34+
}
35+
out := make([]byte, len(raw))
36+
copy(out, raw)
37+
return out, nil
1838
}
1939

2040
// applyFilters decrypts (if encrypted is true and an encryption context

object.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,13 @@ func (s *Stream) RawLength() int64 {
297297
return s.rawLength
298298
}
299299

300+
// RawBytes returns a copy of the stream's raw, undecoded bytes exactly as they
301+
// appear in the file — before any filter or decryption is applied. For the
302+
// decoded content, use Content.
303+
func (s *Stream) RawBytes() ([]byte, error) {
304+
return s.reader.rawStreamBytes(s)
305+
}
306+
300307
// ObjectEntry is yielded by Reader.Objects: an in-use indirect object plus
301308
// its resolved value.
302309
type ObjectEntry struct {

0 commit comments

Comments
 (0)