From cfda92233d187e2cf56a96b760501a7643b0fa59 Mon Sep 17 00:00:00 2001 From: Matej Vrzala Date: Fri, 23 Oct 2026 16:19:17 +0200 Subject: [PATCH] fix(optimize): keep a destination referenced only through an indirect /Dest (#4212) Reduce File Size removes named destinations that nothing refers to. It collected the referenced names by reading /Dest and a GoTo's /D directly, so when a link's /Dest was an indirect reference to the name string (valid PDF) rather than the string itself, the name was not counted. The destination it used was then treated as unreferenced and deleted, silently breaking the link. referenced_names now resolves /Dest and /D before reading the name, so an indirectly referenced destination is kept. Test: a link whose /Dest is a reference to the name string keeps its destination through optimize(); an unused destination is still removed. Reported-by: BS.ehlas.cz --- crates/optimize/src/links.rs | 7 ++++-- crates/optimize/tests/optimize.rs | 42 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 744881df7..082d56e7d 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -150,11 +150,14 @@ fn referenced_names(doc: &Document) -> HashSet> { while let Some(o) = stack.pop() { match o { Object::Dict(d) => { - if let Some(n) = d.get(b"Dest").and_then(dest_name) { + // 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. + 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")) - && let Some(n) = d.get(b"D").and_then(dest_name) + && 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 6cf53d4fc..6d9a9bb44 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1097,3 +1097,45 @@ fn a_hostile_soft_mask_leaves_its_image_unchanged() { assert_eq!(stream(&doc, photo), before); assert_eq!(stream(&doc, mask), hostile); } + +#[test] +fn indirectly_referenced_destination_is_kept() { + // A link's /Dest can be an indirect reference to the name string, not the string itself. Reduce + // File Size read the name only from a direct string, so it deleted a destination the link still + // used, silently breaking the link. + 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())); + // A link whose /Dest is `ref -> "target"`, and an unused destination for contrast. + let name_ref = Object::Ref(doc.add(s("target"))); + 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"Dest".to_vec(), name_ref); + let link = Object::Ref(doc.add(Object::Dict(link))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![link]))).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(); + // Only "unused" is removed; the indirectly-referenced "target" is kept. + assert_eq!(report.unreferenced_dests, 1); + let left = named_dest_keys(&doc); + assert!(left.contains(&b"target".to_vec()), "indirectly referenced destination was kept: {left:?}"); + assert!(!left.contains(&b"unused".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(); + let names = doc.resolve(cat.get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"Dests").unwrap()).as_dict().cloned().unwrap(); + let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); + arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() +}