From ba4a7a6d42f84ea1932c63ca83c714f06f2c3fcd Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 07:40:51 -0400 Subject: [PATCH] M11: Optimizer Clean Up removes invalid links and bookmark destinations and unreferenced named destinations Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 5 + crates/automation/src/tools.rs | 4 +- crates/optimize/src/lib.rs | 19 +++ crates/optimize/src/links.rs | 228 ++++++++++++++++++++++++++++++ crates/optimize/tests/optimize.rs | 54 +++++++ crates/ui-egui/src/optimize_ui.rs | 2 + parity/acrobat-features.toml | 10 +- 7 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 crates/optimize/src/links.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1de5faa0..7aa2543a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -545,6 +545,8 @@ impl Automation { ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), ("flate_unencoded", &mut settings.flate_unencoded), + ("remove_invalid_links", &mut settings.remove_invalid_links), + ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), ] { if let Some(b) = a.opt_bool(key)? { *flag = b; @@ -579,6 +581,9 @@ impl Automation { "alternate_images": o.alternate_images, "tags_removed": o.tags_removed, "streams_compressed": o.streams_compressed, + "invalid_links": o.invalid_links, + "invalid_bookmarks": o.invalid_bookmarks, + "unreferenced_dests": o.unreferenced_dests, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), })) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 35d12a96..d7df0155 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -812,7 +812,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -825,6 +825,8 @@ pub fn tools() -> Vec { "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, + "remove_invalid_links": { "type": "boolean" }, + "remove_unreferenced_dests": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, }), &["doc", "path"], diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index eeea9069..85e189df 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -17,6 +17,7 @@ mod audit; mod images; +mod links; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -70,6 +71,10 @@ pub struct Settings { pub discard_print_settings: bool, /// Flate-compress streams stored without a filter. pub flate_unencoded: bool, + /// Clean Up: remove links (and bookmark destinations) that point nowhere. + pub remove_invalid_links: bool, + /// Clean Up: remove named destinations nothing refers to. + pub remove_unreferenced_dests: bool, } impl Default for Settings { @@ -83,6 +88,8 @@ impl Default for Settings { discard_tags: false, discard_print_settings: false, flate_unencoded: true, + remove_invalid_links: true, + remove_unreferenced_dests: true, } } } @@ -101,6 +108,10 @@ pub struct Report { pub tags_removed: bool, pub print_settings: usize, pub streams_compressed: usize, + /// Links removed and bookmarks whose destination was cleared (Clean Up). + pub invalid_links: usize, + pub invalid_bookmarks: usize, + pub unreferenced_dests: usize, } /// Optimize `doc` in place. @@ -174,6 +185,14 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result {} } } + if settings.remove_invalid_links { + let (l, b) = links::remove_invalid(doc, &pages)?; + report.invalid_links = l; + report.invalid_bookmarks = b; + } + if settings.remove_unreferenced_dests { + report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; + } if settings.flate_unencoded { for num in doc.object_numbers() { let r = ObjRef { num, generation: doc.generation(num) }; diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs new file mode 100644 index 00000000..b294cfe2 --- /dev/null +++ b/crates/optimize/src/links.rs @@ -0,0 +1,228 @@ +//! Clean Up: links and bookmarks whose destination doesn't exist, and named destinations nothing +//! refers to. + +use std::collections::{HashSet, VecDeque}; + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary. +fn named_dests(doc: &Document) -> HashSet> { + let mut out = HashSet::new(); + let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out }; + if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) { + out.extend(d.iter().map(|(k, _)| k.clone())); + } + if let Some(tree) = names_tree(doc, &cat) { + for (k, _) in tree_entries(doc, &tree) { + out.insert(k); + } + } + out +} + +fn names_tree(doc: &Document, cat: &Dict) -> Option { + let names = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned())?; + names.get(b"Dests").cloned() +} + +/// A name tree's entries (key bytes, value), flattened. +fn tree_entries(doc: &Document, node: &Object) -> Vec<(Vec, Object)> { + let mut out = Vec::new(); + let mut queue = VecDeque::from([(node.clone(), 0usize)]); + let mut seen = HashSet::new(); + while let Some((n, depth)) = queue.pop_front() { + if depth > 64 { + continue; + } + if let Object::Ref(r) = n + && !seen.insert(r) + { + continue; + } + let Some(d) = doc.resolve(&n).as_dict().cloned() else { continue }; + if let Some(a) = d.get(b"Names").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) { + for pair in a.chunks(2) { + if let [k, v] = pair + && let Some(s) = doc.resolve(k).as_string() + { + out.push((s.bytes.clone(), v.clone())); + } + } + } + if let Some(kids) = d.get(b"Kids").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) { + queue.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + } + out +} + +/// The destination of a link, bookmark or action dictionary: `/Dest`, or a GoTo's `/D`. +fn destination(doc: &Document, d: &Dict) -> Option { + if let Some(dest) = d.get(b"Dest") { + return Some(doc.resolve(dest).as_ref().clone()); + } + let a = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned())?; + (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").map(|x| doc.resolve(x).as_ref().clone())).flatten() +} + +/// The name a destination refers to, if it is a named one. +fn dest_name(o: &Object) -> Option> { + match o { + Object::String(s) => Some(s.bytes.clone()), + Object::Name(n) => Some(n.to_vec()), + _ => None, + } +} + +fn valid(o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { + match o { + Object::Array(a) => match a.first() { + Some(Object::Ref(r)) => pages.contains(r), + Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count, + _ => false, + }, + other => dest_name(other).is_some_and(|n| names.contains(&n)), + } +} + +/// Remove links whose destination is missing, and clear the destination of such bookmarks +/// (they stay, so their children do too). Returns (links, bookmarks). +pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), printcraft_cos::CosError> { + let set: HashSet = pages.iter().copied().collect(); + let names = named_dests(doc); + let mut links = 0; + for p in pages { + let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; + let Some(annots) = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { continue }; + let keep: Vec = annots + .iter() + .filter(|x| { + let d = doc.resolve(x).as_dict().cloned().unwrap_or_default(); + if d.name(b"Subtype") != Some(b"Link") { + return true; + } + destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names)) + }) + .cloned() + .collect(); + if keep.len() != annots.len() { + links += annots.len() - keep.len(); + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(keep)))?; + } + } + // Bookmarks, depth-first. + let mut bookmarks = 0; + let first = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Outlines").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")))); + let mut stack: Vec = first.into_iter().collect(); + let mut seen = HashSet::new(); + while let Some(r) = stack.pop() { + if !seen.insert(r) || seen.len() > 100_000 { + continue; + } + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + if let Some(n) = d.reference(b"Next") { + stack.push(n); + } + if let Some(f) = d.reference(b"First") { + stack.push(f); + } + if let Some(dest) = destination(doc, &d) + && !valid(&dest, &set, pages.len(), &names) + { + doc.update_dict(r, |d| { + d.remove(b"Dest"); + d.remove(b"A"); + })?; + bookmarks += 1; + } + } + Ok((links, bookmarks)) +} + +/// Every named destination some object refers to (links, bookmarks, actions, open action). +fn referenced_names(doc: &Document) -> HashSet> { + let mut out = HashSet::new(); + let mut visit = |o: &Object| { + let mut stack = vec![o.clone()]; + while let Some(o) = stack.pop() { + match o { + Object::Dict(d) => { + if let Some(n) = d.get(b"Dest").and_then(dest_name) { + 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) + { + out.insert(n); + } + stack.extend(d.iter().map(|(_, v)| v.clone())); + } + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())), + _ => {} + } + } + }; + for num in doc.object_numbers() { + visit(&doc.get(ObjRef::new(num, doc.generation(num)))); + } + if let Some(r) = doc.root() { + visit(&doc.get(r)); + } + out +} + +/// Remove named destinations nothing refers to. Returns how many went. +pub(crate) fn remove_unreferenced_dests(doc: &mut Document) -> Result { + let Some(root) = doc.root() else { return Ok(0) }; + let cat = doc.get(root).as_dict().cloned().unwrap_or_default(); + let used = referenced_names(doc); + let mut removed = 0; + if let Some(tree) = names_tree(doc, &cat) { + let entries = tree_entries(doc, &tree); + let keep: Vec<(Vec, Object)> = entries.iter().filter(|(k, _)| used.contains(k)).cloned().collect(); + if keep.len() != entries.len() { + removed += entries.len() - keep.len(); + let mut keep = keep; + keep.sort_by(|a, b| a.0.cmp(&b.0)); + let mut arr = Vec::with_capacity(keep.len() * 2); + for (k, v) in keep { + arr.push(Object::String(PdfString::literal(k))); + arr.push(v); + } + let mut node = Dict::new(); + node.set(b"Names".to_vec(), Object::Array(arr)); + let node = doc.add(Object::Dict(node)); + let names_obj = cat.get(b"Names").cloned(); + match names_obj { + Some(Object::Ref(r)) => doc.update_dict(r, |n| n.set(b"Dests".to_vec(), Object::Ref(node)))?, + _ => doc.update_dict(root, |c| { + if let Some(Object::Dict(n)) = c.get_mut(b"Names") { + n.set(b"Dests".to_vec(), Object::Ref(node)); + } + })?, + } + } + } + if let Some(old) = cat.get(b"Dests") { + let d = doc.resolve(old).as_dict().cloned().unwrap_or_default(); + let mut keep = Dict::new(); + for (k, v) in d.iter() { + if used.contains(k) { + keep.set(k.clone(), v.clone()); + } else { + removed += 1; + } + } + if keep.len() != d.len() { + match old { + Object::Ref(r) => doc.set(*r, Object::Dict(keep)), + _ => doc.update_dict(root, |c| c.set(b"Dests".to_vec(), Object::Dict(keep)))?, + } + } + } + Ok(removed) +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index f6662820..f39004f0 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -192,3 +192,57 @@ fn space_audit_shares_the_file_out_by_kind() { assert_eq!(audit.iter().map(|u| u.bytes).sum::(), bytes.len() as u64); assert!((audit.iter().map(|u| u.percent).sum::() - 100.0).abs() < 1e-6); } + +#[test] +fn invalid_links_and_unreferenced_destinations_go() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let link = |doc: &mut Document, dest: Object| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + d.set(b"Dest".to_vec(), dest); + Object::Ref(doc.add(Object::Dict(d))) + }; + let s = |t: &str| Object::String(printcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let annots = vec![ + link(&mut doc, Object::Array(vec![Object::Ref(p), Object::name("Fit")])), + link(&mut doc, Object::Array(vec![Object::Ref(ObjRef::new(999, 0)), Object::name("Fit")])), + link(&mut doc, s("gone")), + link(&mut doc, s("kept")), + ]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + // Named destinations "kept" (used) and "unused"; a bookmark to a missing name. + 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("kept"), 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 outlines = doc.add(Object::Null); + let mut item = Dict::new(); + item.set(b"Title".to_vec(), s("Lost")); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"Dest".to_vec(), s("nowhere")); + let item = doc.add(Object::Dict(item)); + let mut o = Dict::new(); + o.set(b"First".to_vec(), Object::Ref(item)); + o.set(b"Last".to_vec(), Object::Ref(item)); + doc.set(outlines, Object::Dict(o)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Names".to_vec(), Object::Dict(names)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (2, 1, 1)); + let left = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len(); + assert_eq!(left, 2); + assert!(doc.get(item).as_dict().unwrap().get(b"Dest").is_none(), "the bookmark stays, without its broken destination"); + // Off: nothing changes. + let mut again = doc.clone(); + let off = Settings { remove_invalid_links: false, remove_unreferenced_dests: false, ..Settings::default() }; + assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0); +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 2d0f7bab..56204cdd 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -129,6 +129,8 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } OptimizeTab::CleanUp => { ui.checkbox(&mut s.flate_unencoded, "Use Flate to encode streams that are not encoded"); + ui.checkbox(&mut s.remove_invalid_links, "Remove invalid links and bookmarks"); + ui.checkbox(&mut s.remove_unreferenced_dests, "Remove unreferenced named destinations"); ui.add_enabled(false, egui::Checkbox::new(&mut true, "Compress document structure (object streams)")); ui.add_enabled(false, egui::Checkbox::new(&mut true, "Remove unused objects and merge identical ones")); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1730eb07..ee585d0d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7403,11 +7403,11 @@ title = "Remove invalid links, bookmarks and unreferenced destinations" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::invalid_links_and_unreferenced_destinations_go"] +notes = "Clean Up: links to missing pages or names are removed; bookmarks keep their place (and children) but lose a broken destination; named destinations nothing refers to (links, bookmarks, actions anywhere) are removed from the name tree and the old /Dests; counts reported" [[feature]] id = "optimize.optimize-content"