M1: edit finds the pages a damaged file's readers find (#1664)

* M1: edit finds the pages a damaged file's readers find (fixes #1656)

* M1: rebuild a lost page tree only when the reader shows the same pages, and refuse edits to signed rebuilt files (#1656)

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 04:01:45 -04:00
1 parent 9479561ffa
commit cccd55fab2
5 files changed
+244 -13

No files matched your search

+96
View File
@@ -239,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
}
}
/// Two pages whose catalog points at a page tree object the file does not have (#1656). The
/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages.
/// Object 3 is written before object 2, so file order and object numbers disagree.
fn fixture_without_page_tree() -> Vec<u8> {
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
let objs: Vec<(usize, Vec<u8>)> = vec![
(1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()),
(3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()),
(2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()),
(4, stream("0 0 m 10 10 l S")),
(5, stream("0 0 m 20 20 l S")),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0; objs.len()];
for (num, o) in &objs {
offsets[num - 1] = out.len();
out.extend_from_slice(format!("{num} 0 obj\n").as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() {
let input = tmp("no-page-tree", "in.pdf");
std::fs::write(&input, fixture_without_page_tree()).unwrap();
let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap();
assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>(), [(200.0, 300.0), (300.0, 200.0)]);
for (name, args, expected) in [
// Width and height are as displayed: the turned page reads 200 x 300.
("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]),
("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]),
] {
let output = tmp("no-page-tree", name);
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
all.extend(args);
let out = Command::new(BIN).args(&all).output().unwrap();
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect();
assert_eq!(pages, expected, "{name}");
}
}
/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the
/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text
/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file.
fn fixture_whose_scan_disagrees() -> Vec<u8> {
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(),
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(),
stream("0 0 m 10 10 l S"),
stream("0 0 m 20 20 l S"),
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() {
let input = tmp("scan-disagrees", "in.pdf");
std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap();
let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap();
assert_eq!(shown.pages.len(), 3);
assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page");
let output = tmp("scan-disagrees", "out.pdf");
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n");
assert!(!output.exists(), "no page may be deleted");
}
/// A page past the end names its own option and the page count left by the earlier options (#1550).
#[test]
fn a_page_past_the_end_after_earlier_edits_names_that_option() {