From b44257b92b0c3bb71e58954cfbf0e5e22c6a4e87 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 9 Nov 2026 04:27:50 -0600 Subject: [PATCH] Test preservation of PDF 2.0 structure metadata (#5073) --- crates/cos/tests/it/writer.rs | 71 ++++++++++++++++++++++++++++++ crates/organize/src/tests.rs | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 2716b9242e..1dc6275b5f 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun // No qpdf check here: qpdf counts every offset from the header in a file with bytes before // it, so it already reconstructs this input before anything is appended. } + +/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 +/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4), +/// both through indirect objects that nothing else references. +fn pdf2_fixture() -> Vec { + Pdf::new() + .version("2.0") + .obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>") + .obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>") + .stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET") + .obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>") + .obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>") + .obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>") + .obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>") + .build() +} + +/// What `pdf2_fixture` declares, read back through the catalog. +fn assert_dpart_and_namespaces(doc: &Document) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot")); + let root = root.as_dict().unwrap(); + let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept")); + let node = node.as_dict().unwrap(); + assert_eq!(node.name(b"Type"), Some(&b"DPart"[..])); + // Both ends of the part are still the one page, found through /Pages (references are + // renumbered by a full save, so they are compared inside this document). + let pages = doc.resolve(catalog.get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid"); + assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page"); + assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page"); + assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some()); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap()); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept")); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + let namespace = namespace.as_dict().unwrap(); + assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn"); + assert!(namespace.get(b"Schema").is_some(), "/Schema is retained"); +} + +#[test] +fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() { + let original = Arc::new(pdf2_fixture()); + let doc = Document::open(original.clone()).unwrap(); + assert_dpart_and_namespaces(&doc); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let full = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(full.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_dpart_and_namespaces(&back); + // A second generation, rewritten from the rewritten file. + let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap(); + assert!(second.repair_log().is_empty(), "{:?}", second.repair_log()); + assert_dpart_and_namespaces(&second); + } + + // An incremental save of an unrelated edit leaves the file's bytes, and so both, alone. + let mut edited = Document::open(original.clone()).unwrap(); + edited.add(Object::Int(1)); + let updated = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(updated.starts_with(&original)); + let reopened = Document::open(Arc::new(updated)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + assert_dpart_and_namespaces(&reopened); +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 0aa6d149d9..451a6dc397 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4505,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } +/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts +/// at its first page, and a structure tree root listing a structure namespace (14.7.4). +fn with_document_parts_and_namespaces() -> Document { + // PDF 2.0, the version these structures belong to. + let mut bytes = fixture(); + let header = bytes.get_mut(..8).expect("the fixture starts with its header"); + assert!(header.starts_with(b"%PDF-1.")); + header.copy_from_slice(b"%PDF-2.0"); + let mut doc = open(bytes); + let first = pages(&doc).unwrap()[0].obj; + let mut namespace = pdfcraft_cos::Dict::new(); + namespace.set(b"Type".to_vec(), Object::name("Namespace")); + namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false })); + let namespace = doc.add(Object::Dict(namespace)); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)])); + let tree = doc.add(Object::Dict(tree)); + let mut part = pdfcraft_cos::Dict::new(); + part.set(b"Type".to_vec(), Object::name("DPart")); + part.set(b"Start".to_vec(), Object::Ref(first)); + part.set(b"End".to_vec(), Object::Ref(first)); + let part = doc.add(Object::Dict(part)); + let mut root = pdfcraft_cos::Dict::new(); + root.set(b"Type".to_vec(), Object::name("DPartRoot")); + root.set(b"DPartRootNode".to_vec(), Object::Ref(part)); + let root = doc.add(Object::Dict(root)); + doc.update_dict(doc.root().unwrap(), |c| { + c.set(b"DPartRoot".to_vec(), Object::Ref(root)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree)); + }) + .unwrap(); + doc +} + +fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped"))); + let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped"))); + let part = node.as_dict().unwrap(); + assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}"); + assert!(part.get(b"Start").is_some(), "{what}: /Start dropped"); + assert!(part.get(b"End").is_some(), "{what}: /End dropped"); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped"))); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped"))); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}"); + assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed"); + assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept"); +} + +#[test] +fn page_edits_keep_document_parts_and_structure_namespaces() { + type Edit = fn(&mut Document); + let edits: [(&str, Edit); 6] = [ + ("rotate", |d| rotate_pages(d, &[0], 90).unwrap()), + ("move", |d| move_pages(d, &[0], 2).unwrap()), + ("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()), + ("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()), + ("insert blank", |d| { + let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap(); + }), + // The part's start page goes: the keys stay, the page reference is the part's to repair. + ("delete", |d| delete_pages(d, &[0]).unwrap()), + ]; + for (what, edit) in edits { + let mut doc = with_document_parts_and_namespaces(); + let base = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup"); + doc = open(base); + edit(&mut doc); + assert_document_parts_and_namespaces_kept(&doc, what); + assert_document_parts_and_namespaces_kept( + &open(write_incremental(&doc, &SaveOptions::default()).unwrap()), + &format!("{what}, incremental save"), + ); + assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save")); + } +} + /// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a /// `%` comment at the end of a piece without a line break continues through the first line of the /// next piece in both resource pruning (`extract_pages`) and `page_as_form`.