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:
parent
763cd70ece
commit
ba4a7a6d42
|
|
@ -545,6 +545,8 @@ impl Automation {
|
||||||
("discard_tags", &mut settings.discard_tags),
|
("discard_tags", &mut settings.discard_tags),
|
||||||
("discard_print_settings", &mut settings.discard_print_settings),
|
("discard_print_settings", &mut settings.discard_print_settings),
|
||||||
("flate_unencoded", &mut settings.flate_unencoded),
|
("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)? {
|
if let Some(b) = a.opt_bool(key)? {
|
||||||
*flag = b;
|
*flag = b;
|
||||||
|
|
@ -579,6 +581,9 @@ impl Automation {
|
||||||
"alternate_images": o.alternate_images,
|
"alternate_images": o.alternate_images,
|
||||||
"tags_removed": o.tags_removed,
|
"tags_removed": o.tags_removed,
|
||||||
"streams_compressed": o.streams_compressed,
|
"streams_compressed": o.streams_compressed,
|
||||||
|
"invalid_links": o.invalid_links,
|
||||||
|
"invalid_bookmarks": o.invalid_bookmarks,
|
||||||
|
"unreferenced_dests": o.unreferenced_dests,
|
||||||
"merged_objects": r.merged,
|
"merged_objects": r.merged,
|
||||||
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
|
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -812,7 +812,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
.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.")
|
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"])),
|
.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")
|
.cmd("optimize.advanced")
|
||||||
.with(schema(
|
.with(schema(
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -825,6 +825,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||||
"discard_tags": { "type": "boolean" },
|
"discard_tags": { "type": "boolean" },
|
||||||
"discard_print_settings": { "type": "boolean" },
|
"discard_print_settings": { "type": "boolean" },
|
||||||
"flate_unencoded": { "type": "boolean" },
|
"flate_unencoded": { "type": "boolean" },
|
||||||
|
"remove_invalid_links": { "type": "boolean" },
|
||||||
|
"remove_unreferenced_dests": { "type": "boolean" },
|
||||||
"discard": { "type": "array", "items": { "type": "string" } },
|
"discard": { "type": "array", "items": { "type": "string" } },
|
||||||
}),
|
}),
|
||||||
&["doc", "path"],
|
&["doc", "path"],
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
mod audit;
|
mod audit;
|
||||||
mod images;
|
mod images;
|
||||||
|
mod links;
|
||||||
|
|
||||||
pub use audit::{SpaceCategory, SpaceUse, audit_space};
|
pub use audit::{SpaceCategory, SpaceUse, audit_space};
|
||||||
|
|
||||||
|
|
@ -70,6 +71,10 @@ pub struct Settings {
|
||||||
pub discard_print_settings: bool,
|
pub discard_print_settings: bool,
|
||||||
/// Flate-compress streams stored without a filter.
|
/// Flate-compress streams stored without a filter.
|
||||||
pub flate_unencoded: bool,
|
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 {
|
impl Default for Settings {
|
||||||
|
|
@ -83,6 +88,8 @@ impl Default for Settings {
|
||||||
discard_tags: false,
|
discard_tags: false,
|
||||||
discard_print_settings: false,
|
discard_print_settings: false,
|
||||||
flate_unencoded: true,
|
flate_unencoded: true,
|
||||||
|
remove_invalid_links: true,
|
||||||
|
remove_unreferenced_dests: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +108,10 @@ pub struct Report {
|
||||||
pub tags_removed: bool,
|
pub tags_removed: bool,
|
||||||
pub print_settings: usize,
|
pub print_settings: usize,
|
||||||
pub streams_compressed: 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.
|
/// 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 {
|
if settings.flate_unencoded {
|
||||||
for num in doc.object_numbers() {
|
for num in doc.object_numbers() {
|
||||||
let r = ObjRef { num, generation: doc.generation(num) };
|
let r = ObjRef { num, generation: doc.generation(num) };
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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_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);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,8 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
|
||||||
}
|
}
|
||||||
OptimizeTab::CleanUp => {
|
OptimizeTab::CleanUp => {
|
||||||
ui.checkbox(&mut s.flate_unencoded, "Use Flate to encode streams that are not encoded");
|
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, "Compress document structure (object streams)"));
|
||||||
ui.add_enabled(false, egui::Checkbox::new(&mut true, "Remove unused objects and merge identical ones"));
|
ui.add_enabled(false, egui::Checkbox::new(&mut true, "Remove unused objects and merge identical ones"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7403,11 +7403,11 @@ title = "Remove invalid links, bookmarks and unreferenced destinations"
|
||||||
area = "K"
|
area = "K"
|
||||||
tier = "P1"
|
tier = "P1"
|
||||||
milestone = "M11"
|
milestone = "M11"
|
||||||
status = "planned"
|
status = "shipped"
|
||||||
commands = []
|
commands = ["optimize.advanced"]
|
||||||
tools = []
|
tools = ["doc_optimize"]
|
||||||
evidence = []
|
evidence = ["crates/optimize/tests/optimize.rs::invalid_links_and_unreferenced_destinations_go"]
|
||||||
notes = ""
|
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]]
|
[[feature]]
|
||||||
id = "optimize.optimize-content"
|
id = "optimize.optimize-content"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue