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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions _validation/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions _validation/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
67 changes: 67 additions & 0 deletions _validation/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
})
}
2 changes: 1 addition & 1 deletion internal/buildinfo/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 5 additions & 1 deletion pdf/modifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
66 changes: 66 additions & 0 deletions pdf/modifier_extra_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pdf

import (
"bytes"
"testing"
)

Expand Down Expand Up @@ -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\n<subsections>trailer".
// 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
}
Loading
Loading