Files
pdfcraft/crates/testkit/README.md
T
Tanish MankuandClaude Opus 5 50629eb6bf feat(testkit): one way to write a synthetic PDF fixture
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.

`pdfcraft-testkit` holds that envelope. It has no workspace dependencies on purpose, so the crates
at the bottom of the layering can use it without a cycle, and it returns bytes rather than a parsed
document for the same reason.

The builder is byte-identical to the serialiser it replaces, asserted against a verbatim copy of
the old code across versions and trailer variations, because a migration that changes a fixture's
bytes silently changes what the test using it proves. Watched failing by breaking the free-entry
generation: three tests caught it. The one intended difference is that an empty stream dictionary
gives `<< /Length N >>` rather than a doubled space.

Migrates cos, measure and redact: 111 copies in 55 files becomes 108 in 53. The rest are
follow-ups rather than one sweep -- ui-egui alone is 27 of those files and is the most contested
area in the repository. Some copies should stay: a test about a malformed cross-reference table
needs to write its own bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 13:57:56 -07:00

58 lines
2.8 KiB
Markdown

# pdfcraft-testkit
Synthetic PDF fixtures: **one way to write the bytes, so tests differ only where they mean to.**
- **Layer:** test support (`Class::Testkit` in `xtask/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 parsed `Document`.
## 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
```rust
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.