Skip to content

feat(pdf): append an incremental update - #845

Merged
andiwand merged 2 commits into
mainfrom
feat/pdf-incremental-writer
Sep 6, 2026
Merged

feat(pdf): append an incremental update#845
andiwand merged 2 commits into
mainfrom
feat/pdf-incremental-writer

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Phase 1 of the plan in docs/design/pdf-annotation.md — the writer itself. No annotations yet: this is the machinery they will ride on. (Was stacked on #844; rebased onto main once that merged.)

What it does

IncrementalWriter pipes the source through untouched, appends the objects it collected, and closes with a cross-reference section naming only their ids plus a trailer chaining back through /Prev (7.5.6).

Nothing is re-serialized, and that is the whole point of decision 2: every read-side gap — an unmodelled key, a filter we pass through, an object stream we never recompressed — survives because it is copied, not rewritten.

  • Matches the file's own xref flavor via xref_kind() from feat(pdf): expose the parse facts a writer needs #844: a classic table, or a cross-reference stream that mints an id and an entry for itself.
  • Refuses a recovered file (decision 2) and an encrypted one (decision 6) — in the constructor, which is also where start_xref_position() and xref_kind() stop being optional for everything downstream.
  • /ID[1] is derived from the update's own bytes, not from a clock. Writing the same update twice gives the same file, which is what lets a test pin the output at all.
  • Only the update is buffered. The source is piped, so appending to a large PDF does not hold it in memory.

Verified plumbing-first

Sequenced so the append machinery could fail separately from any annotation semantics:

  1. A no-op update — changes nothing, and the result still has to parse. Both xref flavors; asserts the source survives byte for byte as a prefix.
  2. A /Rotate rewrite — one integer on an existing dictionary. No new object types, no appearance stream, but it exercises the risky part: overriding an object the file already defines.

Then new objects, a stream object with a computed /Length, both refusals, and an end-to-end pass over style-various-1.pdf where page 1 rotates and page 2, the existing annotations and every content stream stay intact.

Externally, on real output: qpdf --check clean, and ghostscript, CoreGraphics and our own renderer all honour the new rotation — odr's page box turns 8.5×11in into 11×8.5in.

Known gap

A page dictionary living inside an object stream is not tested. It has to be rewritten uncompressed in the new section — legal, the newer entry wins — but no fixture we have puts one there. Recorded in the doc; owed before Phase 2 ships.

Also

docs/design/pdf-annotation.md marks Phase 1 done and folds in what it settled and what it did not.

328 PDF/parser/writer tests pass; -Werror clean; clang-tidy clean on the new files.

No consumer-visible change — internal API only — so no changelog entry.

andiwand and others added 2 commits September 6, 2026 17:09
`IncrementalWriter` pipes the source through untouched and writes the objects
it collected after it, under a cross-reference section naming only their ids
and a trailer chaining back through `/Prev`. Nothing is re-serialized, so
every read-side gap — an unmodelled key, a filter we pass through, an object
stream we never recompressed — survives by being copied rather than rewritten.

It matches the file's own cross-reference flavor, a classic table or a stream
that mints an id and an entry for itself. A recovered file and an encrypted one
are refused in the constructor, which is also where the two facts the rest of
the writer needs stop being optional.

`/ID[1]` comes from the update's own bytes rather than a clock, so the same
update written twice gives the same file.

Verified plumbing-first: a no-op update that re-parses identically, then a
`/Rotate` rewrite. `qpdf --check` passes and ghostscript, CoreGraphics and our
own renderer all turn the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
Comments restating the code, justifying a choice at paragraph length, or
naming what the implementation used to do. `{:g}` having rounded to four
significant digits is what the commit that changed it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
@andiwand
andiwand force-pushed the feat/pdf-incremental-writer branch from a605fb4 to c7a3896 Compare September 6, 2026 15:09
@andiwand
andiwand merged commit 634a426 into main Sep 6, 2026
25 checks passed
@andiwand
andiwand deleted the feat/pdf-incremental-writer branch September 6, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant