From da9cefa33cf9d86a866dbaa675ed659bfaef4635 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:16:19 +0200 Subject: [PATCH] M4: copied pages keep the resources they list only up the page tree (part of #1628) (#1635) --- crates/organize/src/import.rs | 57 ++++++++++++++++++++++++++++++++++- crates/organize/src/tests.rs | 34 +++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 8e1749344..c420d2d59 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -300,9 +300,10 @@ fn import_pages_mapped( ocgs: Vec::new(), }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); + let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); for (k, v) in src_dict.iter() { - if SKIP_ON_PAGE.contains(&k.as_slice()) { + if SKIP_ON_PAGE.contains(&k.as_slice()) || (k.as_slice() == b"Resources" && resources.is_some()) { continue; } if k.as_slice() == b"Annots" { @@ -364,6 +365,10 @@ fn import_pages_mapped( } d.set(k.clone(), copier.copy_value(dst, v, None)); } + if let Some(r) = resources { + let r = copier.copy_value(dst, &r, None); + d.set(b"Resources".to_vec(), r); + } for k in INHERITABLE { if !d.contains(k) && let Some(v) = inherited.get(k) @@ -398,6 +403,56 @@ fn import_pages_mapped( Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map, renamed_fields }) } +/// A page's `/Resources` with the names that only its page-tree ancestors list, or `None` when +/// they add nothing to the dictionary it would have anyway (its own, or else its nearest +/// ancestor's). Readers (ours included) look a resource name up the page and then up the tree, +/// so a page that lists its font but inherits its images still shows them. A copy hangs from +/// another tree, so it must list them itself or it draws nothing of them (#1628). The page's +/// entry wins over an ancestor's, and a nearer ancestor's over a farther one's. +fn resources_with_ancestors(src: &Document, page: &Dict) -> Option { + let mut merged = page.get(b"Resources").map(|r| src.dict(r).unwrap_or_default()); + let mut added = false; + let mut seen = std::collections::HashSet::new(); + let mut parent = page.reference(b"Parent"); + // Bounded: a damaged tree's `/Parent` links can loop or run deep. + while let Some(r) = parent + && seen.insert(r) + && seen.len() <= 256 + { + let node = src.get(r); + let Some(node) = node.as_dict() else { break }; + parent = node.reference(b"Parent"); + let Some(theirs) = node.get(b"Resources").and_then(|r| src.dict(r)) else { continue }; + let Some(mine) = merged.as_mut() else { + merged = Some(theirs); + continue; + }; + for (category, entries) in theirs.iter() { + match (mine.get(category).map(|c| src.dict(c)), src.dict(entries)) { + (None, _) => { + mine.set(category.clone(), entries.clone()); + added = true; + } + (Some(Some(mut names)), Some(entries)) => { + let before = names.len(); + for (name, value) in entries.iter() { + if !names.contains(name) { + names.set(name.clone(), value.clone()); + } + } + if names.len() > before { + mine.set(category.clone(), Object::Dict(names)); + added = true; + } + } + // `/ProcSet` and the like: the page's own value stands. + _ => {} + } + } + } + merged.filter(|_| added).map(Object::Dict) +} + /// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree). fn named_dest(src: &Document, name: &[u8]) -> Option { let catalog = src.get(src.root()?).as_dict().cloned()?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 274aed3e4..ec9f610fe 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -476,6 +476,40 @@ fn inherited_attributes_travel_with_copied_pages() { assert_eq!(hay.pages().first().unwrap().render_dimensions(), (250.0, 500.0), "rotated 90°"); } +#[test] +fn resources_split_between_page_and_tree_travel_together() { + // The page names its own font but draws a form that only the page-tree node lists. Readers + // (ours included) look a name up the whole chain, so the page shows; a copy that kept only + // the page's own dictionary drew nothing (#1628). + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] /Resources << /XObject << /X0 5 0 R >> /Font << /F1 6 0 R /F2 6 0 R >> >> >>" + .into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F2 7 0 R >> >> >>".into(), + { + let s = "q 100 0 0 100 50 50 cm /X0 Do Q BT /F2 12 Tf (x) Tj ET"; + format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len()) + }, + { + let s = "0 0 1 rg 0 0 1 1 re f"; + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 1 1] /Length {} >>\nstream\n{s}\nendstream", s.len()) + }, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(), + ]; + let src = open(build(&b, "/Root 1 0 R")); + let out = full_roundtrip(&combine(&[("S", &src), ("S again", &src)]).unwrap()); + for i in 0..2 { + let res = out.dict(page_dict(&out, i).get(b"Resources").unwrap()).unwrap(); + let entry = |cat: &[u8], name: &[u8]| res.get(cat).and_then(|c| out.dict(c)).and_then(|d| d.get(name).map(|v| out.resolve(v))); + let form = entry(b"XObject", b"X0").expect("the tree's form comes along"); + assert_eq!(form.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Form"[..])); + let font = |name: &[u8]| entry(b"Font", name).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))); + assert_eq!(font(b"F1").as_deref(), Some(&b"Helvetica"[..]), "a name only the tree has is added"); + assert_eq!(font(b"F2").as_deref(), Some(&b"Courier"[..]), "the page's own entry wins over the tree's"); + } +} + #[test] fn links_are_rewired_to_copied_pages_or_dropped() { let a = doc_a();