Skip to content

feat(pdf): expose the parse facts a writer needs - #844

Merged
andiwand merged 2 commits into
mainfrom
feat/pdf-parse-facts
Sep 6, 2026
Merged

feat(pdf): expose the parse facts a writer needs#844
andiwand merged 2 commits into
mainfrom
feat/pdf-parse-facts

Conversation

@andiwand

@andiwand andiwand commented Sep 6, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Rebased onto main now that #842 and #843 have landed. Two commits.

1. The parse facts a writer needs

Appending an incremental update means knowing four things about the file you are appending to. DocumentParser computed all four and kept one:

Fact Needed for Before
xref(), trailer() /Size, /Root, /ID, ids in use already exposed
newest section's offset the new trailer's /Prev a local in read_trailer_chain, dropped on return
classic table vs xref stream which flavor to append a branch in read_xref_section whose answer was discarded
came from recovery the refusal gate m_recovered, private, no accessor

read_xref_section returns a named XrefSection { xref, trailer, kind } instead of a std::pair, so the kind it already determines survives the return. The chain walk records the newest section's position and kind as it goes — the newest being the one an appended section chains onto.

Four accessors: start_xref_position(), xref_kind(), is_recovered(), highest_object_id().

The first two are std::optional and recovery clears them. That is the point rather than a detail: a rebuilt table has no section of the file's own to point /Prev at, which is precisely what makes such a file unappendable (design decision 2). The refusal gate and the missing value are the same fact.

Three test cases, all on fixtures that already existed — classic table, xref stream, recovered. The position assertion reads the offset back out of the built PDF rather than hardcoding it.

2. What the spike proved

Before writing any of this in C++, a throwaway script wrote a /Highlight and an /Ink onto odr-public/pdf/style-various-1.pdf as one incremental update. qpdf --check passes and four independent engines paint both annotations with the page text showing through the highlight: ghostscript, PDFium (Chrome), CoreGraphics (Preview), and our own renderer — which emits <path fill="rgb(255,230,51)" style="mix-blend-mode:multiply"> plus a round-capped stroke.

Now facts rather than assumptions: the transparency group composites against the page and not a black backdrop; appending to a page's existing /Annots works and the newer page object wins; a classic section listing only changed ids is accepted everywhere; to_box places the result correctly.

Three things it could not reach, now called out as such in the doc:

  • /QuadPoints ordering. With an /AP present, the appearance is what every one of those engines painted — the quad points were never read. Added as an open question, because it needs a different kind of oracle rather than more of the same.
  • A page dictionary inside an object stream (the fixture's was plain).
  • Appending to a file whose newest section is an xref stream (the fixture's was a classic table).

The last two are exactly what xref_kind() exists for, and both are owed tests in Phase 1.

The doc also marks Phases 0 and 0.5 done and splits Phase 1 so a no-op update and a /Rotate write fail separately from the annotation semantics.

327 PDF/parser/transform tests pass; -Werror clean. clang-tidy is clean on the file bar one pre-existing performance-move-const-arg at line 1724 (commit 1761fe22, untouched here).

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

@andiwand
andiwand force-pushed the feat/pdf-object-serialization branch from c951ac3 to c57b7d4 Compare September 6, 2026 14:42
Base automatically changed from feat/pdf-object-serialization to main September 6, 2026 14:44
andiwand and others added 2 commits September 6, 2026 16:46
Appending an incremental update to a file means knowing four things about it.
`DocumentParser` computed all four and kept only one: `xref()` and `trailer()`
were reachable, but the newest section's offset was a local in
`read_trailer_chain`, its kind was a branch in `read_xref_section` that
returned nothing, and `m_recovered` had no accessor.

`read_xref_section` now returns a named `XrefSection` carrying the kind
instead of a pair, and the chain walk records the newest section's position
and kind as it passes. Recovery clears both: a rebuilt table has no section of
the file's own to chain onto, which is exactly what makes such a file
unappendable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz
A throwaway script wrote a highlight and an ink stroke onto
`style-various-1.pdf` as one incremental update, before committing any of it
to C++. `qpdf --check` passes and four engines paint both with the page text
showing through: ghostscript, PDFium, CoreGraphics, and our own renderer.

So the transparency group, the append onto an existing `/Annots`, the
changed-ids-only xref section and `to_box`'s placement are facts now. Three
things the spike could not reach are called out as such — `/QuadPoints`
ordering above all, which no oracle we have ever reads while an `/AP` is
present.

Also marks Phases 0 and 0.5 done, and splits Phase 1 so a no-op update and a
`/Rotate` write fail separately from the annotation semantics.

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-parse-facts branch from 52a7a69 to 3db20ac Compare September 6, 2026 14:48
@andiwand
andiwand merged commit d42a09a into main Sep 6, 2026
25 checks passed
@andiwand
andiwand deleted the feat/pdf-parse-facts branch September 6, 2026 14:51
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