M11: Optimizer Clean Up removes invalid links and bookmark destinations and unreferenced named destinations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 07:40:51 -04:00
parent 763cd70ece
commit ba4a7a6d42
7 changed files with 316 additions and 6 deletions

View File

@ -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::<Vec<_>>(),
}))

View File

@ -812,7 +812,7 @@ pub fn tools() -> Vec<ToolDef> {
.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<ToolDef> {
"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"],

View File

@ -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<Report, Optim
_ => {}
}
}
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) };

View File

@ -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<Vec<u8>> {
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<Object> {
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<u8>, 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<Object> {
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<Vec<u8>> {
match o {
Object::String(s) => Some(s.bytes.clone()),
Object::Name(n) => Some(n.to_vec()),
_ => None,
}
}
fn valid(o: &Object, pages: &HashSet<ObjRef>, count: usize, names: &HashSet<Vec<u8>>) -> 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<ObjRef> = 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<Object> = 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<ObjRef> = 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<Vec<u8>> {
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<usize, printcraft_cos::CosError> {
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<u8>, 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)
}

View File

@ -192,3 +192,57 @@ fn space_audit_shares_the_file_out_by_kind() {
assert_eq!(audit.iter().map(|u| u.bytes).sum::<u64>(), bytes.len() as u64);
assert!((audit.iter().map(|u| u.percent).sum::<f64>() - 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);
}

View File

@ -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"));
}

View File

@ -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"