feat(pdf): expose the parse facts a writer needs - #844
Merged
Conversation
andiwand
force-pushed
the
feat/pdf-object-serialization
branch
from
September 6, 2026 14:42
c951ac3 to
c57b7d4
Compare
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
force-pushed
the
feat/pdf-parse-facts
branch
from
September 6, 2026 14:48
52a7a69 to
3db20ac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 Generated with Claude Code
Rebased onto
mainnow 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.
DocumentParsercomputed all four and kept one:xref(),trailer()/Size,/Root,/ID, ids in use/Prevread_trailer_chain, dropped on returnread_xref_sectionwhose answer was discardedm_recovered, private, no accessorread_xref_sectionreturns a namedXrefSection { xref, trailer, kind }instead of astd::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::optionaland recovery clears them. That is the point rather than a detail: a rebuilt table has no section of the file's own to point/Prevat, 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
/Highlightand an/Inkontoodr-public/pdf/style-various-1.pdfas one incremental update.qpdf --checkpasses 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
/Annotsworks and the newer page object wins; a classic section listing only changed ids is accepted everywhere;to_boxplaces the result correctly.Three things it could not reach, now called out as such in the doc:
/QuadPointsordering. With an/APpresent, 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.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
/Rotatewrite fail separately from the annotation semantics.327 PDF/parser/transform tests pass;
-Werrorclean. clang-tidy is clean on the file bar one pre-existingperformance-move-const-argat line 1724 (commit1761fe22, untouched here).No consumer-visible change — internal API and docs — so no changelog entry.