Skip to content

feat(signing): append a cross-reference stream when the document uses one - #224

Merged
lsnepomuceno merged 2 commits into
mainfrom
feat/write-xref-streams
Aug 9, 2026
Merged

feat(signing): append a cross-reference stream when the document uses one#224
lsnepomuceno merged 2 commits into
mainfrom
feat/write-xref-streams

Conversation

@lsnepomuceno

Copy link
Copy Markdown
Owner

Closes the writing half of 0009. PDF 1.5 replaced the classic table of ISO 32000-1 §7.5.4 with a stream object, and Word, "print to PDF" in Chrome and most modern generators emit that form. Reading it landed in #223; signing refused, because appending a classic table to a document whose latest section is a stream produced a file poppler reported as carrying no signatures at all.

What changed

XrefStreamWriter emits the section and RevisionWriter::crossReference() picks the form from what the document already uses, for the signature revision and for the DSS and archive timestamp revisions alike.

Three choices worth naming, each with an alternative:

  • The stream indexes itself. It is an ordinary object, and the next revision can only find this one's objects through it.
  • No /Filter. A revision indexes a handful of objects, so its table is tens of bytes and zlib's header and checksum would make the stream larger than what they compress. It also removes a failure path: no compression call that can fail.
  • /Index carries runs, not one range. A revision touches the catalog and a page low in the file and writes its new objects high in it. XrefSubsections computes the runs and now serves both forms, since the classic table's first count header says the same thing.

The defect this uncovered, which was not about streams

The first signed output still read as unsigned, for an unrelated reason. DocumentReader::findFirstPage() scanned a fixed 400-byte window from each object's offset, and in a compact document that window reaches the objects that follow. The catalog was reported as the first page because a /Type/Page two objects later fell inside its window. The revision then wrote /AcroForm onto object 1 and /Annots onto object 1, the second silently dropping the first, so the document had a signature dictionary and no form to reach it from.

The window is bounded at endobj now. This was latent in any document whose objects sit close together; only a 434-byte fixture was small enough to expose it.

Verified in poppler, not only in the suite

Case pdfsig
Signed once Signature #1, valid, Total document signed
Signed twice #1 valid and not total, #2 valid and total
B-LTA #1 valid, #2 Timestamp2 covering the whole file

The B-LTA output is indistinguishable from samples/pades-b-lta.pdf, down to poppler's Unimplemented Feature (0) on the DocTimeStamp, which the classic-table sample has always reported too. samples/xref-stream.pdf is the committed artefact, signed twice. The classic-table samples were regenerated and compared: same structure, so they are left as they are.

Mutation over the two new classes is 100%. The Signing namespace holds at 67.30%, against measurements of 66.02% and 67.50% and a floor of 62.

Also here

  • The pre-commit hook now runs PHPStan after Pint, on the paths its own configuration names rather than on the staged ones: a change in one file breaks types in another, and analysing only what was touched would report a clean commit that fails in CI. Proven to block: a deliberate type error was refused.
  • The parentheses PHP 8.4 no longer needs around new are gone, 38 of them. Pint has no fixer for this, so CLAUDE.md records it as a review point rather than a gate.

🤖 Generated with Claude Code

… one

PDF 1.5 replaced the classic table of ISO 32000-1 7.5.4 with a stream
object, and Word, print to PDF in Chrome and most modern generators emit
that form. Reading it landed last release; signing refused, because
appending a classic table to a document whose latest section is a stream
produced a file poppler reported as carrying no signatures at all.

XrefStreamWriter emits the section and RevisionWriter picks the form from
what the document already uses, for the signature revision and for the
DSS and archive timestamp revisions alike. Three choices worth naming:
the stream indexes itself, since it is an ordinary object and the next
revision can only find this one through it; no /Filter, because a
revision indexes a handful of objects and zlib overhead would exceed
what it compresses; and /Index carries runs rather than one range, since
a revision touches the catalog low in the file and its new objects high
in it. XrefSubsections computes those runs and now serves both forms.

Uncovered a defect that was never about streams. findFirstPage scanned a
fixed 400-byte window from each object offset, which in a compact
document reaches the objects that follow: the catalog was reported as
the first page because a /Type/Page two objects later fell inside its
window, and the revision then wrote /AcroForm and /Annots both onto
object 1, the second dropping the first. The window is bounded at endobj
now. It was latent in any document whose objects sit close together, and
only a 434-byte fixture was small enough to expose it.

Verified in poppler, not only in the suite: signed once, signed twice
and B-LTA all read as valid, and the B-LTA output is indistinguishable
from samples/pades-b-lta.pdf. samples/xref-stream.pdf is the committed
artefact. The classic-table samples were regenerated and compared: same
structure, so they are left as they are.

Mutation over the two new classes is 100%, and the Signing namespace
holds at 67.30% against measurements of 66.02% and 67.50%.

The pre-commit hook now runs PHPStan after Pint, on the paths its own
configuration names rather than on the staged ones: a change in one file
breaks types in another, and analysing only what was touched would
report a clean commit that fails in CI.
The floor is 8.4, which allows new Reader()->parse($der), so wrapping
the constructor was the pre-8.4 workaround and PhpStorm reports it as a
removable wrapper. 38 occurrences across one source file and six test
files.

Pint has no fixer for this, so CLAUDE.md records it as a review point
rather than a gate, and notes the case that is not a chain:
new self(new Encrypter(...)) is already the plain form and keeps its
parentheses because they belong to the outer call.
@lsnepomuceno
lsnepomuceno merged commit 808a331 into main Aug 9, 2026
3 checks passed
@lsnepomuceno
lsnepomuceno deleted the feat/write-xref-streams branch August 9, 2026 16:39
@lsnepomuceno lsnepomuceno self-assigned this Aug 9, 2026
@lsnepomuceno lsnepomuceno added documentation Improvements or additions to documentation enhancement New feature or request php Pull requests that update php code labels Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request php Pull requests that update php code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant