diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad44c6..258b1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- Reader now supports compressed object streams (`/ObjStm`) and predictor-encoded streams, so PDFs written with a cross-reference stream — the default for most modern producers — can be opened, overlaid and merged instead of failing with `pdf: object N not found in xref` (#35) + - `pdf/objstm.go`: new `/ObjStm` reader — decodes the stream body, parses the `N` object-number/offset header pairs up to `/First`, and resolves objects from it. Decoded streams are cached so sibling objects do not re-inflate the same body, and each object is parsed from its own bounded slice of the body. + - `pdf/reader.go`: `parseXRefEntries` now records type-2 cross-reference entries, `GetObject` resolves them through the object stream, and `MaxObjectNumber` counts them — so `gpdf.Open`, `gpdf.Merge` and `Overlay` all work on these files. Entries from a newer xref section keep winning over an older `/Prev` section. + - `pdf/predictor.go`: `/DecodeParms` predictor support (PNG predictors 10–15 and the TIFF predictor 2). Real cross-reference streams are almost always written with `/Predictor 12`, so without this the decoded xref entries were garbage. + - `pdf/objstm_test.go`, `pdf/predictor_test.go`: coverage for object-stream reads, merging an object-stream PDF, stale xref indices, newest-section-wins precedence, and each predictor filter type +- Incremental update xref entries are now exactly 20 bytes, so the output of `Open` → `Overlay` → `Save` is no longer reported as damaged by strict readers + - `pdf/modifier.go`: `writeIncrementalXRef` wrote `"%010d %05d n \r\n"` — 21 bytes. Readers index the table by fixed-width offset, so the extra byte shifted every following entry and forced a cross-reference reconstruction (`qpdf --check`: `invalid xref entry`). The non-incremental writer in `pdf/xref.go` was already correct; its doc comment showed the wrong width and has been corrected. + - `pdf/modifier_extra_test.go`: regression test asserting every appended xref entry line is 20 bytes + +### Changed +- `_validation` now resolves again: `github.com/hhrutter/lzw` was pinned to `v1.0.4`, a version that no longer exists upstream, and the `go.sum` entry for `github.com/hhrutter/tiff v1.0.4` no longer matched the module. The indirect requirements are realigned with what `pdfcpu v0.9.1` declares (`lzw v1.0.0`, `tiff v1.0.1`); the recorded checksums are verified against `sum.golang.org`. + - `_validation/validation_test.go`: `TestQPDF_ObjectStreamRoundTrip` converts a generated PDF with `qpdf --object-streams=generate`, then merges and overlays it and validates the result with pdfcpu (skipped when `qpdf` is not installed) + ## [1.0.11] - 2026-05-18 ### Fixed diff --git a/_validation/go.mod b/_validation/go.mod index 047b9fb..43e6710 100644 --- a/_validation/go.mod +++ b/_validation/go.mod @@ -10,8 +10,8 @@ require ( ) require ( - github.com/hhrutter/lzw v1.0.4 // indirect - github.com/hhrutter/tiff v1.0.4 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/tiff v1.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/_validation/go.sum b/_validation/go.sum index 0f6841e..c641ca2 100644 --- a/_validation/go.sum +++ b/_validation/go.sum @@ -1,7 +1,7 @@ -github.com/hhrutter/lzw v1.0.4 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= -github.com/hhrutter/lzw v1.0.4/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/tiff v1.0.4 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= -github.com/hhrutter/tiff v1.0.4/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao= diff --git a/_validation/validation_test.go b/_validation/validation_test.go index 246d5bf..fc10d1a 100644 --- a/_validation/validation_test.go +++ b/_validation/validation_test.go @@ -20,6 +20,7 @@ import ( "path/filepath" "testing" + "github.com/gpdf-dev/gpdf" "github.com/gpdf-dev/gpdf/document" "github.com/gpdf-dev/gpdf/pdf" "github.com/gpdf-dev/gpdf/template" @@ -381,3 +382,69 @@ func TestPoppler_PdfToText(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Object stream (/ObjStm) round-trip — see gpdf#35 +// --------------------------------------------------------------------------- + +// TestQPDF_ObjectStreamRoundTrip converts a generated PDF into the compressed +// object stream layout that most real-world producers emit, then reads it back +// through gpdf's merge and overlay paths and validates the result with pdfcpu. +func TestQPDF_ObjectStreamRoundTrip(t *testing.T) { + if _, err := exec.LookPath("qpdf"); err != nil { + t.Skip("qpdf not found; install qpdf to enable this test") + } + + plain, err := genHelloWorld() + if err != nil { + t.Fatalf("generate: %v", err) + } + + tmpDir := t.TempDir() + plainPath := filepath.Join(tmpDir, "plain.pdf") + objStmPath := filepath.Join(tmpDir, "objstm.pdf") + if err := os.WriteFile(plainPath, plain, 0644); err != nil { + t.Fatalf("write file: %v", err) + } + if out, err := exec.Command("qpdf", "--object-streams=generate", plainPath, objStmPath).CombinedOutput(); err != nil { + t.Fatalf("qpdf --object-streams=generate: %v\n%s", err, out) + } + objStm, err := os.ReadFile(objStmPath) + if err != nil { + t.Fatalf("read objstm.pdf: %v", err) + } + + t.Run("merge", func(t *testing.T) { + merged, err := gpdf.Merge([]gpdf.Source{{Data: objStm}, {Data: objStm}}) + if err != nil { + t.Fatalf("merge object stream PDF: %v", err) + } + if err := pdfcpuapi.Validate(bytes.NewReader(merged), nil); err != nil { + t.Errorf("pdfcpu validate merged: %v", err) + } + }) + + t.Run("overlay", func(t *testing.T) { + doc, err := gpdf.Open(objStm) + if err != nil { + t.Fatalf("open object stream PDF: %v", err) + } + err = doc.Overlay(0, func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("overlay") + }) + }) + }) + if err != nil { + t.Fatalf("overlay: %v", err) + } + out, err := doc.Save() + if err != nil { + t.Fatalf("save: %v", err) + } + if err := pdfcpuapi.Validate(bytes.NewReader(out), nil); err != nil { + t.Errorf("pdfcpu validate overlay: %v", err) + } + }) +} diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 6e42753..b01e6bb 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.11" +const Version = "1.0.12" diff --git a/pdf/modifier.go b/pdf/modifier.go index 07cd116..61d66c8 100644 --- a/pdf/modifier.go +++ b/pdf/modifier.go @@ -249,7 +249,11 @@ func (m *Modifier) writeIncrementalXRef(w io.Writer, xref *XRefTable) error { return err } for k := i; k < j; k++ { - line := fmt.Sprintf("%010d %05d n \r\n", entries[k].offset, 0) + // Each line is exactly 20 bytes: 10-digit offset + space + 5-digit + // gen + space + marker + EOL(2), per ISO 32000-2 §7.5.4. An extra + // byte here shifts every following entry and makes readers treat + // the incremental update as damaged. + line := fmt.Sprintf("%010d %05d n\r\n", entries[k].offset, 0) if _, err := io.WriteString(w, line); err != nil { return err } diff --git a/pdf/modifier_extra_test.go b/pdf/modifier_extra_test.go index cd88970..8de78b3 100644 --- a/pdf/modifier_extra_test.go +++ b/pdf/modifier_extra_test.go @@ -1,6 +1,7 @@ package pdf import ( + "bytes" "testing" ) @@ -163,3 +164,68 @@ func TestModifierOverlayOutOfRange(t *testing.T) { t.Error("expected error for out-of-range page overlay") } } + +// TestModifierIncrementalXRefEntryWidth guards the fixed-width xref entry format +// of the incremental update. Every entry line must be exactly 20 bytes: readers +// index into the table by offset, so a single extra byte shifts every following +// entry and makes the update look damaged (ISO 32000-2 §7.5.4). +func TestModifierIncrementalXRefEntryWidth(t *testing.T) { + data := buildTestPDF(t, 2) + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + m := NewModifier(r) + if err := m.OverlayPage(0, []byte("BT /F1 24 Tf 100 400 Td (OVERLAY) Tj ET"), nil); err != nil { + t.Fatalf("OverlayPage: %v", err) + } + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + // Take the appended xref section: "\nxref\ntrailer". + // Match on the leading newline so this does not hit "startxref". + xrefStart := bytes.LastIndex(result, []byte("\nxref\n")) + trailerStart := bytes.LastIndex(result, []byte("trailer")) + if xrefStart < 0 || trailerStart < xrefStart { + t.Fatalf("no incremental xref section found") + } + section := result[xrefStart+len("\nxref\n") : trailerStart] + + entries := 0 + for _, line := range bytes.SplitAfter(section, []byte("\n")) { + if len(line) == 0 { + continue + } + // Subsection headers are "start count\n"; entry lines begin with a + // zero-padded 10-digit offset. Match on the offset rather than the + // terminator so a wrong terminator is reported as a width error. + if !isXRefEntryLine(line) { + continue + } + entries++ + if len(line) != 20 { + t.Errorf("xref entry %q is %d bytes, want exactly 20", line, len(line)) + } + } + if entries == 0 { + t.Fatal("no xref entry lines found in the incremental section") + } +} + +// isXRefEntryLine reports whether a line from an xref section is an entry +// (a 10-digit zero-padded offset followed by a space) rather than a +// "start count" subsection header. +func isXRefEntryLine(line []byte) bool { + if len(line) < 11 || line[10] != ' ' { + return false + } + for _, c := range line[:10] { + if c < '0' || c > '9' { + return false + } + } + return true +} diff --git a/pdf/objstm.go b/pdf/objstm.go new file mode 100644 index 0000000..c13daec --- /dev/null +++ b/pdf/objstm.go @@ -0,0 +1,189 @@ +package pdf + +import ( + "fmt" + "strconv" +) + +// compressedObjRef locates an object stored inside a compressed object stream, +// as recorded by a type-2 cross-reference entry (ISO 32000-1 §7.5.8.3). +type compressedObjRef struct { + streamNum int // object number of the containing /ObjStm + index int // index of the object within that stream +} + +// objStmEntry describes one object inside a decoded object stream. +// start and end are absolute offsets into the decoded body. +type objStmEntry struct { + num int + start int + end int +} + +// objectStream is a decoded /ObjStm: the decompressed body together with the +// object number / offset pairs parsed from its header (ISO 32000-1 §7.5.7). +type objectStream struct { + content []byte + entries []objStmEntry +} + +// getCompressedObject resolves an object stored inside an object stream. +func (r *Reader) getCompressedObject(objNum int, loc compressedObjRef) (Object, error) { + stm, err := r.loadObjectStream(loc.streamNum) + if err != nil { + return nil, fmt.Errorf("pdf: object %d: %w", objNum, err) + } + + entry, ok := stm.entryFor(objNum, loc.index) + if !ok { + return nil, fmt.Errorf("pdf: object %d not found in object stream %d", objNum, loc.streamNum) + } + + // Parse the object from its own slice of the body. Bounding the parser to + // the entry keeps it from reading into the next object — objects inside an + // object stream are self-delimiting and may not be streams themselves. + p := newParser(stm.content[entry.start:entry.end]) + obj, err := p.parseObject() + if err != nil { + return nil, fmt.Errorf("pdf: object %d in object stream %d: %w", objNum, loc.streamNum, err) + } + return obj, nil +} + +// entryFor returns the entry for objNum. The index from the cross-reference +// entry is used when it agrees with the stream header; otherwise the object +// number is looked up directly, since some producers write stale indices. +func (stm *objectStream) entryFor(objNum, index int) (objStmEntry, bool) { + if index >= 0 && index < len(stm.entries) && stm.entries[index].num == objNum { + return stm.entries[index], true + } + for _, e := range stm.entries { + if e.num == objNum { + return e, true + } + } + return objStmEntry{}, false +} + +// loadObjectStream fetches, decodes and parses the header of the /ObjStm with +// the given object number. Decoded streams are cached so that sibling objects +// do not re-inflate the same body. +func (r *Reader) loadObjectStream(streamNum int) (*objectStream, error) { + if stm, ok := r.objStms[streamNum]; ok { + return stm, nil + } + if r.loadingStm == nil { + r.loadingStm = make(map[int]bool) + } + if r.loadingStm[streamNum] { + return nil, fmt.Errorf("object stream %d is self-referential", streamNum) + } + r.loadingStm[streamNum] = true + defer delete(r.loadingStm, streamNum) + + obj, err := r.GetObject(streamNum) + if err != nil { + return nil, fmt.Errorf("object stream %d: %w", streamNum, err) + } + s, ok := obj.(Stream) + if !ok { + return nil, fmt.Errorf("object %d is not a stream", streamNum) + } + if typ, ok := s.Dict[Name("Type")].(Name); ok && typ != Name("ObjStm") { + return nil, fmt.Errorf("object %d is /%s, not /ObjStm", streamNum, typ) + } + + n, err := r.intFromDict(s.Dict, "N") + if err != nil { + return nil, fmt.Errorf("object stream %d: %w", streamNum, err) + } + first, err := r.intFromDict(s.Dict, "First") + if err != nil { + return nil, fmt.Errorf("object stream %d: %w", streamNum, err) + } + + content, err := r.decodeStreamContent(s) + if err != nil { + return nil, fmt.Errorf("decode object stream %d: %w", streamNum, err) + } + if first < 0 || first > len(content) { + return nil, fmt.Errorf("object stream %d: /First %d out of range (body is %d bytes)", streamNum, first, len(content)) + } + + entries, err := parseObjStmHeader(content[:first], n, first, len(content)) + if err != nil { + return nil, fmt.Errorf("object stream %d: %w", streamNum, err) + } + + stm := &objectStream{content: content, entries: entries} + if r.objStms == nil { + r.objStms = make(map[int]*objectStream) + } + r.objStms[streamNum] = stm + return stm, nil +} + +// parseObjStmHeader reads the N pairs of "objectNumber relativeOffset" that +// precede /First and converts them to absolute [start, end) ranges in the body. +func parseObjStmHeader(header []byte, n, first, bodyLen int) ([]objStmEntry, error) { + if n < 0 { + return nil, fmt.Errorf("/N is negative (%d)", n) + } + + entries := make([]objStmEntry, 0, n) + p := newParser(header) + for i := 0; i < n; i++ { + p.skipWhitespaceAndComments() + numStr, isReal, err := p.scanNumber() + if err != nil { + return nil, fmt.Errorf("header pair %d: object number: %w", i, err) + } + if isReal { + return nil, fmt.Errorf("header pair %d: object number %q is not an integer", i, numStr) + } + p.skipWhitespaceAndComments() + offStr, isReal, err := p.scanNumber() + if err != nil { + return nil, fmt.Errorf("header pair %d: offset: %w", i, err) + } + if isReal { + return nil, fmt.Errorf("header pair %d: offset %q is not an integer", i, offStr) + } + + num, _ := strconv.Atoi(numStr) + off, _ := strconv.Atoi(offStr) + start := first + off + if off < 0 || start > bodyLen { + return nil, fmt.Errorf("header pair %d: offset %d out of range", i, off) + } + entries = append(entries, objStmEntry{num: num, start: start, end: bodyLen}) + } + + // Offsets are required to be ascending, so each object ends where the next + // one begins. Leave an entry running to the end of the body if a producer + // emitted them out of order. + for i := 0; i+1 < len(entries); i++ { + if entries[i+1].start >= entries[i].start { + entries[i].end = entries[i+1].start + } + } + return entries, nil +} + +// intFromDict reads an integer entry from a dict, resolving it if it is an +// indirect reference. +func (r *Reader) intFromDict(d Dict, key string) (int, error) { + obj, ok := d[Name(key)] + if !ok { + return 0, fmt.Errorf("missing /%s", key) + } + resolved, err := r.Resolve(obj) + if err != nil { + return 0, fmt.Errorf("resolve /%s: %w", key, err) + } + v, ok := resolved.(Integer) + if !ok { + return 0, fmt.Errorf("/%s is not an integer", key) + } + return int(v), nil +} diff --git a/pdf/objstm_test.go b/pdf/objstm_test.go new file mode 100644 index 0000000..7b792cd --- /dev/null +++ b/pdf/objstm_test.go @@ -0,0 +1,315 @@ +package pdf + +import ( + "bytes" + "compress/zlib" + "fmt" + "strings" + "testing" +) + +// buildObjStmPDF creates a PDF 1.5 whose catalog, page tree and page objects all +// live inside a compressed object stream, referenced by type-2 cross-reference +// entries — the layout most modern producers emit by default. +// +// Object layout: +// +// 1, 2, 3 — catalog / pages / page, stored inside the object stream +// 4 — the /ObjStm itself +// 5 — the /XRef stream +func buildObjStmPDF(t *testing.T, compressStm bool) []byte { + t.Helper() + + objects := []struct { + num int + body string + }{ + {1, "<< /Type /Catalog /Pages 2 0 R >>"}, + {2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"}, + {3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"}, + } + + // Build the object stream body first so the header offsets are known. + var header, body strings.Builder + for _, o := range objects { + fmt.Fprintf(&header, "%d %d ", o.num, body.Len()) + body.WriteString(o.body) + body.WriteString("\n") + } + first := header.Len() + objStmContent := []byte(header.String() + body.String()) + + objStmFilter := "" + if compressStm { + var compressed bytes.Buffer + zw := zlib.NewWriter(&compressed) + _, _ = zw.Write(objStmContent) + _ = zw.Close() + objStmContent = compressed.Bytes() + objStmFilter = " /Filter /FlateDecode" + } + + var pdf bytes.Buffer + pdf.WriteString("%PDF-1.5\n") + + objStmOffset := pdf.Len() + fmt.Fprintf(&pdf, "4 0 obj\n<< /Type /ObjStm /N %d /First %d /Length %d%s >>\nstream\n", + len(objects), first, len(objStmContent), objStmFilter) + pdf.Write(objStmContent) + pdf.WriteString("\nendstream\nendobj\n") + + xrefOffset := pdf.Len() + + // /W [1 2 1]: type, 2-byte field, 1-byte field. + var xrefContent bytes.Buffer + writeEntry := func(typ byte, f2 int, f3 byte) { + xrefContent.WriteByte(typ) + xrefContent.WriteByte(byte(f2 >> 8)) + xrefContent.WriteByte(byte(f2 & 0xFF)) + xrefContent.WriteByte(f3) + } + writeEntry(0, 0, 0) // obj 0: free + writeEntry(2, 4, 0) // obj 1: in object stream 4, index 0 + writeEntry(2, 4, 1) // obj 2: in object stream 4, index 1 + writeEntry(2, 4, 2) // obj 3: in object stream 4, index 2 + writeEntry(1, objStmOffset, 0) // obj 4: the object stream + writeEntry(1, xrefOffset, 0) // obj 5: the xref stream itself + + fmt.Fprintf(&pdf, "5 0 obj\n<< /Type /XRef /Size 6 /W [1 2 1] /Root 1 0 R /Length %d >>\nstream\n", xrefContent.Len()) + pdf.Write(xrefContent.Bytes()) + pdf.WriteString("\nendstream\nendobj\n") + + fmt.Fprintf(&pdf, "startxref\n%d\n%%%%EOF\n", xrefOffset) + return pdf.Bytes() +} + +func TestReaderObjectStream(t *testing.T) { + for _, tc := range []struct { + name string + compress bool + }{ + {"FlateDecode", true}, + {"uncompressed", false}, + } { + t.Run(tc.name, func(t *testing.T) { + r, err := NewReader(buildObjStmPDF(t, tc.compress)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // The catalog itself is a type-2 object, so this exercises the + // compressed path during construction. + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 1 { + t.Fatalf("PageCount = %d, want 1", count) + } + + page, err := r.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if typ, _ := page[Name("Type")].(Name); typ != Name("Page") { + t.Errorf("page /Type = %v, want /Page", page[Name("Type")]) + } + + info, err := r.Page(0) + if err != nil { + t.Fatalf("Page: %v", err) + } + if info.MediaBox.URX != 612 || info.MediaBox.URY != 792 { + t.Errorf("MediaBox = %+v, want 612x792", info.MediaBox) + } + + // Object 3 lives in the object stream; object 4 is the stream itself. + if max := r.MaxObjectNumber(); max != 5 { + t.Errorf("MaxObjectNumber = %d, want 5", max) + } + }) + } +} + +func TestReaderObjectStreamCaching(t *testing.T) { + r, err := NewReader(buildObjStmPDF(t, true)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // Resolving three sibling objects must decode the containing stream once. + for _, num := range []int{1, 2, 3} { + if _, err := r.GetObject(num); err != nil { + t.Fatalf("GetObject(%d): %v", num, err) + } + } + if len(r.objStms) != 1 { + t.Errorf("decoded object streams = %d, want 1", len(r.objStms)) + } + if _, ok := r.objStms[4]; !ok { + t.Error("object stream 4 not cached") + } +} + +func TestMergeObjectStreamPDF(t *testing.T) { + data := buildObjStmPDF(t, true) + + merged, err := MergePDFs([]MergeSource{{Data: data}, {Data: data}}, MergeConfig{}) + if err != nil { + t.Fatalf("MergePDFs: %v", err) + } + + r, err := NewReader(merged) + if err != nil { + t.Fatalf("NewReader(merged): %v", err) + } + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 2 { + t.Errorf("merged PageCount = %d, want 2", count) + } +} + +func TestReaderObjectStreamStaleIndex(t *testing.T) { + r, err := NewReader(buildObjStmPDF(t, true)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // Point object 3 at an index that belongs to a different object. The lookup + // must fall back to matching by object number rather than returning object 1. + r.compressed[3] = compressedObjRef{streamNum: 4, index: 0} + obj, err := r.GetObject(3) + if err != nil { + t.Fatalf("GetObject(3): %v", err) + } + d, ok := obj.(Dict) + if !ok { + t.Fatalf("GetObject(3) = %T, want Dict", obj) + } + if typ, _ := d[Name("Type")].(Name); typ != Name("Page") { + t.Errorf("/Type = %v, want /Page", d[Name("Type")]) + } +} + +func TestReaderObjectStreamMissingObject(t *testing.T) { + r, err := NewReader(buildObjStmPDF(t, true)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + r.compressed[99] = compressedObjRef{streamNum: 4, index: 0} + if _, err := r.GetObject(99); err == nil { + t.Fatal("GetObject(99) succeeded, want error") + } +} + +func TestReaderObjectStreamNotAStream(t *testing.T) { + r, err := NewReader(buildObjStmPDF(t, true)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + // Object 1 is a plain dict, not an /ObjStm. + r.compressed[99] = compressedObjRef{streamNum: 1, index: 0} + _, err = r.GetObject(99) + if err == nil || !strings.Contains(err.Error(), "not a stream") { + t.Fatalf("err = %v, want 'not a stream'", err) + } +} + +func TestParseXRefEntriesType2(t *testing.T) { + r := &Reader{ + xref: make(map[int]int64), + compressed: make(map[int]compressedObjRef), + } + + // w=[1,2,1], entrySize=4. + content := []byte{ + 2, 0x00, 0x0A, 3, // obj 5: type=2, stream 10, index 3 + 1, 0x01, 0x00, 0, // obj 6: type=1, offset 256 + 0, 0x00, 0x00, 0, // obj 7: free + } + r.parseXRefEntries(content, []int{5, 3}, [3]int{1, 2, 1}, 4) + + want := compressedObjRef{streamNum: 10, index: 3} + if got := r.compressed[5]; got != want { + t.Errorf("compressed[5] = %+v, want %+v", got, want) + } + if _, ok := r.xref[5]; ok { + t.Error("xref[5] should not be set for a type-2 entry") + } + if r.xref[6] != 256 { + t.Errorf("xref[6] = %d, want 256", r.xref[6]) + } + if _, ok := r.compressed[7]; ok { + t.Error("compressed[7] should not exist (free entry)") + } +} + +func TestParseXRefEntriesNewestWins(t *testing.T) { + r := &Reader{ + xref: map[int]int64{5: 100}, + compressed: map[int]compressedObjRef{6: {streamNum: 1, index: 0}}, + } + + // An older /Prev section must not override entries already recorded. + content := []byte{ + 2, 0x00, 0x0A, 3, // obj 5: type=2 — must not displace xref[5] + 2, 0x00, 0x0B, 7, // obj 6: type=2 — must not displace compressed[6] + } + r.parseXRefEntries(content, []int{5, 2}, [3]int{1, 2, 1}, 4) + + if r.xref[5] != 100 { + t.Errorf("xref[5] = %d, want 100", r.xref[5]) + } + if _, ok := r.compressed[5]; ok { + t.Error("compressed[5] should not have been set") + } + if got := r.compressed[6]; got.streamNum != 1 || got.index != 0 { + t.Errorf("compressed[6] = %+v, want {1 0}", got) + } +} + +func TestParseObjStmHeader(t *testing.T) { + header := []byte("1 0 2 10 3 25 ") + entries, err := parseObjStmHeader(header, 3, 20, 60) + if err != nil { + t.Fatalf("parseObjStmHeader: %v", err) + } + + want := []objStmEntry{ + {num: 1, start: 20, end: 30}, + {num: 2, start: 30, end: 45}, + {num: 3, start: 45, end: 60}, + } + for i, w := range want { + if entries[i] != w { + t.Errorf("entries[%d] = %+v, want %+v", i, entries[i], w) + } + } +} + +func TestParseObjStmHeaderErrors(t *testing.T) { + tests := []struct { + name string + header string + n int + first int + bodyLen int + }{ + {"negative N", "", -1, 0, 0}, + {"truncated header", "1 0 2", 3, 10, 40}, + {"offset past body", "1 999", 1, 10, 40}, + {"non-integer offset", "1 1.5", 1, 10, 40}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseObjStmHeader([]byte(tc.header), tc.n, tc.first, tc.bodyLen); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} diff --git a/pdf/predictor.go b/pdf/predictor.go new file mode 100644 index 0000000..73323e3 --- /dev/null +++ b/pdf/predictor.go @@ -0,0 +1,191 @@ +package pdf + +import "fmt" + +// predictorParams holds the /DecodeParms entries that affect predictor decoding +// (ISO 32000-1 §7.4.4.4). Zero value means "no predictor". +type predictorParams struct { + predictor int + colors int + bitsPerComponent int + columns int +} + +// readPredictorParams extracts predictor settings from a /DecodeParms dict, +// applying the defaults defined by the specification. +func (r *Reader) readPredictorParams(d Dict) (predictorParams, error) { + p := predictorParams{predictor: 1, colors: 1, bitsPerComponent: 8, columns: 1} + if d == nil { + return p, nil + } + + fields := []struct { + key string + dst *int + }{ + {"Predictor", &p.predictor}, + {"Colors", &p.colors}, + {"BitsPerComponent", &p.bitsPerComponent}, + {"Columns", &p.columns}, + } + for _, f := range fields { + if _, ok := d[Name(f.key)]; !ok { + continue + } + v, err := r.intFromDict(d, f.key) + if err != nil { + return p, err + } + *f.dst = v + } + + if p.colors < 1 || p.bitsPerComponent < 1 || p.columns < 1 { + return p, fmt.Errorf("pdf: invalid /DecodeParms (Colors=%d BitsPerComponent=%d Columns=%d)", + p.colors, p.bitsPerComponent, p.columns) + } + return p, nil +} + +// applyPredictor reverses the predictor applied before compression. +// Predictor 1 (or absent) is a no-op, 2 is the TIFF predictor, and 10–15 are +// the PNG predictors, which record the filter type per row. +func applyPredictor(data []byte, p predictorParams) ([]byte, error) { + switch { + case p.predictor <= 1: + return data, nil + case p.predictor == 2: + return applyTIFFPredictor(data, p) + case p.predictor >= 10: + return applyPNGPredictor(data, p) + default: + return nil, fmt.Errorf("pdf: unsupported /Predictor %d", p.predictor) + } +} + +// rowLength returns the number of bytes in one unfiltered row. +func (p predictorParams) rowLength() int { + bits := p.columns * p.colors * p.bitsPerComponent + return (bits + 7) / 8 +} + +// pixelBytes returns the distance in bytes to the preceding pixel, at least 1. +func (p predictorParams) pixelBytes() int { + n := (p.colors*p.bitsPerComponent + 7) / 8 + if n < 1 { + return 1 + } + return n +} + +// applyPNGPredictor reverses PNG row filtering (RFC 2083 §6). Each encoded row +// is one filter-type byte followed by rowLen filtered bytes. +func applyPNGPredictor(data []byte, p predictorParams) ([]byte, error) { + rowLen := p.rowLength() + if rowLen < 1 { + return nil, fmt.Errorf("pdf: predictor row length is zero") + } + bpp := p.pixelBytes() + + out := make([]byte, 0, len(data)) + prev := make([]byte, rowLen) + row := make([]byte, rowLen) + + for pos := 0; pos < len(data); pos += 1 + rowLen { + filter := data[pos] + end := pos + 1 + rowLen + if end > len(data) { + // Tolerate a truncated final row rather than discarding the + // rows already decoded — some producers pad inconsistently. + end = len(data) + } + n := copy(row, data[pos+1:end]) + for i := n; i < rowLen; i++ { + row[i] = 0 + } + + if err := unfilterPNGRow(filter, row, prev, bpp); err != nil { + return nil, err + } + + out = append(out, row[:n]...) + copy(prev, row) + } + return out, nil +} + +// unfilterPNGRow reverses one row's filter in place. +func unfilterPNGRow(filter byte, row, prev []byte, bpp int) error { + switch filter { + case 0: // None + case 1: // Sub + for i := bpp; i < len(row); i++ { + row[i] += row[i-bpp] + } + case 2: // Up + for i := range row { + row[i] += prev[i] + } + case 3: // Average + for i := range row { + left := 0 + if i >= bpp { + left = int(row[i-bpp]) + } + row[i] += byte((left + int(prev[i])) / 2) + } + case 4: // Paeth + for i := range row { + var left, upLeft byte + if i >= bpp { + left = row[i-bpp] + upLeft = prev[i-bpp] + } + row[i] += paeth(left, prev[i], upLeft) + } + default: + return fmt.Errorf("pdf: unsupported PNG predictor filter type %d", filter) + } + return nil +} + +// paeth is the PNG Paeth predictor function. +func paeth(a, b, c byte) byte { + p := int(a) + int(b) - int(c) + pa, pb, pc := abs(p-int(a)), abs(p-int(b)), abs(p-int(c)) + if pa <= pb && pa <= pc { + return a + } + if pb <= pc { + return b + } + return c +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// applyTIFFPredictor reverses TIFF predictor 2, which stores each component as +// the difference from the same component of the previous pixel. +func applyTIFFPredictor(data []byte, p predictorParams) ([]byte, error) { + if p.bitsPerComponent != 8 { + return nil, fmt.Errorf("pdf: TIFF predictor supports 8 bits per component only, got %d", p.bitsPerComponent) + } + + rowLen := p.rowLength() + out := make([]byte, len(data)) + copy(out, data) + for start := 0; start < len(out); start += rowLen { + end := start + rowLen + if end > len(out) { + end = len(out) + } + for i := start + p.colors; i < end; i++ { + out[i] += out[i-p.colors] + } + } + return out, nil +} diff --git a/pdf/predictor_test.go b/pdf/predictor_test.go new file mode 100644 index 0000000..2b63908 --- /dev/null +++ b/pdf/predictor_test.go @@ -0,0 +1,264 @@ +package pdf + +import ( + "bytes" + "compress/zlib" + "fmt" + "testing" +) + +// encodePNGUp applies the PNG "Up" filter to rows of rowLen bytes, producing the +// representation an xref stream with /Predictor 12 carries on disk. +func encodePNGUp(raw []byte, rowLen int) []byte { + var out bytes.Buffer + prev := make([]byte, rowLen) + for start := 0; start < len(raw); start += rowLen { + end := start + rowLen + if end > len(raw) { + end = len(raw) + } + row := raw[start:end] + out.WriteByte(2) // filter type: Up + for i, b := range row { + out.WriteByte(b - prev[i]) + } + prev = make([]byte, rowLen) + copy(prev, row) + } + return out.Bytes() +} + +// buildPredictedXRefPDF builds a PDF whose xref stream is Flate-compressed with +// a PNG "Up" predictor — the encoding emitted by nearly every modern producer. +func buildPredictedXRefPDF(t *testing.T) []byte { + t.Helper() + + var pdf bytes.Buffer + pdf.WriteString("%PDF-1.5\n") + + obj1 := pdf.Len() + pdf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") + obj2 := pdf.Len() + pdf.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n") + obj3 := pdf.Len() + pdf.WriteString("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] >>\nendobj\n") + xrefOffset := pdf.Len() + + // /W [1 2 1] → 4-byte rows. + var raw bytes.Buffer + writeEntry := func(typ byte, f2 int, f3 byte) { + raw.WriteByte(typ) + raw.WriteByte(byte(f2 >> 8)) + raw.WriteByte(byte(f2 & 0xFF)) + raw.WriteByte(f3) + } + writeEntry(0, 0, 0) + writeEntry(1, obj1, 0) + writeEntry(1, obj2, 0) + writeEntry(1, obj3, 0) + writeEntry(1, xrefOffset, 0) + + var compressed bytes.Buffer + zw := zlib.NewWriter(&compressed) + _, _ = zw.Write(encodePNGUp(raw.Bytes(), 4)) + _ = zw.Close() + + fmt.Fprintf(&pdf, "4 0 obj\n<< /Type /XRef /Size 5 /W [1 2 1] /Root 1 0 R /Filter /FlateDecode "+ + "/DecodeParms << /Predictor 12 /Columns 4 >> /Length %d >>\nstream\n", compressed.Len()) + pdf.Write(compressed.Bytes()) + pdf.WriteString("\nendstream\nendobj\n") + + fmt.Fprintf(&pdf, "startxref\n%d\n%%%%EOF\n", xrefOffset) + return pdf.Bytes() +} + +func TestReaderXRefStreamWithPredictor(t *testing.T) { + r, err := NewReader(buildPredictedXRefPDF(t)) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 1 { + t.Fatalf("PageCount = %d, want 1", count) + } + + info, err := r.Page(0) + if err != nil { + t.Fatalf("Page: %v", err) + } + if info.MediaBox.URX != 595 || info.MediaBox.URY != 842 { + t.Errorf("MediaBox = %+v, want 595x842", info.MediaBox) + } +} + +func TestApplyPNGPredictor(t *testing.T) { + raw := []byte{ + 1, 2, 3, 4, + 5, 7, 9, 11, + 0, 0, 0, 0, + } + encoded := encodePNGUp(raw, 4) + + got, err := applyPredictor(encoded, predictorParams{predictor: 12, colors: 1, bitsPerComponent: 8, columns: 4}) + if err != nil { + t.Fatalf("applyPredictor: %v", err) + } + if !bytes.Equal(got, raw) { + t.Errorf("got %v, want %v", got, raw) + } +} + +func TestApplyPNGPredictorFilterTypes(t *testing.T) { + p := predictorParams{predictor: 15, colors: 1, bitsPerComponent: 8, columns: 3} + + tests := []struct { + name string + encoded []byte + want []byte + }{ + // filter 0 (None): bytes pass through. + {"none", []byte{0, 10, 20, 30}, []byte{10, 20, 30}}, + // filter 1 (Sub): each byte adds the one bpp to its left. + {"sub", []byte{1, 10, 5, 5}, []byte{10, 15, 20}}, + // filter 3 (Average) on the first row: prev row is zero, so each byte + // adds half of the byte to its left. + {"average", []byte{3, 10, 5, 5}, []byte{10, 10, 10}}, + // filter 4 (Paeth) on the first row degenerates to Sub. + {"paeth", []byte{4, 10, 5, 5}, []byte{10, 15, 20}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := applyPredictor(tc.encoded, p) + if err != nil { + t.Fatalf("applyPredictor: %v", err) + } + if !bytes.Equal(got, tc.want) { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestApplyPredictorPassthroughAndErrors(t *testing.T) { + data := []byte{1, 2, 3, 4} + + got, err := applyPredictor(data, predictorParams{predictor: 1, colors: 1, bitsPerComponent: 8, columns: 4}) + if err != nil { + t.Fatalf("predictor 1: %v", err) + } + if !bytes.Equal(got, data) { + t.Errorf("predictor 1 modified data: %v", got) + } + + if _, err := applyPredictor(data, predictorParams{predictor: 5, colors: 1, bitsPerComponent: 8, columns: 4}); err == nil { + t.Error("predictor 5: expected error") + } + + // Unknown PNG row filter type. + if _, err := applyPredictor([]byte{9, 1, 2, 3}, predictorParams{predictor: 12, colors: 1, bitsPerComponent: 8, columns: 3}); err == nil { + t.Error("filter type 9: expected error") + } +} + +func TestApplyTIFFPredictor(t *testing.T) { + // Two rows of 3 single-byte components, stored as deltas. + encoded := []byte{ + 10, 5, 5, + 1, 1, 1, + } + p := predictorParams{predictor: 2, colors: 1, bitsPerComponent: 8, columns: 3} + + got, err := applyPredictor(encoded, p) + if err != nil { + t.Fatalf("applyPredictor: %v", err) + } + want := []byte{10, 15, 20, 1, 2, 3} + if !bytes.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } + + p.bitsPerComponent = 4 + if _, err := applyPredictor(encoded, p); err == nil { + t.Error("4 bits per component: expected error") + } +} + +func TestReadPredictorParams(t *testing.T) { + r := &Reader{} + + p, err := r.readPredictorParams(nil) + if err != nil { + t.Fatalf("nil dict: %v", err) + } + if p.predictor != 1 || p.colors != 1 || p.bitsPerComponent != 8 || p.columns != 1 { + t.Errorf("defaults = %+v", p) + } + + p, err = r.readPredictorParams(Dict{ + Name("Predictor"): Integer(12), + Name("Colors"): Integer(3), + Name("BitsPerComponent"): Integer(8), + Name("Columns"): Integer(4), + }) + if err != nil { + t.Fatalf("full dict: %v", err) + } + if p.predictor != 12 || p.colors != 3 || p.columns != 4 { + t.Errorf("params = %+v", p) + } + if got := p.pixelBytes(); got != 3 { + t.Errorf("pixelBytes = %d, want 3", got) + } + if got := p.rowLength(); got != 12 { + t.Errorf("rowLength = %d, want 12", got) + } + + if _, err := r.readPredictorParams(Dict{Name("Columns"): Integer(0)}); err == nil { + t.Error("Columns=0: expected error") + } + if _, err := r.readPredictorParams(Dict{Name("Predictor"): Name("bogus")}); err == nil { + t.Error("non-integer Predictor: expected error") + } +} + +func TestDecodeParmsList(t *testing.T) { + r := &Reader{} + + // Single dict form. + parms, err := r.decodeParmsList(Dict{ + Name("DecodeParms"): Dict{Name("Predictor"): Integer(12)}, + }, 1) + if err != nil { + t.Fatalf("single dict: %v", err) + } + if parms[0] == nil { + t.Fatal("parms[0] is nil") + } + + // Array form with a null for the filter that takes no parameters. + parms, err = r.decodeParmsList(Dict{ + Name("DecodeParms"): Array{Null{}, Dict{Name("Predictor"): Integer(12)}}, + }, 2) + if err != nil { + t.Fatalf("array: %v", err) + } + if parms[0] != nil { + t.Error("parms[0] should be nil for a null entry") + } + if parms[1] == nil { + t.Error("parms[1] should be set") + } + + // Absent. + parms, err = r.decodeParmsList(Dict{}, 1) + if err != nil { + t.Fatalf("absent: %v", err) + } + if parms[0] != nil { + t.Error("parms[0] should be nil when /DecodeParms is absent") + } +} diff --git a/pdf/reader.go b/pdf/reader.go index 03ee71c..f13d897 100644 --- a/pdf/reader.go +++ b/pdf/reader.go @@ -11,12 +11,15 @@ import ( // It provides random access to objects via the cross-reference table // and can traverse the page tree to enumerate pages. type Reader struct { - data []byte - xref map[int]int64 // object number -> byte offset - trailer Dict // the trailer dictionary - root ObjectRef // catalog reference - pages []PageInfo // flattened page list (populated lazily) - cache map[int]Object // parsed object cache + data []byte + xref map[int]int64 // object number -> byte offset (type-1 entries) + compressed map[int]compressedObjRef // object number -> location in an /ObjStm (type-2 entries) + objStms map[int]*objectStream // decoded /ObjStm cache, keyed by stream object number + loadingStm map[int]bool // guards against self-referential /ObjStm chains + trailer Dict // the trailer dictionary + root ObjectRef // catalog reference + pages []PageInfo // flattened page list (populated lazily) + cache map[int]Object // parsed object cache } // PageInfo describes a page in the existing PDF. @@ -30,9 +33,12 @@ type PageInfo struct { // It parses the xref table and trailer to enable object lookups. func NewReader(data []byte) (*Reader, error) { r := &Reader{ - data: data, - xref: make(map[int]int64), - cache: make(map[int]Object), + data: data, + xref: make(map[int]int64), + compressed: make(map[int]compressedObjRef), + objStms: make(map[int]*objectStream), + loadingStm: make(map[int]bool), + cache: make(map[int]Object), } if err := r.parseXRefAndTrailer(); err != nil { @@ -73,23 +79,32 @@ func (r *Reader) Page(i int) (PageInfo, error) { } // GetObject reads and parses the indirect object with the given number. -// Results are cached for repeated lookups. +// The object may be stored directly in the file or inside a compressed +// object stream (/ObjStm). Results are cached for repeated lookups. func (r *Reader) GetObject(objNum int) (Object, error) { if obj, ok := r.cache[objNum]; ok { return obj, nil } - offset, ok := r.xref[objNum] - if !ok { - return nil, fmt.Errorf("pdf: object %d not found in xref", objNum) + if offset, ok := r.xref[objNum]; ok { + obj, err := r.parseIndirectObjectAt(offset) + if err != nil { + return nil, err + } + r.cache[objNum] = obj + return obj, nil } - obj, err := r.parseIndirectObjectAt(offset) - if err != nil { - return nil, err + if loc, ok := r.compressed[objNum]; ok { + obj, err := r.getCompressedObject(objNum, loc) + if err != nil { + return nil, err + } + r.cache[objNum] = obj + return obj, nil } - r.cache[objNum] = obj - return obj, nil + + return nil, fmt.Errorf("pdf: object %d not found in xref", objNum) } // Resolve dereferences an Object: if it is an ObjectRef, fetch the actual object. @@ -133,7 +148,8 @@ func (r *Reader) Data() []byte { return r.data } -// MaxObjectNumber returns the highest object number found in the xref table. +// MaxObjectNumber returns the highest object number found in the xref table, +// including objects stored inside compressed object streams. func (r *Reader) MaxObjectNumber() int { max := 0 for n := range r.xref { @@ -141,6 +157,11 @@ func (r *Reader) MaxObjectNumber() int { max = n } } + for n := range r.compressed { + if n > max { + max = n + } + } return max } @@ -408,6 +429,9 @@ func parseXRefIndices(d Dict) []int { } // parseXRefEntries reads xref entries from stream content and populates the xref table. +// Type-1 entries record a byte offset; type-2 entries record a location inside a +// compressed object stream. Entries seen in a newer section always win, so an +// object already recorded is never overwritten by an older /Prev section. func (r *Reader) parseXRefEntries(content []byte, indices []int, w [3]int, entrySize int) { pos := 0 for i := 0; i < len(indices)-1; i += 2 { @@ -421,9 +445,23 @@ func (r *Reader) parseXRefEntries(content []byte, indices []int, w [3]int, entry pos += entrySize objNum := startObj + j - if fields[0] == 1 { - if _, exists := r.xref[objNum]; !exists { - r.xref[objNum] = fields[1] + if _, exists := r.xref[objNum]; exists { + continue + } + if _, exists := r.compressed[objNum]; exists { + continue + } + + switch fields[0] { + case 1: + r.xref[objNum] = fields[1] + case 2: + if r.compressed == nil { + r.compressed = make(map[int]compressedObjRef) + } + r.compressed[objNum] = compressedObjRef{ + streamNum: int(fields[1]), + index: int(fields[2]), } } } @@ -496,27 +534,25 @@ func (r *Reader) parseIndirectObjectAt(offset int64) (Object, error) { return obj, nil } -// decodeStreamContent decompresses a stream's content based on /Filter. +// decodeStreamContent decompresses a stream's content based on /Filter, +// applying any predictor given by the matching /DecodeParms entry. func (r *Reader) decodeStreamContent(s Stream) ([]byte, error) { filterObj, hasFilter := s.Dict[Name("Filter")] if !hasFilter { return s.Content, nil } - filters := []string{} - switch f := filterObj.(type) { - case Name: - filters = []string{string(f)} - case Array: - for _, item := range f { - if n, ok := item.(Name); ok { - filters = append(filters, string(n)) - } - } + filters, err := r.filterNames(filterObj) + if err != nil { + return nil, err + } + parms, err := r.decodeParmsList(s.Dict, len(filters)) + if err != nil { + return nil, err } data := s.Content - for _, filter := range filters { + for i, filter := range filters { switch filter { case "FlateDecode": decoded, err := decompressFlate(data) @@ -527,10 +563,81 @@ func (r *Reader) decodeStreamContent(s Stream) ([]byte, error) { default: return nil, fmt.Errorf("pdf: unsupported filter %q", filter) } + + params, err := r.readPredictorParams(parms[i]) + if err != nil { + return nil, err + } + data, err = applyPredictor(data, params) + if err != nil { + return nil, err + } } return data, nil } +// filterNames normalises /Filter, which may be a single name or an array. +func (r *Reader) filterNames(obj Object) ([]string, error) { + resolved, err := r.Resolve(obj) + if err != nil { + return nil, err + } + + switch f := resolved.(type) { + case Name: + return []string{string(f)}, nil + case Array: + names := make([]string, 0, len(f)) + for _, item := range f { + if n, ok := item.(Name); ok { + names = append(names, string(n)) + } + } + return names, nil + default: + return nil, nil + } +} + +// decodeParmsList returns one /DecodeParms dict per filter. Entries are nil +// where the stream supplies no parameters. /DecodeParms may be a single dict +// (when there is one filter) or an array parallel to /Filter, with null for +// filters that take no parameters. +func (r *Reader) decodeParmsList(d Dict, numFilters int) ([]Dict, error) { + parms := make([]Dict, numFilters) + + obj, ok := d[Name("DecodeParms")] + if !ok { + obj, ok = d[Name("DP")] + } + if !ok { + return parms, nil + } + + resolved, err := r.Resolve(obj) + if err != nil { + return nil, err + } + + switch v := resolved.(type) { + case Dict: + if numFilters > 0 { + parms[0] = v + } + case Array: + for i := 0; i < len(v) && i < numFilters; i++ { + item, err := r.Resolve(v[i]) + if err != nil { + return nil, err + } + if pd, ok := item.(Dict); ok { + parms[i] = pd + } + } + } + return parms, nil +} + // --------------------------------------------------------------------------- // Internal: page tree traversal // --------------------------------------------------------------------------- @@ -654,7 +761,7 @@ func (r *Reader) parseRectangle(obj Object) (Rectangle, error) { // String returns a summary of the reader state. func (r *Reader) String() string { var b strings.Builder - fmt.Fprintf(&b, "Reader: %d bytes, %d objects in xref", len(r.data), len(r.xref)) + fmt.Fprintf(&b, "Reader: %d bytes, %d objects in xref", len(r.data), len(r.xref)+len(r.compressed)) if r.pages != nil { fmt.Fprintf(&b, ", %d pages", len(r.pages)) } diff --git a/pdf/xref.go b/pdf/xref.go index af73875..ac4b289 100644 --- a/pdf/xref.go +++ b/pdf/xref.go @@ -52,8 +52,8 @@ func (t *XRefTable) Size() int { // // xref // 0 N -// 0000000000 65535 f \r\n -// 0000000009 00000 n \r\n +// 0000000000 65535 f\r\n +// 0000000009 00000 n\r\n // ... // // Each entry line is exactly 20 bytes (including the trailing \r\n).