An update whose cross-reference section has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2) cuts the earlier revisions, and the page tree with them, off the chain. The signed PDFBOX-2720 and PDFBOX-2942 files have this shape: the document model found no page tree and the renderer showed 2 blank pages found by scanning for page dictionaries. When the chain reaches only the newest section, the file holds another startxref and the catalog's /Pages can't be read, both readers now rebuild the table from the file's objects, as for a damaged file: - cos: the existing reconstruction (newest in-file definition wins), a repair-log line saying why, and `Document::rebuilt_unlinked_update`. - hayro-syntax (44): `root_xref` repairs the table before the page tree is read; `Pdf::rebuilt_unlinked_update` says so. - render: an open warning, and the lazy structure reader accepts such a rebuilt file, so links, fields and bookmarks are read too. A save rewrites the file in full (main's rule for reconstructed files). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
pdfcraft-testkit
Synthetic PDF fixtures: one way to write the bytes, so tests differ only where they mean to.
- Layer: test support (
Class::Testkitinxtask/src/layers.rs). A dev-dependency only, never compiled into the product. - No workspace dependencies, deliberately. The crates at the bottom of the layering (
cos,geom,filters,crypt,arlington) can use it in their own tests without a cycle, which is also why it hands back bytes rather than a parsedDocument.
Why it exists
55 files hand-rolled the same serialiser — number the objects, record each one's offset, emit a classic cross-reference table and a trailer — 111 times over. The object bodies are properly the business of the crate under test. The envelope around them is not, and every copy is a place for a fixture to be quietly wrong in a way that makes a test prove less than it appears to.
API
use pdfcraft_testkit::Pdf;
let bytes = Pdf::new()
.version("1.4") // default 1.7
.obj("<< /Type /Catalog /Pages 2 0 R >>") // becomes 1 0 R
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
.stream("", b"BT /F1 12 Tf (hi) Tj ET") // /Length computed
.trailer_entry("/Info 5 0 R")
.build();
Objects are numbered from 1 in the order they are added, all at generation 0; the trailer always
names 1 0 R as /Root and carries the right /Size. next_obj() gives the number the next
object will get, for forward references. stream_obj(dict, data) builds a stream body on its own,
for callers that assemble their object list first.
Byte-identical by construction
the_builder_is_byte_identical_to_the_serialiser_it_replaces keeps a verbatim copy of the
hand-rolled serialiser and asserts the builder agrees with it, across versions and trailer
variations. Migrating a crate must not change a single byte of its fixtures — otherwise the test
that fixture feeds silently starts testing something else.
The one intended difference: an empty stream dictionary gives << /Length N >> rather than the
doubled space << /Length N >> that some copies produced. Parsers accept both.
Migration status
Three crates are migrated (cos, measure, redact), taking the count from 111 copies in 55
files to 108 in 53. The rest are follow-ups, deliberately not done in one sweep: ui-egui
alone accounts for 27 of those files and is the most contested area in the repository, so a single
large mechanical change there would rot before it landed.
Not every copy should move. A test that is about a malformed or unusual cross-reference table needs to write its own bytes; this builder is for the fixtures that are merely scaffolding for something else.