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.
457 lines
16 KiB
Rust
457 lines
16 KiB
Rust
//! The content-stream round-trip property, over the whole corpus.
|
|
//!
|
|
//! This is the gate Phase 5's editing API is built on. `content_edit.rs`
|
|
//! can serialise operators back to bytes; everything above it — insert,
|
|
//! delete, replace, text rewriting, flatten — is only as trustworthy as
|
|
//! that serialiser. And a wrong serialiser does not throw: it writes a
|
|
//! valid content stream that draws something else.
|
|
//!
|
|
//! The property:
|
|
//!
|
|
//! ```text
|
|
//! parse(write(parse(bytes))) == parse(bytes)
|
|
//! ```
|
|
//!
|
|
//! Asserted on *operators*, not bytes. Byte equality is the wrong test —
|
|
//! `1.0` may legally be written `1`, whitespace is free, and a serialiser
|
|
//! that reproduced its input byte for byte would only prove it had copied
|
|
//! it. Operator equality says the meaning survived.
|
|
//!
|
|
//! Run over every content stream in every corpus fixture rather than a
|
|
//! list of hand-written cases, because the interesting operators are the
|
|
//! ones nobody thought to write a case for. This lives in `pdf-document`
|
|
//! rather than `pdf-graphics` because reaching a page's content stream
|
|
//! needs the document layer to resolve and decode it.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use nigig_pdf_document::PdfDocument;
|
|
use nigig_pdf_graphics::content::{parse_content_stream, PdfOp};
|
|
use nigig_pdf_graphics::content_edit::{write_ops, ContentEditor};
|
|
|
|
fn corpus_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../tests/corpus")
|
|
}
|
|
|
|
/// Every `.pdf` in the corpus, recursively.
|
|
fn corpus_files() -> Vec<PathBuf> {
|
|
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
walk(&path, out);
|
|
} else if path.extension().is_some_and(|e| e == "pdf") {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
walk(&corpus_root(), &mut out);
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// Pull every page content stream out of a file.
|
|
///
|
|
/// Failures are skipped rather than reported: the corpus deliberately
|
|
/// contains malformed and encrypted files, and this test is about the
|
|
/// serialiser, not about parsing. A file that will not open contributes
|
|
/// nothing and must not fail the run.
|
|
fn content_streams(path: &Path) -> Vec<Vec<u8>> {
|
|
let Ok(bytes) = std::fs::read(path) else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(mut doc) = PdfDocument::parse(&bytes) else {
|
|
return Vec::new();
|
|
};
|
|
let mut out = Vec::new();
|
|
for i in 0..doc.page_count() {
|
|
if let Ok(page) = doc.page(i) {
|
|
if !page.content_data.is_empty() {
|
|
out.push(page.content_data.clone());
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Describe an operator by variant name, for reporting.
|
|
fn variant(op: &PdfOp) -> String {
|
|
let debug = format!("{op:?}");
|
|
debug
|
|
.split(['(', ' ', '{'])
|
|
.next()
|
|
.unwrap_or("?")
|
|
.to_string()
|
|
}
|
|
|
|
/// **The gate.** Every content stream in the corpus survives a round trip.
|
|
#[test]
|
|
fn every_corpus_content_stream_round_trips_through_the_serialiser() {
|
|
let files = corpus_files();
|
|
assert!(
|
|
!files.is_empty(),
|
|
"no corpus files found under {} — the gate would pass vacuously",
|
|
corpus_root().display()
|
|
);
|
|
|
|
let mut checked_streams = 0usize;
|
|
let mut checked_ops = 0usize;
|
|
let mut failures: Vec<String> = Vec::new();
|
|
|
|
for path in &files {
|
|
for (index, data) in content_streams(path).into_iter().enumerate() {
|
|
let Ok(first) = parse_content_stream(&data) else {
|
|
continue;
|
|
};
|
|
if first.is_empty() {
|
|
continue;
|
|
}
|
|
checked_streams += 1;
|
|
checked_ops += first.len();
|
|
|
|
let written = write_ops(&first);
|
|
let Ok(again) = parse_content_stream(&written) else {
|
|
failures.push(format!(
|
|
"{}: page {index} re-parse failed outright",
|
|
path.display()
|
|
));
|
|
continue;
|
|
};
|
|
|
|
if again != first {
|
|
// Report the first divergence rather than two operator
|
|
// dumps: which operator broke says far more than how many.
|
|
let mut detail = format!(
|
|
"{}: page {index} changed ({} ops -> {} ops)",
|
|
path.display(),
|
|
first.len(),
|
|
again.len()
|
|
);
|
|
for (i, (a, b)) in first.iter().zip(again.iter()).enumerate() {
|
|
if a != b {
|
|
detail.push_str(&format!(
|
|
"\n first divergence at op {i}:\n before: {a:?}\n after: {b:?}"
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
failures.push(detail);
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
checked_streams > 0,
|
|
"no content streams were checked — the gate would pass vacuously"
|
|
);
|
|
assert!(
|
|
failures.is_empty(),
|
|
"{} of {checked_streams} content streams did not round trip:\n{}",
|
|
failures.len(),
|
|
failures.join("\n")
|
|
);
|
|
|
|
// Printed so a reviewer can see the gate is covering something. A
|
|
// property test over an accidentally-empty corpus is the failure mode
|
|
// this whole file exists to avoid.
|
|
println!(
|
|
"round-tripped {checked_ops} operators across {checked_streams} streams \
|
|
in {} files",
|
|
files.len()
|
|
);
|
|
}
|
|
|
|
/// The round trip must be *stable*: writing twice gives the same bytes.
|
|
///
|
|
/// An unstable serialiser — an unordered dictionary, say — would make the
|
|
/// property above fail intermittently, which is the worst kind of failure
|
|
/// to diagnose. This pins it directly.
|
|
#[test]
|
|
fn serialising_twice_produces_identical_bytes() {
|
|
for path in corpus_files() {
|
|
for data in content_streams(&path) {
|
|
let Ok(ops) = parse_content_stream(&data) else {
|
|
continue;
|
|
};
|
|
if ops.is_empty() {
|
|
continue;
|
|
}
|
|
let a = write_ops(&ops);
|
|
let b = write_ops(&ops);
|
|
assert_eq!(
|
|
a,
|
|
b,
|
|
"{}: serialising the same operators twice differed",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A second round trip must be a fixed point: once written, re-parsing and
|
|
/// re-writing changes nothing further.
|
|
#[test]
|
|
fn the_serialiser_reaches_a_fixed_point_after_one_pass() {
|
|
for path in corpus_files() {
|
|
for data in content_streams(&path) {
|
|
let Ok(ops) = parse_content_stream(&data) else {
|
|
continue;
|
|
};
|
|
if ops.is_empty() {
|
|
continue;
|
|
}
|
|
let once = write_ops(&ops);
|
|
let Ok(reparsed) = parse_content_stream(&once) else {
|
|
continue;
|
|
};
|
|
let twice = write_ops(&reparsed);
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&once),
|
|
String::from_utf8_lossy(&twice),
|
|
"{}: the serialiser is not idempotent",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Report which operator variants the corpus actually exercises.
|
|
///
|
|
/// Not an assertion about coverage — the corpus is what it is — but a
|
|
/// standing statement of what the gate above does and does not prove. A
|
|
/// variant absent here is round-tripped only by the unit tests in
|
|
/// `content_edit.rs`, and a reviewer should know which.
|
|
#[test]
|
|
fn report_the_operator_variants_the_corpus_covers() {
|
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
|
for path in corpus_files() {
|
|
for data in content_streams(&path) {
|
|
if let Ok(ops) = parse_content_stream(&data) {
|
|
for op in &ops {
|
|
seen.insert(variant(op));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
println!("corpus exercises {} operator variants:", seen.len());
|
|
for v in &seen {
|
|
println!(" {v}");
|
|
}
|
|
assert!(
|
|
seen.len() >= 10,
|
|
"the corpus exercises only {} operator variants, which is too few \
|
|
for the round-trip gate to mean much",
|
|
seen.len()
|
|
);
|
|
}
|
|
|
|
/// Adversarial streams the corpus does not contain.
|
|
///
|
|
/// The corpus-wide gate above is necessary and not sufficient: real files
|
|
/// are written by well-behaved producers, so they exercise none of the
|
|
/// shapes that break a serialiser. Mutation-testing the gate proved it —
|
|
/// dropping name escaping, unescaping string parens, un-sorting dictionary
|
|
/// keys and discarding unknown operators *all passed* against 16,000
|
|
/// corpus operators, because no corpus file contains a name with a space,
|
|
/// a nested paren, a five-key inline dictionary or a vendor operator.
|
|
///
|
|
/// These are the cases that make those mutations fail. Each one is a shape
|
|
/// a real file may legally contain, and each was chosen because a specific
|
|
/// serialiser defect survives without it.
|
|
const ADVERSARIAL: &[(&str, &str)] = &[
|
|
// Names: a space splits the name in two; `#` is the escape character
|
|
// itself; a delimiter ends the token early.
|
|
("name with a space", "/My Font 12 Tf"),
|
|
("name with a hash", "/A#B 12 Tf"),
|
|
("name with a slash", "/a/b 12 Tf"),
|
|
("name with a paren", "/a(b 12 Tf"),
|
|
// Strings: unbalanced and nested parens, backslashes, and bytes that
|
|
// must be octal-escaped.
|
|
("unbalanced open paren", r"(a\(b) Tj"),
|
|
("unbalanced close paren", r"(a\)b) Tj"),
|
|
("nested parens", "((nested)) Tj"),
|
|
("backslash", r"(back\\slash) Tj"),
|
|
("high bytes", "(\\376\\377) Tj"),
|
|
// Numbers: small magnitudes that a fixed six-decimal format flushes
|
|
// to zero, and a matrix built from them.
|
|
("small scale factor", "0.0000001 0 0 0.0000001 0 0 cm"),
|
|
("negative small", "-0.0000025 0 0 1 0 0 cm"),
|
|
("many decimals", "1.234567891 2.345678912 m"),
|
|
// Dictionaries: enough keys that an unstable iteration order shows.
|
|
(
|
|
"multi-key inline dictionary",
|
|
"/Span <</A 1 /B 2 /C 3 /D 4 /E 5 /F 6 /G 7>> BDC EMC",
|
|
),
|
|
// Unknown operators, with and without operands.
|
|
("unknown with operands", "1 2 /Name VendorOp"),
|
|
("unknown bare", "VendorOp2"),
|
|
// Text-showing variants with awkward payloads.
|
|
("TJ with kerning", "BT [(a) -120 (b) 55 (c)] TJ ET"),
|
|
("quote operators", "BT (x) ' 1 2 (y) \" ET"),
|
|
("empty string", "BT () Tj ET"),
|
|
];
|
|
|
|
#[test]
|
|
fn adversarial_streams_round_trip() {
|
|
let mut failures = Vec::new();
|
|
for (name, src) in ADVERSARIAL {
|
|
let Ok(first) = parse_content_stream(src.as_bytes()) else {
|
|
failures.push(format!("{name}: {src:?} did not parse at all"));
|
|
continue;
|
|
};
|
|
if first.is_empty() {
|
|
failures.push(format!("{name}: {src:?} parsed to no operators"));
|
|
continue;
|
|
}
|
|
let written = write_ops(&first);
|
|
let Ok(again) = parse_content_stream(&written) else {
|
|
failures.push(format!(
|
|
"{name}: re-parse failed\n wrote: {}",
|
|
String::from_utf8_lossy(&written)
|
|
));
|
|
continue;
|
|
};
|
|
if again != first {
|
|
failures.push(format!(
|
|
"{name}: changed across a round trip\n source: {src}\n wrote: {}\n before: {first:?}\n after: {again:?}",
|
|
String::from_utf8_lossy(&written)
|
|
));
|
|
}
|
|
}
|
|
assert!(
|
|
failures.is_empty(),
|
|
"{} adversarial streams did not round trip:\n{}",
|
|
failures.len(),
|
|
failures.join("\n")
|
|
);
|
|
}
|
|
|
|
/// The adversarial set must be *stable* too: an unsorted dictionary shows
|
|
/// up here and nowhere in the corpus.
|
|
#[test]
|
|
fn adversarial_streams_serialise_identically_every_time() {
|
|
for (name, src) in ADVERSARIAL {
|
|
let Ok(ops) = parse_content_stream(src.as_bytes()) else {
|
|
continue;
|
|
};
|
|
let first = write_ops(&ops);
|
|
for _ in 0..16 {
|
|
assert_eq!(
|
|
write_ops(&ops),
|
|
first,
|
|
"{name}: serialisation is not stable across runs"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parser-side invariants the round trip cannot see.
|
|
///
|
|
/// A round trip is blind to a defect the parser and serialiser share, and
|
|
/// to one the serialiser *hides*. Nested parentheses are the second kind:
|
|
/// the parser truncated `((nested))` to the empty string, but the
|
|
/// serialiser escapes every paren it writes, so the second parse never
|
|
/// meets a nested one and the property held while the bug was live. These
|
|
/// assert what the parser must produce, independently of writing it back.
|
|
#[test]
|
|
fn the_parser_reads_the_shapes_the_serialiser_never_emits() {
|
|
let cases: &[(&str, &[u8])] = &[
|
|
// Balanced parens nest and need no escaping (§7.3.4.2). Stopping
|
|
// at the first `)` loses the text *and* desynchronises everything
|
|
// after it.
|
|
("((nested)) Tj", b"(nested)"),
|
|
("((a)(b)) Tj", b"(a)(b)"),
|
|
("(plain) Tj", b"plain"),
|
|
// Escaped parens are literal and must not affect nesting depth.
|
|
(r"(a\(b) Tj", b"a(b"),
|
|
(r"(a\)b) Tj", b"a)b"),
|
|
// A `#` escape in a name is the only way to write a space in one.
|
|
// Not decoding it meant `/My#20Font` never matched the resource
|
|
// `My Font` that the page's /Font dictionary actually declares.
|
|
("/My#20Font 12 Tf", b"My Font"),
|
|
];
|
|
|
|
for (src, expected) in cases {
|
|
let ops = parse_content_stream(src.as_bytes())
|
|
.unwrap_or_else(|e| panic!("{src:?} did not parse: {e}"));
|
|
assert_eq!(ops.len(), 1, "{src:?} parsed to {} operators", ops.len());
|
|
let got: Vec<u8> = match &ops[0] {
|
|
PdfOp::ShowText(b) => b.clone(),
|
|
PdfOp::SetFont(name, _) => name.as_bytes().to_vec(),
|
|
other => panic!("{src:?} parsed to an unexpected {other:?}"),
|
|
};
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&got),
|
|
String::from_utf8_lossy(expected),
|
|
"{src:?} parsed to the wrong content"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A nested-paren string must not desynchronise the operators after it.
|
|
///
|
|
/// This is the damaging half of the truncation bug: the reader was left
|
|
/// mid-string, so every subsequent operator was parsed from the wrong
|
|
/// offset. The text being wrong is visible; the rest of the page quietly
|
|
/// changing is not.
|
|
#[test]
|
|
fn a_nested_paren_string_does_not_desynchronise_the_stream() {
|
|
let ops = parse_content_stream(b"BT ((nested)) Tj 1 0 0 rg ET").expect("parses");
|
|
let kinds: Vec<String> = ops.iter().map(variant).collect();
|
|
assert_eq!(
|
|
kinds,
|
|
vec!["BeginText", "ShowText", "RgbFill", "EndText"],
|
|
"operators after a nested-paren string were misparsed: {ops:?}"
|
|
);
|
|
}
|
|
|
|
/// Editing through `ContentEditor` and writing back must also round trip.
|
|
///
|
|
/// The property above covers freshly parsed streams. This covers the case
|
|
/// the editing API actually produces: a stream that has been mutated.
|
|
#[test]
|
|
fn an_edited_corpus_stream_still_round_trips() {
|
|
let mut edited = 0usize;
|
|
for path in corpus_files() {
|
|
for data in content_streams(&path) {
|
|
let Ok(mut editor) = ContentEditor::parse(&data) else {
|
|
continue;
|
|
};
|
|
if editor.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// A mutation every stream can take: wrap the whole thing in
|
|
// q/Q. It changes the operator list without needing to know
|
|
// anything about the content.
|
|
let range = nigig_pdf_graphics::content_edit::OpRange::new(0, editor.len());
|
|
if editor.isolate(range).is_err() {
|
|
// Refused because the stream was already unbalanced; the
|
|
// editor is right to refuse and this file is not a case.
|
|
continue;
|
|
}
|
|
edited += 1;
|
|
|
|
let built = editor.build();
|
|
let Ok(reparsed) = parse_content_stream(&built) else {
|
|
panic!("{}: an edited stream failed to re-parse", path.display());
|
|
};
|
|
assert_eq!(
|
|
reparsed,
|
|
editor.ops(),
|
|
"{}: an edited stream lost meaning on write",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
assert!(
|
|
edited > 0,
|
|
"no streams were edited — the test proves nothing"
|
|
);
|
|
println!("edited and round-tripped {edited} streams");
|
|
}
|