Test preservation of PDF 2.0 structure metadata (#5073)

This commit is contained in:
Harrison Booth authored and GitHub committed 2026-11-09 18:27:50 +08:00
1 parent 1bffa8d72d
commit b44257b92b
2 files changed
+152

No files matched your search

+71
View File
@@ -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<u8> {
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);
}
+81
View File
@@ -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`.