From 02b6fcc5ef15edb0df2da76f5b6a4138dde1e945 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Sat, 24 Oct 2026 07:29:33 -0700 Subject: [PATCH] fix(optimize): a GoToR or GoToE /D no longer keeps a local named destination (#4445) Clean Up removes named destinations that nothing in the document refers to. referenced_names counted the /D of GoTo, GoToR and GoToE actions alike. The /D of a GoToR or GoToE action names a destination in another document, so a local destination with the same name was kept although nothing in the document used it. Only a GoTo's /D now counts as a use. A /Dest key and a GoTo's /D are read as before. Test: links with a GoTo, a GoToR and a GoToE action name three destinations; optimize() keeps the one the GoTo uses and removes the other two. --- crates/optimize/src/links.rs | 5 ++-- crates/optimize/tests/optimize.rs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 082d56e7d..cbd64eb92 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -152,11 +152,12 @@ fn referenced_names(doc: &Document) -> HashSet> { Object::Dict(d) => { // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. + // 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())) { out.insert(n); } - if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) + 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())) { out.insert(n); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8802ef351..3e69e27cb 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1273,6 +1273,50 @@ fn indirectly_referenced_destination_is_kept() { assert!(!left.contains(&b"unused".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 + // destination of the same name that nothing else uses is unreferenced. + 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 link = |doc: &mut Document, action: Dict| { + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + 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"A".to_vec(), Object::Dict(action)); + Object::Ref(doc.add(Object::Dict(link))) + }; + let mut go_to = Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), s("local")); + let mut remote = Dict::new(); + remote.set(b"S".to_vec(), Object::name("GoToR")); + remote.set(b"F".to_vec(), s("other.pdf")); + remote.set(b"D".to_vec(), s("remote")); + let mut target = Dict::new(); + target.set(b"R".to_vec(), Object::name("C")); + target.set(b"N".to_vec(), s("attached.pdf")); + let mut embedded = Dict::new(); + embedded.set(b"S".to_vec(), Object::name("GoToE")); + embedded.set(b"T".to_vec(), Object::Dict(target)); + embedded.set(b"D".to_vec(), s("embedded")); + let links = vec![link(&mut doc, go_to), link(&mut doc, remote), link(&mut doc, embedded)]; + 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("embedded"), fit.clone(), s("local"), fit.clone(), s("remote"), 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, 2); + assert_eq!(named_dest_keys(&doc), vec![b"local".to_vec()]); +} + /// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). fn named_dest_keys(doc: &Document) -> Vec> { let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();