//! Discard User Data ▸ Discard external cross-references: links and bookmarks that open another //! document. //! //! An action opens another document when its `/S` is `GoToR` (another PDF file), `GoToE` (a //! document embedded in this one, or the one this is embedded in), `Launch` (a file or an //! application), `ImportData` (form data read from another file) or `Thread` with an `/F` (an //! article in another file). Such an action is taken out of the `/A` of a link annotation //! (`/Subtype /Link`) or a bookmark, and the actions chained after it through `/Next` take its //! place in order. Other annotations, such as form fields, keep their actions. A link left with no //! action and no `/Dest` is removed from its page; when the link is stored as its own object, that //! object keeps the link without its action for anything else that refers to it (the structure //! tree, for example). A bookmark stays, with its title and children. Links that jump within the //! document (`/Dest`, `GoTo`) and web links (`URI`) are kept. An `/A` that takes more than //! [`MAX_STEPS`] values to check, or whose actions open another document and take more than that //! to rewrite, stops the optimization with an error, and so does an outline of more than //! [`MAX_OUTLINE`] items or with items stored inside each other more than [`MAX_DEPTH`] levels //! deep. use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::OptimizeError; /// Action types that open or read another document (`Thread` does when it has an `/F`). const EXTERNAL: [&[u8]; 4] = [b"GoToR", b"GoToE", b"Launch", b"ImportData"]; /// How many levels (actions, `/Next` arrays and references) a chain is rewritten. Past it, the rest /// of the chain is kept when it opens no other document, and removed when it does. const MAX_DEPTH: usize = 512; /// How many values each pass over one `/A` looks at: the check for actions to other documents, /// then the rewrite with its check past [`MAX_DEPTH`]. A chain that needs more in either pass /// stops the optimization with [`OptimizeError::ExternalRefLimit`]. const MAX_STEPS: usize = 100_000; /// How many bookmarks the outline walk looks at before it stops the optimization with /// [`OptimizeError::ExternalRefLimit`]. const MAX_OUTLINE: usize = 100_000; /// What is left of an action or a list of actions. enum Kept { /// Nothing changed: keep the object as it is stored. Same, /// These actions, in order, replace it. Now(Vec), } #[derive(Default)] struct Walk { /// References on the current chain, to end a chain that comes back to itself. path: Vec, /// Indirect objects from which an action to another document can be reached. dirty: HashSet, /// Actions to another document taken out. removed: usize, /// A chain that opens another document past [`MAX_DEPTH`] was cut there. cut: bool, /// Values looked at so far, against [`MAX_STEPS`]. steps: usize, /// The walk ran out of steps; its result is not used. exhausted: bool, } fn is_external(doc: &Document, d: &Dict) -> bool { let Some(s) = d.get(b"S").map(|s| doc.resolve(s)) else { return false }; match s.as_name() { Some(b"Thread") => d.get(b"F").is_some_and(|f| !matches!(doc.resolve(f).as_ref(), Object::Null)), Some(n) => EXTERNAL.contains(&n), None => false, } } /// The actions an unchanged `/Next` value stands for, in order. fn listed(doc: &Document, next: &Object) -> Vec { match doc.resolve(next).as_ref() { Object::Array(a) => a.clone(), Object::Dict(_) => vec![next.clone()], _ => Vec::new(), } } impl Walk { /// Count one value; false once the walk has run out of steps. fn step(&mut self) -> bool { self.steps += 1; if self.steps > MAX_STEPS { self.exhausted = true; } !self.exhausted } /// Whether the actions stored directly in `o` include one to another document; the references /// they hold are added to `refs`. fn scan(&mut self, doc: &Document, o: &Object, refs: &mut Vec) -> bool { let mut stack = vec![o]; let mut external = false; while let Some(o) = stack.pop() { if !self.step() { return false; } match o { Object::Ref(r) => refs.push(*r), Object::Array(items) => stack.extend(items), Object::Dict(d) => { external |= is_external(doc, d); if let Some(n) = d.get(b"Next") { stack.push(n); } } _ => {} } } external } /// Mark the indirect objects reachable from `a` that lead to an action to another document. /// Each indirect object is looked at once. fn reach(&mut self, doc: &Document, a: &Object) { let mut todo = Vec::new(); self.scan(doc, a, &mut todo); let mut seen = HashSet::new(); let mut parents: HashMap> = HashMap::new(); let mut found = Vec::new(); while let Some(r) = todo.pop() { if self.exhausted { return; } if !seen.insert(r) { continue; } let target = doc.get(r); let mut refs = Vec::new(); if self.scan(doc, &target, &mut refs) { found.push(r); } for c in refs { parents.entry(c).or_default().push(r); todo.push(c); } } while let Some(r) = found.pop() { if self.dirty.insert(r) && let Some(p) = parents.get(&r) { found.extend(p); } } } /// Whether `o` or an action chained after it opens another document. Needs [`Walk::reach`] /// to have run on an `/A` that holds `o`. fn holds_external(&mut self, doc: &Document, o: &Object) -> bool { let mut refs = Vec::new(); let external = self.scan(doc, o, &mut refs); external || refs.iter().any(|r| self.dirty.contains(r)) } } impl Walk { /// Indirect actions that change are written as new indirect objects (the old ones stay for /// whatever else uses them), so a rewritten chain nests no deeper than the original. fn strip(&mut self, doc: &mut Document, o: &Object, depth: usize) -> Kept { if !self.step() { return Kept::Same; } if depth > MAX_DEPTH { if !self.holds_external(doc, o) { return Kept::Same; } self.cut = true; return Kept::Now(Vec::new()); } match o { Object::Ref(r) => { if !self.dirty.contains(r) { return Kept::Same; } if self.path.contains(r) { return Kept::Now(Vec::new()); } self.path.push(*r); let target = doc.get(*r); let kept = self.strip(doc, &target, depth + 1); self.path.pop(); match kept { Kept::Now(mut v) if v.len() == 1 && matches!(v.first(), Some(Object::Dict(_))) => { let d = v.remove(0); Kept::Now(vec![Object::Ref(doc.add(d))]) } other => other, } } Object::Array(items) => { let mut out = Vec::new(); let mut changed = false; for item in items { match self.strip(doc, item, depth + 1) { Kept::Same => out.push(item.clone()), Kept::Now(v) => { changed = true; out.extend(v); } } } if changed { Kept::Now(out) } else { Kept::Same } } Object::Dict(d) => { let next = d.get(b"Next"); let rest = next.map(|n| self.strip(doc, n, depth + 1)); if is_external(doc, d) { self.removed += 1; return Kept::Now(match (rest, next) { (Some(Kept::Now(v)), _) => v, (Some(Kept::Same), Some(n)) => listed(doc, n), _ => Vec::new(), }); } match rest { Some(Kept::Now(v)) => { let mut d = d.clone(); set_next(doc, &mut d, v); Kept::Now(vec![Object::Dict(d)]) } _ => Kept::Same, } } _ => Kept::Same, } } } fn set_next(doc: &mut Document, d: &mut Dict, mut v: Vec) { match v.len() { 0 => { d.remove(b"Next"); } 1 => d.set(b"Next".to_vec(), v.remove(0)), _ => { let next = v.into_iter().map(|a| if matches!(a, Object::Dict(_) | Object::Array(_)) { Object::Ref(doc.add(a)) } else { a }).collect(); d.set(b"Next".to_vec(), Object::Array(next)); } } } /// `/A` of `owner` with its actions to other documents taken out: `None` when nothing changed, /// `Some(None)` when no action is left, `Some(Some(action))` otherwise (one action, the rest after /// it). fn strip_action(doc: &mut Document, a: &Object, owner: ObjRef) -> Result>, OptimizeError> { let mut walk = Walk::default(); // A quick check first, which looks at each indirect object once: a chain with no action to // another document is left alone however its actions are shared, unless it holds more than // `MAX_STEPS` values. walk.reach(doc, a); let external = walk.holds_external(doc, a); if walk.exhausted { return Err(OptimizeError::ExternalRefLimit(owner)); } if !external { return Ok(None); } walk.steps = 0; let kept = walk.strip(doc, a, 0); if walk.exhausted { return Err(OptimizeError::ExternalRefLimit(owner)); } let Kept::Now(mut v) = kept else { return Ok(None) }; if walk.removed == 0 && !walk.cut { return Ok(None); } // A null or a reference to something other than an action cannot lead the chain. let head = v.iter().position(|o| doc.resolve(o).as_dict().is_some()).unwrap_or(v.len()); v.drain(..head); if v.is_empty() { return Ok(Some(None)); } let first = v.remove(0); if v.is_empty() { return Ok(Some(Some(first))); } let Some(mut d) = doc.resolve(&first).as_dict().cloned() else { return Ok(Some(None)) }; let mut next = d.get(b"Next").map(|n| listed(doc, n)).unwrap_or_default(); next.extend(v); set_next(doc, &mut d, next); Ok(Some(Some(Object::Ref(doc.add(Object::Dict(d)))))) } /// The outline walk: bookmarks stored as their own objects wait on `stack`; those stored inside /// another bookmark are edited where they are. #[derive(Default)] struct Outline { stack: Vec, items: usize, changed: usize, } impl Outline { /// Take actions to other documents out of the bookmark `d` (or, when `item` is false, the /// outline dictionary) and of the bookmarks stored inside it. `owner` is the object that holds /// `d`. Returns whether `d` changed. fn visit(&mut self, doc: &mut Document, d: &mut Dict, owner: ObjRef, depth: usize, item: bool) -> Result { let mut changed = false; if item { self.items += 1; if self.items > MAX_OUTLINE { return Err(OptimizeError::ExternalRefLimit(owner)); } let stripped = match d.get(b"A") { Some(a) => strip_action(doc, a, owner)?, None => None, }; if let Some(action) = stripped { match action { Some(a) => d.set(b"A".to_vec(), a), None => { d.remove(b"A"); } } self.changed += 1; changed = true; } } let keys: &[&[u8]] = if item { &[b"Next", b"First"] } else { &[b"First"] }; for k in keys { match d.get_mut(k) { Some(Object::Ref(r)) => self.stack.push(*r), Some(Object::Dict(c)) => { if depth >= MAX_DEPTH { return Err(OptimizeError::ExternalRefLimit(owner)); } changed |= self.visit(doc, c, owner, depth + 1, true)?; } _ => {} } } Ok(changed) } } /// Take actions to other documents out of bookmarks, depth-first. Returns the bookmarks changed. fn discard_bookmarks(doc: &mut Document) -> Result { let Some(root) = doc.root() else { return Ok(0) }; let catalog = doc.get(root); let mut walk = Outline::default(); let mut seen = HashSet::new(); match catalog.as_dict().and_then(|c| c.get(b"Outlines")) { Some(Object::Ref(o)) => { seen.insert(*o); if let Some(mut d) = doc.get(*o).as_dict().cloned() && walk.visit(doc, &mut d, *o, 0, false)? { doc.set(*o, Object::Dict(d)); } } Some(Object::Dict(o)) => { let mut d = o.clone(); if walk.visit(doc, &mut d, root, 0, false)? { doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Dict(d)))?; } } _ => {} } while let Some(r) = walk.stack.pop() { if !seen.insert(r) { continue; } let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; if walk.visit(doc, &mut d, r, 0, true)? { doc.set(r, Object::Dict(d)); } } Ok(walk.changed) } /// Take actions to other documents out of links and bookmarks. Returns (links, bookmarks) /// changed; a link counts whether it was removed or kept for its other actions. pub(crate) fn discard(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), OptimizeError> { let mut links = 0; let mut removed_links = HashSet::new(); 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 mut keep = Vec::with_capacity(annots.len()); let mut changed = false; for x in &annots { let Some(mut d) = doc.resolve(x).as_dict().cloned() else { keep.push(x.clone()); continue; }; let owner = match x { Object::Ref(r) => *r, _ => *p, }; let link = d.get(b"Subtype").map(|s| doc.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|n| n == b"Link")); let stripped = match d.get(b"A") { Some(a) if link => strip_action(doc, a, owner)?, _ => None, }; let Some(action) = stripped else { if !matches!(x, Object::Ref(r) if removed_links.contains(r)) { keep.push(x.clone()); } else { changed = true; } continue; }; links += 1; match action { Some(a) => d.set(b"A".to_vec(), a), None => { d.remove(b"A"); } } let removed = d.get(b"A").is_none() && d.get(b"Dest").is_none(); match x { Object::Ref(r) => { doc.set(*r, Object::Dict(d)); if removed { removed_links.insert(*r); changed = true; } else { keep.push(x.clone()); } } _ => { changed = true; if !removed { keep.push(Object::Dict(d)); } } } } if changed { doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(keep)))?; } } let bookmarks = discard_bookmarks(doc)?; Ok((links, bookmarks)) }