//! Page operations, read back from the saved file. //! //! Phase 5's exit criterion is "edit-anything round-trip tests (modify → //! save → re-parse → verify)". The unit tests in `page_ops.rs` check the //! *plan*; these check the **file**, which is the only thing a reader ever //! sees. //! //! The distinction matters more here than almost anywhere else in the //! stack. A page-tree edit that is subtly wrong produces a document that //! opens: `/Count` says five, the reader finds four, and which of those it //! believes depends on the reader. Nothing errors. So every assertion here //! goes through `PdfDocument::parse` on the saved bytes and checks what a //! reader would actually get. use nigig_pdf_cos::object::{PdfDict, PdfObj}; use nigig_pdf_document::page_ops::{apply_plan, import_pages, PageOpError, PagePlan}; use nigig_pdf_document::PdfDocument; /// Build a document whose pages are individually identifiable. /// /// Each page's `/MediaBox` width encodes its index — page `i` is /// `100 + i` wide — so a reordering can be verified by reading sizes back /// rather than by trusting object numbers. Object numbers are exactly what /// a page-tree bug scrambles, so they cannot be the identity used to check /// one. fn document_with_pages(count: usize) -> Vec { let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); let page_obj = |i: usize| 3 + i; let content_obj = |i: usize, count: usize| 3 + count + i; offsets.push(out.len()); out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets.push(out.len()); let kids: Vec = (0..count).map(|i| format!("{} 0 R", page_obj(i))).collect(); out.push_str(&format!( "2 0 obj\n<< /Type /Pages /Count {count} /Kids [{}] >>\nendobj\n", kids.join(" ") )); for i in 0..count { offsets.push(out.len()); out.push_str(&format!( "{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} 200] \ /Contents {} 0 R >>\nendobj\n", page_obj(i), 100 + i, content_obj(i, count) )); } for i in 0..count { offsets.push(out.len()); let content = format!("BT (page {i}) Tj ET"); out.push_str(&format!( "{} 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content_obj(i, count), content.len() )); } let startxref = out.len(); let total = 1 + 2 + count * 2; 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() } /// The `/MediaBox` widths of every page, in order — the page identities. fn page_widths(bytes: &[u8]) -> Vec { let mut doc = PdfDocument::parse(bytes).expect("the saved file must parse"); (0..doc.page_count()) .map(|i| { let page = doc.page(i).unwrap_or_else(|e| panic!("page {i}: {e}")); page.media_box[2] as i64 }) .collect() } fn parse(bytes: &[u8]) -> PdfDocument<'_> { PdfDocument::parse(bytes).expect("parses") } #[test] fn the_fixture_itself_reads_back_correctly() { // If this fails, every other test in the file is meaningless. let bytes = document_with_pages(4); assert_eq!(page_widths(&bytes), vec![100, 101, 102, 103]); } #[test] fn reordering_pages_survives_a_save_and_reparse() { let bytes = document_with_pages(4); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.set_order(&[3, 1, 0, 2]).expect("reorders"); let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(report.final_page_count, 4); assert_eq!( page_widths(&saved), vec![103, 101, 100, 102], "the saved page order does not match the plan" ); } #[test] fn moving_one_page_survives_a_save_and_reparse() { let bytes = document_with_pages(3); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.move_page(0, 2).expect("moves"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(page_widths(&saved), vec![101, 102, 100]); } #[test] fn deleting_a_page_survives_a_save_and_reparse() { let bytes = document_with_pages(4); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.delete(1).expect("deletes"); let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(report.final_page_count, 3); assert_eq!(page_widths(&saved), vec![100, 102, 103]); // The reader's own count must agree with the kid array. A stale // /Count is the classic page-tree bug: readers disagree about how many // pages the document has and none of them errors. let saved_doc = parse(&saved); assert_eq!(saved_doc.page_count(), 3, "/Count disagrees with /Kids"); } #[test] fn duplicating_a_page_produces_two_readable_copies() { let bytes = document_with_pages(2); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.duplicate(0).expect("duplicates"); let (saved, report) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(report.final_page_count, 3); assert_eq!( page_widths(&saved), vec![100, 100, 101], "the copy must sit directly after the original" ); // Both copies must reach their content. A duplicate that shares the // original's /Contents reference is correct; one that lost it renders // blank, and blank is a valid page. let mut saved_doc = parse(&saved); for i in 0..2 { let page = saved_doc.page(i).expect("page"); assert!( !page.content_data.is_empty(), "copy {i} has no content stream" ); } } #[test] fn inserting_a_new_page_survives_a_save_and_reparse() { let bytes = document_with_pages(2); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); let mut page = PdfDict::new(); page.set( "MediaBox", PdfObj::Array(vec![ PdfObj::Int(0), PdfObj::Int(0), PdfObj::Int(555), PdfObj::Int(200), ]), ); plan.insert_new(1, page).expect("inserts"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(page_widths(&saved), vec![100, 555, 101]); } /// Every page must reach its own content after a reorder — not the /// content of whichever page used to be at that index. /// /// This is the assertion that catches a page tree rebuilt with the right /// *shape* and the wrong *references*. Page order alone cannot see it: /// the sizes come from the page dictionaries, which move together with /// them; the content is a separate object and can be left behind. #[test] fn each_page_keeps_its_own_content_after_a_reorder() { let bytes = document_with_pages(4); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.set_order(&[2, 0, 3, 1]).expect("reorders"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); let mut saved_doc = parse(&saved); let expected = ["page 2", "page 0", "page 3", "page 1"]; for (i, want) in expected.iter().enumerate() { let page = saved_doc.page(i).expect("page"); let text = String::from_utf8_lossy(&page.content_data).to_string(); assert!( text.contains(want), "page {i} should show {want:?} but its content is {text:?}" ); } } /// Every page's `/Parent` must point at the rebuilt page tree. /// /// A page whose `/Parent` still names an old intermediate node inherits /// that node's attributes — a `/MediaBox` or `/Resources` that no longer /// applies — and a reader following `/Parent` upward can leave the tree /// entirely. #[test] fn every_page_parents_to_the_rebuilt_tree() { let bytes = document_with_pages(3); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.set_order(&[2, 1, 0]).expect("reorders"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); let mut saved_doc = parse(&saved); let root_ref = saved_doc .trailer() .get("Root") .and_then(|o| o.as_ref().copied()) .expect("root"); let root = saved_doc.resolve_ref(root_ref).expect("root object"); let pages_ref = root .as_dict() .and_then(|d| d.get("Pages")) .and_then(|o| o.as_ref().copied()) .expect("pages ref"); for i in 0..saved_doc.page_count() { let page_ref = saved_doc.page_object_ref(i).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page object"); let parent = page .as_dict() .and_then(|d| d.get("Parent")) .and_then(|o| o.as_ref().copied()) .unwrap_or_else(|| panic!("page {i} has no /Parent")); assert_eq!(parent, pages_ref, "page {i} parents outside the tree"); } } /// `/Count` must equal the number of kids. /// /// Our own parser walks `/Kids` and never reads `/Count`, so it cannot see /// a stale one — mutation-testing proved that: setting `/Count` to a /// constant 5 passed every other test in this file. Other readers *do* /// trust `/Count`, and a document where the two disagree opens with the /// wrong number of pages in one viewer and the right number in another. /// /// So this reads the raw page-tree node rather than asking the document /// how many pages it thinks there are. #[test] fn the_page_count_matches_the_kid_array() { for order in [vec![0usize, 1, 2, 3], vec![3, 2], vec![1, 1, 1]] { let bytes = document_with_pages(4); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.set_order(&order).expect("reorders"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); let mut saved_doc = parse(&saved); let root_ref = saved_doc .trailer() .get("Root") .and_then(|o| o.as_ref().copied()) .expect("root"); let root = saved_doc.resolve_ref(root_ref).expect("root object"); let pages_ref = root .as_dict() .and_then(|d| d.get("Pages")) .and_then(|o| o.as_ref().copied()) .expect("pages ref"); let pages = saved_doc.resolve_ref(pages_ref).expect("pages node"); let dict = pages.as_dict().expect("pages is a dictionary"); let count = dict.get_int("Count").expect("/Count must be present"); let kids = dict .get_array("Kids") .map(|k| k.len()) .expect("/Kids must be present"); assert_eq!( count as usize, kids, "order {order:?}: /Count says {count} but there are {kids} kids" ); assert_eq!( kids, order.len(), "order {order:?}: the kid array is the wrong length" ); } } /// Every kid in the rebuilt tree must be a distinct, resolvable page. /// /// A rebuilt array that repeated a reference, or kept a dangling one from /// a deleted page, still has the right *length*, so the count check above /// cannot see it. #[test] fn every_kid_resolves_to_a_page_object() { let bytes = document_with_pages(4); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.delete(2).expect("deletes"); plan.move_page(0, 2).expect("moves"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); let mut saved_doc = parse(&saved); let root_ref = saved_doc .trailer() .get("Root") .and_then(|o| o.as_ref().copied()) .expect("root"); let root = saved_doc.resolve_ref(root_ref).expect("root object"); let pages_ref = root .as_dict() .and_then(|d| d.get("Pages")) .and_then(|o| o.as_ref().copied()) .expect("pages ref"); let pages = saved_doc.resolve_ref(pages_ref).expect("pages node"); let kids: Vec = pages .as_dict() .and_then(|d| d.get_array("Kids")) .expect("kids") .to_vec(); for (i, kid) in kids.iter().enumerate() { let r = kid .as_ref() .copied() .unwrap_or_else(|| panic!("kid {i} is not an indirect reference")); let obj = saved_doc .resolve_ref(r) .unwrap_or_else(|e| panic!("kid {i} does not resolve: {e}")); assert_eq!( obj.as_dict().and_then(|d| d.get_name("Type")), Some("Page"), "kid {i} is not a /Page" ); } } /// A page moved out of a *nested* tree must be re-parented. /// /// The flat fixture above cannot test this: its pages already parent to /// the root node, so the re-parenting branch never runs and mutation /// testing showed it could be deleted with every other test still green. /// A real document with an intermediate `/Pages` node is the case that /// matters — leave the old `/Parent` in place and the page keeps /// inheriting attributes from a node it is no longer under. #[test] fn a_page_from_a_nested_tree_is_reparented() { // Root -> [intermediate -> [page A, page B]], with the intermediate // node supplying a /MediaBox its children inherit. let bytes = { let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); offsets.push(out.len()); out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets.push(out.len()); out.push_str("2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R] >>\nendobj\n"); offsets.push(out.len()); out.push_str( "3 0 obj\n<< /Type /Pages /Parent 2 0 R /Count 2 /Kids [4 0 R 5 0 R] \ /MediaBox [0 0 321 654] >>\nendobj\n", ); offsets.push(out.len()); out.push_str("4 0 obj\n<< /Type /Page /Parent 3 0 R >>\nendobj\n"); offsets.push(out.len()); out.push_str("5 0 obj\n<< /Type /Page /Parent 3 0 R >>\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" )); out.into_bytes() }; let mut doc = parse(&bytes); assert_eq!(doc.page_count(), 2, "the nested fixture must parse"); let mut plan = PagePlan::new(doc.page_count()); plan.swap(0, 1).expect("swaps"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); let mut saved_doc = parse(&saved); let root_ref = saved_doc .trailer() .get("Root") .and_then(|o| o.as_ref().copied()) .expect("root"); let root = saved_doc.resolve_ref(root_ref).expect("root object"); let pages_ref = root .as_dict() .and_then(|d| d.get("Pages")) .and_then(|o| o.as_ref().copied()) .expect("pages ref"); for i in 0..saved_doc.page_count() { let page_ref = saved_doc.page_object_ref(i).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page object"); let parent = page .as_dict() .and_then(|d| d.get("Parent")) .and_then(|o| o.as_ref().copied()) .unwrap_or_else(|| panic!("page {i} has no /Parent")); assert_eq!( parent, pages_ref, "page {i} still parents to the old intermediate node, so it \ inherits from a node it is no longer under" ); } } /// The original bytes must remain intact: an incremental save appends. /// /// A signed or already-incrementally-updated document depends on its /// earlier revisions being byte-identical. Rewriting them in place /// invalidates every signature over them. #[test] fn the_original_revision_is_left_untouched() { let bytes = document_with_pages(3); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); plan.delete(1).expect("deletes"); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert!( saved.len() > bytes.len(), "an incremental save must grow the file" ); assert_eq!( &saved[..bytes.len()], &bytes[..], "the original revision was modified rather than appended to" ); } #[test] fn an_empty_plan_is_refused() { let bytes = document_with_pages(2); let mut doc = parse(&bytes); let mut plan = PagePlan::new(doc.page_count()); // Reaching an empty plan requires bypassing delete()'s own guard. plan.delete(0).expect("first delete is allowed"); assert_eq!( plan.delete(0), Err(PageOpError::WouldEmptyDocument), "the last page cannot be deleted" ); let (saved, _) = apply_plan(&mut doc, &bytes, &plan).expect("applies"); assert_eq!(page_widths(&saved), vec![101]); } // ------------------------------------------------------------ importing #[test] fn importing_a_page_appends_it_and_it_reads_back() { let dest_bytes = document_with_pages(2); let src_bytes = document_with_pages(3); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[2]).expect("imports"); assert_eq!(report.final_page_count, 3); assert!( report.objects_copied > 0, "importing must copy the page and what it reaches" ); // The imported page is source page 2, which is 102 wide. assert_eq!(page_widths(&saved), vec![100, 101, 102]); } /// The imported page must bring its content with it. /// /// This is the assertion that catches the central import bug: copying the /// page dictionary but not the objects it references. The result is a page /// whose `/Contents` names an object number that means something else /// entirely in the destination — usually another page's content, always /// silently. #[test] fn an_imported_page_brings_its_content_stream() { let dest_bytes = document_with_pages(1); let src_bytes = document_with_pages(3); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut src, &[1]).expect("imports"); let mut saved_doc = parse(&saved); assert_eq!(saved_doc.page_count(), 2); let imported = saved_doc.page(1).expect("the imported page"); let text = String::from_utf8_lossy(&imported.content_data).to_string(); assert!( text.contains("page 1"), "the imported page shows {text:?}, not its own source content" ); // And the destination's own page must be undisturbed. let original = saved_doc.page(0).expect("page 0"); let original_text = String::from_utf8_lossy(&original.content_data).to_string(); assert!( original_text.contains("page 0"), "importing overwrote the destination's own content: {original_text:?}" ); } #[test] fn importing_several_pages_keeps_their_order() { let dest_bytes = document_with_pages(1); let src_bytes = document_with_pages(4); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[3, 1]).expect("imports"); assert_eq!(report.final_page_count, 3); assert_eq!(page_widths(&saved), vec![100, 103, 101]); } #[test] fn importing_no_pages_changes_nothing() { let dest_bytes = document_with_pages(2); let src_bytes = document_with_pages(2); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let (saved, report) = import_pages(&mut dest, &dest_bytes, &mut src, &[]).expect("no-op"); assert_eq!(report.final_page_count, 2); assert_eq!(saved, dest_bytes, "a no-op import must not touch the file"); } #[test] fn importing_a_page_that_does_not_exist_is_refused() { let dest_bytes = document_with_pages(1); let src_bytes = document_with_pages(2); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let err = import_pages(&mut dest, &dest_bytes, &mut src, &[9]).expect_err("no page 9"); assert!(matches!(err, PageOpError::NoSuchPage { index: 9, .. })); } /// An imported page must not carry a `/Parent` pointing into the source. /// /// Following it would lead a reader out of this document's page tree and /// into object numbers that belong to something else. #[test] fn an_imported_page_parents_into_the_destination() { let dest_bytes = document_with_pages(1); let src_bytes = document_with_pages(2); let mut dest = parse(&dest_bytes); let mut src = parse(&src_bytes); let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut src, &[0]).expect("imports"); let mut saved_doc = parse(&saved); let root_ref = saved_doc .trailer() .get("Root") .and_then(|o| o.as_ref().copied()) .expect("root"); let root = saved_doc.resolve_ref(root_ref).expect("root object"); let pages_ref = root .as_dict() .and_then(|d| d.get("Pages")) .and_then(|o| o.as_ref().copied()) .expect("pages ref"); let imported_ref = saved_doc.page_object_ref(1).expect("imported page ref"); let imported = saved_doc.resolve_ref(imported_ref).expect("imported page"); let parent = imported .as_dict() .and_then(|d| d.get("Parent")) .and_then(|o| o.as_ref().copied()) .expect("the imported page must have a /Parent"); assert_eq!( parent, pages_ref, "the imported page parents into the source" ); } /// A page inheriting `/MediaBox` from its parent must keep that size when /// imported — the ancestor it inherited from is not coming with it. #[test] fn an_imported_page_keeps_an_inherited_media_box() { // A source whose page carries no /MediaBox of its own: it inherits // one from the /Pages node, which is exactly the shape that breaks on // import if the attribute is not resolved first. let src = { let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); offsets.push(out.len()); out.push_str("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); offsets.push(out.len()); out.push_str( "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] \ /MediaBox [0 0 333 444] >>\nendobj\n", ); offsets.push(out.len()); out.push_str("3 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n"); let startxref = out.len(); out.push_str("xref\n0 4\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); out.into_bytes() }; let dest_bytes = document_with_pages(1); let mut dest = parse(&dest_bytes); let mut source = parse(&src); assert_eq!(source.page_count(), 1, "the source fixture must parse"); let (saved, _) = import_pages(&mut dest, &dest_bytes, &mut source, &[0]).expect("imports"); let mut saved_doc = parse(&saved); let imported = saved_doc.page(1).expect("the imported page"); assert_eq!( imported.media_box[2] as i64, 333, "the inherited /MediaBox was lost, so the page changed size" ); }