//! 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 { fn walk(dir: &Path, out: &mut Vec) { 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> { 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 = 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 = 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 <> 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 = 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 = 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"); }