1 commit
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b8bd71852f |
feat(pdf): content-stream serialiser and editor — the Phase 5 foundation
Some checks failed
email.yml / feat(pdf): content-stream serialiser and editor — the Phase 5 foundation (push) Failing after 0s
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 5 needs to write operators back, and `content.rs` has only ever
parsed them. Every editing feature the phase asks for — insert, delete,
replace, rewrite a text run, flatten an annotation — rests on that, and a
serialiser that is subtly wrong does not throw: it writes a valid content
stream that draws something else.
So this is the serialiser plus the gate, and nothing built on top yet.
The contract is a property, run over the whole corpus:
parse(write(parse(bytes))) == parse(bytes)
Operators, not bytes. Byte equality would be the wrong test — `1.0` may
legally be written `1`, whitespace is free, and a writer that reproduced
its input byte for byte would only prove it had copied it.
It passes: **16,466 operators across 154 streams in 109 files**, plus
stability, idempotence, and the same property after an edit.
**Then mutation testing showed the corpus gate was not enough.** Six
injected defects, and *five passed*: dropping name escaping, unescaping
string parens, un-sorting dictionary keys, discarding unknown operators,
and a fixed six-decimal number format. Real files are written by
well-behaved producers, so 16,000 corpus operators contain no name with a
space, no nested parenthesis, no seven-key inline dictionary and no
vendor operator. A gate that only sees well-formed input cannot catch a
writer that mishandles the rest.
The adversarial set fixes that — eighteen streams, each a legal shape the
corpus lacks, each chosen because a specific defect survives without it.
Writing it found **three live bugs in the parser**, none of which the
round trip could see on its own:
- **Nested parentheses truncated a string to nothing.** `((nested))`
parsed as the empty string, and worse, left the reader mid-string so
every operator after it was parsed from the wrong offset. §7.3.4.2 says
balanced parens nest and need no escaping.
- **`#` escapes in names were never decoded.** `/My#20Font` — how every
producer writes a font whose name contains a space — parsed as the
literal `My#20Font` and never matched the page's resource.
- **`PdfOp::Unknown` was declared and never constructed.** An operator
the parser did not recognise vanished. Survivable for a renderer, fatal
for an editor: parse, change one operator, write back, and every vendor
extension in the page is silently gone from the saved file.
And two in my own serialiser, both found the same way:
- A fixed `{:.6}` flushed 1e-7 to zero — a scale factor silently becoming
zero collapses whatever it transforms — and rounded `1.234567891` to a
different number. Precision is now the shortest that parses back to the
identical f64, exact by construction rather than by choosing a number.
- Sorted dictionary keys turned out to be load-bearing. `PdfDict` is a
HashMap and Rust seeds its hasher per process, so an unsorted writer is
stable within a run and different on every new one: rebuild the same
document twice, get two different files. Neither the round trip nor a
within-process stability check can see it — both sides are equally
unordered. Verified by running five separate processes and getting five
different key orders.
Two of those needed tests the round trip structurally cannot provide, so
they assert on the parser directly: what `((nested))` must produce, and
that operators after it are still read at the right offset.
Final mutation run, eight defects, all caught:
fixed 6-decimal precision 1 fail
name escaping dropped (writer) 1 fail
name unescaping dropped (parser) 2 fail
nested-paren fix reverted 1 fail
unknown operators discarded 1 fail
string parens unescaped 1 fail
close-paren unescaped 1 fail
dictionary keys unsorted 2 fail
`ContentEditor` sits on top: insert, append, prepend, delete, replace,
isolate, and text-run rewriting that preserves the operator *kind* — a
`'` stays a `'` and keeps its line advance, a `TJ` keeps its kerning
numbers while its strings change. Every mutation is balance-checked, so
an edit that would leave `q` without `Q`, or `BT` without `ET`, is
refused at the edit rather than discovered at save time. `PdfOp` gained
`PartialEq`, which is what makes the property expressible at all.
Engine suite 1025 -> 1039.
Phase 5's remaining items — page ops, import/merge, flatten, compaction,
redaction — build on this and are not started.
|