Some checks failed
email.yml / feat(pdf): redaction and object compaction — and three reader defects (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's last two functional items. Together they are what makes a redaction real, which is why they are one commit: redaction removes the content, compaction removes the revision that still holds it. **Redaction removes operators; it does not draw rectangles.** The famous failure is painting black over text and shipping it — the text is still there, and `pdftotext` prints it. This module draws nothing. It removes the text-showing operators whose position falls inside a rectangle, and the test that matters is that the text can no longer be extracted. Positioning needs the text matrix, so the module tracks `Tm`/`Td`/`TD`/`T*` and the CTM through `q`/`Q`/`cm`. It cannot reach the graphics layer — the crate boundary again — so it treats a showing operator's origin as its position and removes the whole run. That is coarse in the *safe* direction: removing more than asked loses content the user can see is missing; removing less leaves the secret in the file. What it refuses to claim is as important. Images are removed entirely rather than cropped. Metadata and attachments are untouched. And an incremental redaction leaves the original text in the earlier revision — the report says so via `earlier_revisions_retain_content` rather than implying the job is done. **Compaction finishes it.** The output is built from the object graph reachable from `/Root`, so dead objects, superseded revisions and the bytes behind a redaction are not copied — they are simply never written. A signed document is refused unless `allow_signed` is set, because compaction destroys the revision a signature covers and would leave every signature unverifiable with no warning. The end-to-end test is the point: redact, compact, then search the output bytes for the secret. It is gone. **Three reader defects, all found by writing the tests.** - **`PdfWriter` wrote dictionary keys unordered.** `PdfDict` is a HashMap and Rust seeds its hasher per process, so *every generated PDF differed run to run*. Found by compaction's idempotence test — compacting an already-compact file produced the same objects at the same offsets with their keys shuffled. Verified fixed by running four separate processes and getting a byte-identical file. Same defect as the one fixed in `content_edit::write_dict`; this one affected every file this codebase has ever written. - **A short `/Length` silently truncated a stream.** The reader guarded against a `/Length` running past the buffer but trusted one that was too small, cutting the stream at the wrong place and losing the rest with no error. Short lengths are common in hand-edited files. `endstream` is now the authority when the two disagree — but only when it is *further* on, so binary data containing the word `endstream` is still bounded by its declared length. - **Two stream readers disagreed by one byte.** `read_object_at` did not trim the EOL before `endstream` while `find_endstream` did, so a write-read-write cycle grew every stream by a newline. A test fixture had encoded the bug: it declared `/Length 9` for eight bytes of content and asserted the newline came back as data. Both corrected — the newline is syntax (§7.3.8.1), not content. Verified by mutation. Ten defects across the two modules, all caught: redaction covers instead of removes 16 fail CTM ignored 1 fail Q does not restore the CTM 1 fail operands kept when operator removed 12 fail revision warning always false 1 fail signature guard removed 1 fail reachability keeps everything 2 fail dropped reference left dangling 1 fail unresolvable object kept as reachable 1 fail writer dictionary order unsorted 1 fail short-/Length fix reverted 1 fail /Length not rewritten on compaction 1 fail One mutation survived and deleted code rather than adding a test: a `continue` skipping `/Length` in the compaction loop was dead, because the `set` after the loop overwrites it either way. Removed rather than left as untested defence with a reassuring comment — the same call ADR 0017 made about the visited-set guard. A second mutation moved a test rather than a fixture: a stale `/Length` can no longer reach `renumber` through a file, because the reader now repairs it first, so that branch is tested directly instead. Engine suite 1108 -> 1158. External readers still pass. Phase 5 remaining: outline, page label and struct-tree editing.
521 lines
18 KiB
Rust
521 lines
18 KiB
Rust
//! Compaction, verified on the saved file.
|
|
//!
|
|
//! Two claims matter and both are checked here rather than inferred:
|
|
//!
|
|
//! 1. **The document still works.** Compaction rewrites every object with
|
|
//! a new number; if the renumbering is wrong the file still parses and
|
|
//! points at the wrong things.
|
|
//! 2. **The discarded revisions are really gone.** This is the claim that
|
|
//! makes compaction the finishing step for a redaction, and the only
|
|
//! honest way to test it is to search the output bytes for the secret.
|
|
|
|
use nigig_pdf_document::compact::{compact, CompactError, CompactOptions};
|
|
use nigig_pdf_document::redact::{redact_page, RedactionRect};
|
|
use nigig_pdf_document::PdfDocument;
|
|
|
|
/// A document with `dead` unreferenced objects appended, so compaction has
|
|
/// something to drop.
|
|
fn document_with_dead_objects(dead: usize, text: &str) -> Vec<u8> {
|
|
let content = format!("BT /F1 12 Tf 1 0 0 1 100 700 Tm ({text}) Tj ET");
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
|
|
offsets.push(out.len());
|
|
out.push_str(s);
|
|
};
|
|
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
|
|
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
&format!(
|
|
"4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n",
|
|
content.len()
|
|
),
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
|
|
);
|
|
|
|
for i in 0..dead {
|
|
let num = 6 + i;
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
&format!(
|
|
"{num} 0 obj\n<< /Type /Unreferenced /Index {i} \
|
|
/Payload (DEADOBJECT{i}) >>\nendobj\n"
|
|
),
|
|
);
|
|
}
|
|
|
|
let startxref = out.len();
|
|
let total = 6 + dead;
|
|
out.push_str(&format!("xref\n0 {total}\n0000000000 65535 f \n"));
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
out.into_bytes()
|
|
}
|
|
|
|
fn parse(bytes: &[u8]) -> PdfDocument<'_> {
|
|
PdfDocument::parse(bytes).expect("parses")
|
|
}
|
|
|
|
fn page_text(bytes: &[u8]) -> String {
|
|
let mut doc = parse(bytes);
|
|
let page = doc.page(0).expect("page 0");
|
|
String::from_utf8_lossy(&page.content_data).to_string()
|
|
}
|
|
|
|
fn file_contains(bytes: &[u8], needle: &str) -> bool {
|
|
bytes.windows(needle.len()).any(|w| w == needle.as_bytes())
|
|
}
|
|
|
|
#[test]
|
|
fn the_fixture_contains_its_dead_objects() {
|
|
let bytes = document_with_dead_objects(3, "KEEP");
|
|
assert!(file_contains(&bytes, "DEADOBJECT0"));
|
|
assert!(file_contains(&bytes, "DEADOBJECT2"));
|
|
assert!(page_text(&bytes).contains("KEEP"));
|
|
}
|
|
|
|
/// Unreferenced objects are dropped and the document still works.
|
|
#[test]
|
|
fn compaction_drops_unreachable_objects_and_keeps_the_document_readable() {
|
|
let bytes = document_with_dead_objects(4, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let (saved, report) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts");
|
|
|
|
assert!(
|
|
report.objects_dropped >= 4,
|
|
"the four dead objects should have been dropped, report: {report:?}"
|
|
);
|
|
for i in 0..4 {
|
|
assert!(
|
|
!file_contains(&saved, &format!("DEADOBJECT{i}")),
|
|
"dead object {i} survived compaction"
|
|
);
|
|
}
|
|
|
|
// And the live document is intact.
|
|
let mut saved_doc = parse(&saved);
|
|
assert_eq!(saved_doc.page_count(), 1);
|
|
let page = saved_doc.page(0).expect("the page must still parse");
|
|
assert_eq!(page.media_box[2], 600.0, "the page size changed");
|
|
assert!(
|
|
page.fonts.contains_key("F1"),
|
|
"the font resource was lost: {:?}",
|
|
page.fonts.keys().collect::<Vec<_>>()
|
|
);
|
|
assert!(page_text(&saved).contains("KEEP"), "the content was lost");
|
|
}
|
|
|
|
/// Renumbering must be consistent: every surviving reference must resolve
|
|
/// to the object it named before.
|
|
///
|
|
/// This is the assertion that catches a renumbering that is internally
|
|
/// tidy and points everything at the wrong objects — the file parses, the
|
|
/// page count is right, and the fonts are somebody else's.
|
|
#[test]
|
|
fn every_reference_still_resolves_after_renumbering() {
|
|
let bytes = document_with_dead_objects(2, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let page_ref = saved_doc.page_object_ref(0).expect("page ref");
|
|
let page = saved_doc.resolve_ref(page_ref).expect("page resolves");
|
|
let dict = page.as_dict().cloned().expect("page is a dict");
|
|
|
|
// /Parent must reach a /Pages node, not whatever object took its number.
|
|
let parent = dict
|
|
.get("Parent")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("the page must have a /Parent");
|
|
let parent_obj = saved_doc.resolve_ref(parent).expect("parent resolves");
|
|
assert_eq!(
|
|
parent_obj.as_dict().and_then(|d| d.get_name("Type")),
|
|
Some("Pages"),
|
|
"/Parent points at the wrong object after renumbering"
|
|
);
|
|
|
|
// /Contents must reach a stream.
|
|
let contents = dict
|
|
.get("Contents")
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("the page must have /Contents");
|
|
let stream = saved_doc.resolve_ref(contents).expect("contents resolve");
|
|
assert!(
|
|
matches!(stream, nigig_pdf_cos::object::PdfObj::Stream(_)),
|
|
"/Contents points at a {stream:?} after renumbering"
|
|
);
|
|
}
|
|
|
|
/// **The claim that matters for redaction.** Compacting a redacted file
|
|
/// removes the original content from the bytes entirely.
|
|
///
|
|
/// An incremental redaction leaves the original text in the earlier
|
|
/// revision — `redact.rs` says so in its report rather than pretending
|
|
/// otherwise. Compaction is what actually finishes the job, because the
|
|
/// output is built from the object graph rather than copied from the
|
|
/// input.
|
|
#[test]
|
|
fn compacting_a_redacted_file_removes_the_original_text_from_the_bytes() {
|
|
let bytes = document_with_dead_objects(0, "TOPSECRET");
|
|
assert!(file_contains(&bytes, "TOPSECRET"));
|
|
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(0.0, 600.0, 600.0, 800.0);
|
|
let (redacted, report) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
assert_eq!(report.text_runs_removed, 1);
|
|
|
|
// Incremental: the original bytes are still there. This is the honest
|
|
// intermediate state.
|
|
assert!(
|
|
file_contains(&redacted, "TOPSECRET"),
|
|
"an incremental redaction should still contain the original bytes"
|
|
);
|
|
|
|
let mut redacted_doc = parse(&redacted);
|
|
let (compacted, _) =
|
|
compact(&mut redacted_doc, &redacted, CompactOptions::default()).expect("compacts");
|
|
|
|
assert!(
|
|
!file_contains(&compacted, "TOPSECRET"),
|
|
"the redacted text survived compaction — the redaction is not real"
|
|
);
|
|
// And the document still opens.
|
|
let mut final_doc = parse(&compacted);
|
|
assert_eq!(final_doc.page_count(), 1);
|
|
assert!(final_doc.page(0).is_ok());
|
|
}
|
|
|
|
/// Compaction collapses every revision into one.
|
|
#[test]
|
|
fn compaction_produces_a_single_revision_file() {
|
|
let bytes = document_with_dead_objects(0, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let rect = RedactionRect::new(0.0, 600.0, 600.0, 800.0);
|
|
let (redacted, _) = redact_page(&mut doc, &bytes, 0, &[rect]).expect("redacts");
|
|
|
|
let revisions = |b: &[u8]| b.windows(9).filter(|w| *w == b"startxref").count();
|
|
assert_eq!(revisions(&redacted), 2, "the redaction appended a revision");
|
|
|
|
let mut redacted_doc = parse(&redacted);
|
|
let (compacted, report) =
|
|
compact(&mut redacted_doc, &redacted, CompactOptions::default()).expect("compacts");
|
|
assert_eq!(
|
|
revisions(&compacted),
|
|
1,
|
|
"compaction must leave exactly one revision"
|
|
);
|
|
assert_eq!(report.revisions_discarded, 1);
|
|
}
|
|
|
|
/// A signed document is refused by default.
|
|
///
|
|
/// A signature covers a byte range in a revision compaction destroys, so
|
|
/// proceeding silently would leave every signature unverifiable with no
|
|
/// warning at all.
|
|
#[test]
|
|
fn a_signed_document_is_refused_unless_explicitly_allowed() {
|
|
// A minimal document carrying a signature field with a /ByteRange.
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
|
|
offsets.push(out.len());
|
|
out.push_str(s);
|
|
};
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] \
|
|
/SigFlags 3 >> >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
|
|
/Contents 4 0 R /Annots [5 0 R] >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"5 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig1) \
|
|
/Rect [0 0 100 50] /V 6 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"6 0 obj\n<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached \
|
|
/ByteRange [0 100 200 300] /Contents <00> >>\nendobj\n",
|
|
);
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 7\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 7 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
let bytes = out.into_bytes();
|
|
|
|
let mut doc = parse(&bytes);
|
|
let signature_count = doc.signatures().map(|r| r.signatures.len()).unwrap_or(0);
|
|
assert!(
|
|
signature_count > 0,
|
|
"the fixture must actually carry a signature for this test to mean anything"
|
|
);
|
|
drop(doc);
|
|
|
|
let mut doc = parse(&bytes);
|
|
match compact(&mut doc, &bytes, CompactOptions::default()) {
|
|
Err(CompactError::WouldBreakSignatures { count }) => {
|
|
assert_eq!(count, signature_count);
|
|
}
|
|
Err(other) => panic!("wrong error: {other}"),
|
|
Ok(_) => panic!("compaction silently invalidated a signature"),
|
|
}
|
|
|
|
// With the flag, it proceeds.
|
|
let mut doc = parse(&bytes);
|
|
let allowed = CompactOptions {
|
|
allow_signed: true,
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
compact(&mut doc, &bytes, allowed).is_ok(),
|
|
"an explicit allow_signed must be honoured"
|
|
);
|
|
}
|
|
|
|
/// Dropping `/Info` removes the metadata, which is often wanted when
|
|
/// finishing a redaction.
|
|
#[test]
|
|
fn info_can_be_dropped() {
|
|
let bytes = document_with_dead_objects(0, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let options = CompactOptions {
|
|
keep_info: false,
|
|
..Default::default()
|
|
};
|
|
let (saved, _) = compact(&mut doc, &bytes, options).expect("compacts");
|
|
let saved_doc = parse(&saved);
|
|
assert!(
|
|
saved_doc.trailer().get("Info").is_none(),
|
|
"/Info survived a keep_info: false compaction"
|
|
);
|
|
}
|
|
|
|
/// Compacting an already-compact document must be stable: it does not
|
|
/// keep shrinking or keep renumbering.
|
|
#[test]
|
|
fn compacting_twice_is_stable() {
|
|
let bytes = document_with_dead_objects(3, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let (once, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("first");
|
|
|
|
let mut doc2 = parse(&once);
|
|
let (twice, report) = compact(&mut doc2, &once, CompactOptions::default()).expect("second");
|
|
assert_eq!(
|
|
report.objects_dropped, 0,
|
|
"a compact file has nothing left to drop"
|
|
);
|
|
assert_eq!(twice, once, "compaction is not idempotent");
|
|
}
|
|
|
|
/// The report's arithmetic must match the file.
|
|
#[test]
|
|
fn the_report_describes_what_happened() {
|
|
let bytes = document_with_dead_objects(5, "KEEP");
|
|
let mut doc = parse(&bytes);
|
|
let (saved, report) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts");
|
|
|
|
assert_eq!(report.bytes_before, bytes.len());
|
|
assert_eq!(report.bytes_after, saved.len());
|
|
assert!(
|
|
report.objects_kept >= 5,
|
|
"catalog, pages, page, contents and font must all be kept"
|
|
);
|
|
assert!(
|
|
report.bytes_saved() > 0,
|
|
"dropping five objects should shrink the file: {} -> {}",
|
|
report.bytes_before,
|
|
report.bytes_after
|
|
);
|
|
}
|
|
|
|
/// A reference to a dropped object must become null, not stay dangling.
|
|
///
|
|
/// A dangling reference is legal — readers treat it as null — but it says
|
|
/// nothing, and a later tool that renumbers again may bind it to an
|
|
/// unrelated object. The fixture points the page at an object that is
|
|
/// deliberately never written, which is what a partly-repaired file looks
|
|
/// like.
|
|
#[test]
|
|
fn a_reference_to_a_missing_object_is_written_as_null() {
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
|
|
offsets.push(out.len());
|
|
out.push_str(s);
|
|
};
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
|
|
);
|
|
// /Missing points at object 99, which does not exist.
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
|
|
/Contents 4 0 R /Missing 99 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n",
|
|
);
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 5\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
let bytes = out.into_bytes();
|
|
|
|
let mut doc = parse(&bytes);
|
|
let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let page_ref = saved_doc.page_object_ref(0).expect("page ref");
|
|
let page = saved_doc.resolve_ref(page_ref).expect("page");
|
|
let missing = page
|
|
.as_dict()
|
|
.and_then(|d| d.get("Missing").cloned())
|
|
.expect("/Missing must still be present as a key");
|
|
assert_eq!(
|
|
missing,
|
|
nigig_pdf_cos::object::PdfObj::Null,
|
|
"a reference to a dropped object must be written as null, got {missing:?}"
|
|
);
|
|
}
|
|
|
|
/// A stale `/Length` in the source must be recomputed, not copied.
|
|
///
|
|
/// A `/Length` larger than the data runs the reader past the end of the
|
|
/// stream; smaller and it truncates the content. Either way the file
|
|
/// parses and the page is wrong.
|
|
#[test]
|
|
fn a_stale_stream_length_is_recomputed() {
|
|
let content = "BT /F1 12 Tf 1 0 0 1 100 700 Tm (KEEP) Tj ET";
|
|
let mut out = String::from("%PDF-1.7\n");
|
|
let mut offsets = Vec::new();
|
|
let push = |out: &mut String, offsets: &mut Vec<usize>, s: &str| {
|
|
offsets.push(out.len());
|
|
out.push_str(s);
|
|
};
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n",
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \
|
|
/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n",
|
|
);
|
|
// /Length deliberately wrong: shorter than the real content.
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
&format!("4 0 obj\n<< /Length 5 >>\nstream\n{content}\nendstream\nendobj\n"),
|
|
);
|
|
push(
|
|
&mut out,
|
|
&mut offsets,
|
|
"5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n",
|
|
);
|
|
let startxref = out.len();
|
|
out.push_str("xref\n0 6\n0000000000 65535 f \n");
|
|
for off in &offsets {
|
|
out.push_str(&format!("{off:010} 00000 n \n"));
|
|
}
|
|
out.push_str(&format!(
|
|
"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n"
|
|
));
|
|
let bytes = out.into_bytes();
|
|
|
|
let mut doc = parse(&bytes);
|
|
let (saved, _) = compact(&mut doc, &bytes, CompactOptions::default()).expect("compacts");
|
|
|
|
let mut saved_doc = parse(&saved);
|
|
let page_ref = saved_doc.page_object_ref(0).expect("page ref");
|
|
let page = saved_doc.resolve_ref(page_ref).expect("page");
|
|
let contents = page
|
|
.as_dict()
|
|
.and_then(|d| d.get("Contents"))
|
|
.and_then(|o| o.as_ref().copied())
|
|
.expect("/Contents");
|
|
let stream = saved_doc.resolve_ref(contents).expect("contents resolve");
|
|
match stream {
|
|
nigig_pdf_cos::object::PdfObj::Stream(s) => {
|
|
let declared = s.dict.get_int("Length").expect("/Length must be written");
|
|
assert_eq!(
|
|
declared as usize,
|
|
s.data.len(),
|
|
"the written /Length does not match the data it describes"
|
|
);
|
|
assert!(
|
|
declared > 5,
|
|
"the stale /Length of 5 was copied instead of recomputed"
|
|
);
|
|
}
|
|
other => panic!("/Contents is a {other:?}"),
|
|
}
|
|
}
|