mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
440 lines
16 KiB
Rust
440 lines
16 KiB
Rust
//! 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<Object>),
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Walk {
|
|
/// References on the current chain, to end a chain that comes back to itself.
|
|
path: Vec<ObjRef>,
|
|
/// Indirect objects from which an action to another document can be reached.
|
|
dirty: HashSet<ObjRef>,
|
|
/// 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<Object> {
|
|
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<ObjRef>) -> 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<ObjRef, Vec<ObjRef>> = 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<Object>) {
|
|
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<Option<Option<Object>>, 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<ObjRef>,
|
|
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<bool, OptimizeError> {
|
|
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<usize, OptimizeError> {
|
|
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))
|
|
}
|