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
This commit is contained in:
Matej Vrzala
2026-10-23 10:19:17 -04:00
committed by GitHub
parent f2e33db113
commit cfda92233d
2 changed files with 47 additions and 2 deletions
+5 -2
View File
@@ -150,11 +150,14 @@ fn referenced_names(doc: &Document) -> HashSet<Vec<u8>> {
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);
}
+42
View File
@@ -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<Vec<u8>> {
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()
}