Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions examples/pageinfo/main.go
Original file line number Diff line number Diff line change
@@ -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 <file.pdf>")
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
}
108 changes: 108 additions & 0 deletions examples/pageinfo/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 22 additions & 2 deletions filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,31 @@ import (
// the file, applies decryption if enabled, then runs the declared filter
// chain.
func (r *Reader) decodeStream(s *Stream) ([]byte, error) {
raw, err := r.rawStreamSlice(s)
if err != nil {
return nil, err
}
return r.applyFilters(s, raw, true)
}

// 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.applyFilters(s, raw, true)
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
}

// applyFilters decrypts (if encrypted is true and an encryption context
Expand Down
7 changes: 7 additions & 0 deletions object.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading