diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index bef69c43b7..485e7912f0 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -2,6 +2,7 @@ //! refers to. use std::collections::{HashSet, VecDeque}; +use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; @@ -164,6 +165,8 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us fn referenced_names(doc: &Document) -> HashSet> { let mut out = HashSet::new(); let mut seen = HashSet::new(); + let mut named_refs = HashSet::new(); + let mut named_held: std::collections::HashMap<*const Object, Arc> = std::collections::HashMap::new(); let mut stack = vec![Object::Dict(doc.trailer().clone())]; if let Some(r) = doc.root() { stack.push(Object::Ref(r)); @@ -180,11 +183,26 @@ fn referenced_names(doc: &Document) -> HashSet> { // string, and an unresolved ref has no name, so the destination it uses would // look unreferenced and be removed. A GoToR or GoToE /D names a destination in // another document, so it does not count as a use of this one's. - if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { + // A name given by reference is copied once, however many dictionaries (or aliases + // of the object) name it. The objects are kept so an address is never reused. + let mut named = |x: Option<&Object>| { + let x = x?; + if let Object::Ref(r) = x + && !named_refs.insert(*r) + { + return None; + } + let held = doc.resolve(x); + if matches!(x, Object::Ref(_)) && named_held.insert(Arc::as_ptr(&held), held.clone()).is_some() { + return None; + } + dest_name(held.as_ref()) + }; + if let Some(n) = named(d.get(b"Dest")) { out.insert(n); } if d.name(b"S") == Some(b"GoTo") - && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) + && let Some(n) = named(d.get(b"D")) { out.insert(n); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index eee2e9570c..5bb20e8159 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -4439,6 +4439,36 @@ fn indirectly_referenced_destination_is_kept() { assert!(!left.contains(&b"unused".to_vec())); } +#[test] +fn many_links_naming_one_destination_string_by_reference_keep_it() { + // The name string is copied once however many links name it by reference; it is still a use. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let name_ref = Object::Ref(doc.add(s("target"))); + let links: Vec = (0..50) + .map(|_| { + let mut link = Dict::new(); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"Dest".to_vec(), name_ref.clone()); + Object::Ref(doc.add(Object::Dict(link))) + }) + .collect(); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(links))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("target"), fit.clone(), s("unused"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unreferenced_dests, 1); + assert_eq!(named_dest_keys(&doc), vec![b"target".to_vec()]); +} + #[test] fn a_destination_named_only_by_a_remote_or_embedded_go_to_is_removed() { // The /D of a GoToR or GoToE action names a destination in another document. A local