mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M11: PDF Optimizer can discard form submission, import and reset actions (optimize.discard-form-actions) (#4416)
This commit is contained in:
1 parent
ba2846cba8
commit
e78102c907
9 files changed
+862
-8
No files matched your search
@@ -361,6 +361,7 @@ impl Automation {
|
||||
("discard_alternate_images", &mut settings.discard_alternate_images),
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
("discard_print_settings", &mut settings.discard_print_settings),
|
||||
("discard_form_actions", &mut settings.discard_form_actions),
|
||||
("flate_unencoded", &mut settings.flate_unencoded),
|
||||
("object_streams", &mut settings.object_streams),
|
||||
("lzw_to_flate", &mut settings.lzw_to_flate),
|
||||
@@ -471,6 +472,8 @@ impl Automation {
|
||||
"thumbnails": o.thumbnails,
|
||||
"alternate_images": o.alternate_images,
|
||||
"tags_removed": o.tags_removed,
|
||||
"print_settings": o.print_settings,
|
||||
"form_actions": o.form_actions,
|
||||
"streams_compressed": o.streams_compressed,
|
||||
"lzw_streams": o.lzw_streams,
|
||||
"invalid_links": o.invalid_links,
|
||||
|
||||
@@ -1678,7 +1678,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). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. 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); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1695,6 +1695,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"discard_alternate_images": { "type": "boolean" },
|
||||
"discard_tags": { "type": "boolean" },
|
||||
"discard_print_settings": { "type": "boolean" },
|
||||
"discard_form_actions": { "type": "boolean" },
|
||||
"flate_unencoded": { "type": "boolean" },
|
||||
"object_streams": { "type": "boolean" },
|
||||
"lzw_to_flate": { "type": "boolean" },
|
||||
|
||||
@@ -7710,6 +7710,32 @@ fn optimizing_through_tools() {
|
||||
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimizing_discards_form_actions_through_tools() {
|
||||
let dir = workdir("optimize-form-actions");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Send" }));
|
||||
ok(
|
||||
&mut a,
|
||||
"form_set_actions",
|
||||
json!({ "doc": doc, "field": "Send", "actions": [
|
||||
{ "trigger": "mouse_up", "submit": "https://example.com/submit" },
|
||||
{ "trigger": "mouse_down", "reset": [] },
|
||||
{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }
|
||||
] }),
|
||||
);
|
||||
let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" }));
|
||||
assert_eq!(kept["form_actions"], 0, "{kept}");
|
||||
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_form_actions": true }));
|
||||
assert_eq!(r["form_actions"], 2, "{r}");
|
||||
let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": small }));
|
||||
assert!(fields.to_string().contains("Send"), "the button stays: {fields}");
|
||||
let actions = ok(&mut a, "form_actions", json!({ "doc": small, "field": "Send" }));
|
||||
assert_eq!(actions["actions"], json!([{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }]), "{actions}");
|
||||
}
|
||||
|
||||
/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream
|
||||
/// needs is not what viewers read: the header is raised, and the later of the two is reported.
|
||||
#[test]
|
||||
|
||||
@@ -127,7 +127,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
its priority. The document only changes in order on one thread, and what a picture costs is paid
|
||||
in that order, so the file written, and the pictures the run's decoding allowance refuses, are
|
||||
the same with any number.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings, and form
|
||||
submission, import and reset actions (`/SubmitForm`, `/ImportData`, `/ResetForm`, off by
|
||||
default). The fields, links, bookmarks and pages that ran a removed action keep their other
|
||||
actions, and the actions chained after it through `/Next` still run in order.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Discard Objects: form submission, import and reset actions (`/SubmitForm`, `/ImportData`,
|
||||
//! `/ResetForm`). The fields, buttons, links, bookmarks and pages that ran them stay; a removed
|
||||
//! action's `/Next` actions take its place, so the rest of a chain still runs in order.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::OptimizeError;
|
||||
|
||||
const FORM_ACTIONS: [&[u8]; 3] = [b"SubmitForm", b"ImportData", b"ResetForm"];
|
||||
|
||||
/// Keys whose value is one action (or, for `/OpenAction`, an action or a destination).
|
||||
const ACTION_KEYS: [&[u8]; 2] = [b"A", b"OpenAction"];
|
||||
|
||||
/// How deep direct objects and action chains are followed.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// How many action values one object may make the walk look at.
|
||||
const MAX_STEPS: usize = 100_000;
|
||||
|
||||
/// How many action values the walk may look at in the whole document.
|
||||
const MAX_TOTAL_STEPS: usize = 1_000_000;
|
||||
|
||||
/// Remove every form submission, import and reset action. Returns how many the document held in
|
||||
/// action entries, each counted once: an indirect action however many entries refer to it, and a
|
||||
/// direct one in the object that holds it. Fails with [`OptimizeError::ActionLimit`] when an
|
||||
/// object holds a form action more than [`MAX_DEPTH`] direct objects deep, chains form actions
|
||||
/// more than [`MAX_DEPTH`] deep, or makes the walk look at more than [`MAX_STEPS`] action values,
|
||||
/// or when the walk has looked at more than [`MAX_TOTAL_STEPS`] action values in the document.
|
||||
pub(crate) fn discard(doc: &mut Document) -> Result<usize, OptimizeError> {
|
||||
let numbers = doc.object_numbers();
|
||||
let mut count = Count::default();
|
||||
for &num in &numbers {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
if let o @ (Object::Dict(_) | Object::Array(_)) = &*doc.get(r) {
|
||||
count.object(doc, o, 0);
|
||||
}
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for num in numbers {
|
||||
let r = ObjRef { num, generation: doc.generation(num) };
|
||||
let mut obj = match &*doc.get(r) {
|
||||
o @ (Object::Dict(_) | Object::Array(_)) => o.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
let limit = MAX_STEPS.min(MAX_TOTAL_STEPS - total);
|
||||
let mut walk =
|
||||
Walk { this: r, changed: false, steps: 0, limit, exhausted: false, chain: HashSet::new(), looped: false, writes: HashMap::new() };
|
||||
walk.clean(doc, &mut obj, 0);
|
||||
total += walk.steps.min(limit);
|
||||
if walk.exhausted {
|
||||
return Err(OptimizeError::ActionLimit(r));
|
||||
}
|
||||
if walk.changed {
|
||||
doc.set(r, obj);
|
||||
}
|
||||
for (shared, cleaned) in walk.writes {
|
||||
doc.set(shared, cleaned);
|
||||
}
|
||||
}
|
||||
Ok(count.direct + count.indirect.len())
|
||||
}
|
||||
|
||||
/// Whether `d` is a form action; its `/S` may be stored as an indirect name.
|
||||
fn is_form_action(doc: &Document, d: &Dict) -> bool {
|
||||
d.get(b"S").is_some_and(|s| doc.resolve(s).as_name().is_some_and(|s| FORM_ACTIONS.contains(&s)))
|
||||
}
|
||||
|
||||
/// The form actions in action entries, counted before they are removed. Each object's pass looks
|
||||
/// only at its own direct objects, so a direct action is counted once, in the object that holds
|
||||
/// it; references are counted by identity.
|
||||
#[derive(Default)]
|
||||
struct Count {
|
||||
direct: usize,
|
||||
indirect: HashSet<ObjRef>,
|
||||
/// Indirect `/AA` dictionaries and `/Next` arrays already counted.
|
||||
shared: HashSet<ObjRef>,
|
||||
}
|
||||
|
||||
impl Count {
|
||||
fn object(&mut self, doc: &Document, o: &Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
for key in [b"A".as_slice(), b"OpenAction", b"Next"] {
|
||||
if let Some(v) = d.get(key) {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
match d.get(b"AA") {
|
||||
Some(Object::Ref(r)) if self.shared.insert(*r) => {
|
||||
if let Object::Dict(t) = &*doc.get(*r) {
|
||||
for (_, v) in t.iter() {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Object::Dict(t)) => {
|
||||
for (_, v) in t.iter() {
|
||||
self.entry(doc, v, depth);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (_, v) in d.iter() {
|
||||
self.object(doc, v, depth + 1);
|
||||
}
|
||||
}
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.object(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One action entry's value: an action, or (in `/Next`) a list of them.
|
||||
fn entry(&mut self, doc: &Document, v: &Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Object::Dict(a) if is_form_action(doc, a) => self.direct += 1,
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.entry(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
Object::Ref(r) => match &*doc.get(*r) {
|
||||
Object::Dict(a) if is_form_action(doc, a) => {
|
||||
self.indirect.insert(*r);
|
||||
}
|
||||
// An indirect `/Next` array: its direct actions are counted from the first entry
|
||||
// that refers to it, and their own entries by the array's own pass.
|
||||
Object::Array(items) if self.shared.insert(*r) => {
|
||||
for item in items {
|
||||
self.entry(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
/// The object being walked.
|
||||
this: ObjRef,
|
||||
/// Whether the object changed.
|
||||
changed: bool,
|
||||
steps: usize,
|
||||
/// How many action values this object's walk may look at: [`MAX_STEPS`], or what is left of
|
||||
/// [`MAX_TOTAL_STEPS`] when that is less.
|
||||
limit: usize,
|
||||
/// Whether a limit stopped the walk before it looked at everything.
|
||||
exhausted: bool,
|
||||
/// Indirect form actions and indirect arrays on the path of the chain being followed.
|
||||
chain: HashSet<ObjRef>,
|
||||
/// Whether the entry being cleaned holds an indirect array that lists itself.
|
||||
looped: bool,
|
||||
/// Cleaned replacements for indirect actions, `/Next` arrays and `/AA` dictionaries that
|
||||
/// action entries of this object refer to, written over them after the walk.
|
||||
writes: HashMap<ObjRef, Object>,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
/// Take form actions out of the action entries of `o` and of the direct objects inside it.
|
||||
fn clean(&mut self, doc: &Document, o: &mut Object, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
if holds_form_action(doc, o) {
|
||||
self.exhausted = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match o {
|
||||
Object::Dict(d) => self.clean_dict(doc, d, depth),
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
self.clean(doc, item, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_dict(&mut self, doc: &Document, d: &mut Dict, depth: usize) {
|
||||
for key in ACTION_KEYS {
|
||||
if let Some(v) = d.get(key)
|
||||
&& let shared = v.as_ref()
|
||||
&& let Some(rest) = self.take(doc, v)
|
||||
{
|
||||
let a = chain(doc, rest);
|
||||
self.put(doc, d, key, shared, a, depth);
|
||||
}
|
||||
}
|
||||
if let Some(v) = d.get(b"Next")
|
||||
&& let shared = v.as_ref()
|
||||
&& let Some(mut rest) = self.take(doc, v)
|
||||
{
|
||||
let next = match rest.len() {
|
||||
0 => None,
|
||||
1 => Some(rest.remove(0)),
|
||||
// An action that other entries may run as their `/A` stays a dictionary.
|
||||
_ if shared.is_some_and(|r| doc.get(r).as_dict().is_some()) => chain(doc, rest),
|
||||
_ => Some(Object::Array(rest)),
|
||||
};
|
||||
self.put(doc, d, b"Next", shared, next, depth);
|
||||
}
|
||||
// Additional actions: each trigger holds one action.
|
||||
if let Some(aa) = d.get(b"AA")
|
||||
&& let shared = aa.as_ref()
|
||||
&& let Object::Dict(t) = &*doc.resolve(aa)
|
||||
{
|
||||
let mut triggers = t.clone();
|
||||
let mut changed = false;
|
||||
let keys: Vec<Vec<u8>> = triggers.iter().map(|(k, _)| k.to_vec()).collect();
|
||||
for key in keys {
|
||||
if let Some(v) = triggers.get(&key)
|
||||
&& let Some(rest) = self.take(doc, v)
|
||||
{
|
||||
changed = true;
|
||||
match chain(doc, rest) {
|
||||
Some(a) => triggers.set(key, a),
|
||||
None => {
|
||||
triggers.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let aa = (!triggers.is_empty()).then_some(Object::Dict(triggers));
|
||||
self.put(doc, d, b"AA", shared, aa, depth);
|
||||
}
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = d.iter().map(|(k, _)| k.to_vec()).collect();
|
||||
for key in keys {
|
||||
if let Some(v) = d.get_mut(&key) {
|
||||
self.clean(doc, v, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set entry `key` of `d` to its cleaned value, or remove it when nothing is left. When the
|
||||
/// entry referred to indirect object `shared` and the cleaned value is a direct object, the
|
||||
/// value (with the form actions inside it taken out too) replaces `shared` and the entry keeps
|
||||
/// its reference, so the objects that share it share one cleaned copy. An entry that refers to
|
||||
/// an object already replaced, or being cleaned, keeps its reference.
|
||||
fn put(&mut self, doc: &Document, d: &mut Dict, key: &[u8], shared: Option<ObjRef>, value: Option<Object>, depth: usize) {
|
||||
match (shared, value) {
|
||||
(_, None) => {
|
||||
d.remove(key);
|
||||
}
|
||||
(Some(r), Some(_)) if self.writes.contains_key(&r) => {}
|
||||
(Some(r), Some(mut v)) if r != self.this && !matches!(v, Object::Ref(_)) => {
|
||||
self.writes.insert(r, Object::Null);
|
||||
self.clean(doc, &mut v, depth + 1);
|
||||
self.writes.insert(r, v);
|
||||
}
|
||||
(_, Some(v)) => d.set(key.to_vec(), v),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Walk::without`] for one action entry; marks the object changed when it changes. An entry
|
||||
/// that changes while it holds an array that lists itself stops the walk when what is left
|
||||
/// still reaches a form action through that array.
|
||||
fn take(&mut self, doc: &Document, v: &Object) -> Option<Vec<Object>> {
|
||||
self.chain.clear();
|
||||
self.looped = false;
|
||||
let rest = self.without(doc, v, 0)?;
|
||||
if self.looped && self.reaches_form_action(doc, &rest) {
|
||||
self.exhausted = true;
|
||||
return None;
|
||||
}
|
||||
self.changed = true;
|
||||
Some(rest)
|
||||
}
|
||||
|
||||
/// The actions an action value stands for once the form actions at the head of its chain are
|
||||
/// taken out: a form action gives way to the actions of its `/Next`. `None` when nothing
|
||||
/// changes (the value is kept as it is, references included).
|
||||
fn without(&mut self, doc: &Document, v: &Object, depth: usize) -> Option<Vec<Object>> {
|
||||
self.steps += 1;
|
||||
if depth > MAX_DEPTH || self.steps > self.limit {
|
||||
self.exhausted = true;
|
||||
return None;
|
||||
}
|
||||
let resolved = doc.resolve(v);
|
||||
match &*resolved {
|
||||
Object::Dict(a) if is_form_action(doc, a) => {
|
||||
let r = v.as_ref();
|
||||
// A chain that comes back to an action it is still inside ends there.
|
||||
if let Some(r) = r
|
||||
&& !self.chain.insert(r)
|
||||
{
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let rest = match a.get(b"Next") {
|
||||
None => Vec::new(),
|
||||
Some(next) => match self.without(doc, next, depth + 1) {
|
||||
Some(rest) => rest,
|
||||
None => match &*doc.resolve(next) {
|
||||
Object::Array(items) => items.clone(),
|
||||
_ => vec![next.clone()],
|
||||
},
|
||||
},
|
||||
};
|
||||
if let Some(r) = r {
|
||||
self.chain.remove(&r);
|
||||
}
|
||||
Some(rest)
|
||||
}
|
||||
Object::Array(items) => {
|
||||
let r = v.as_ref();
|
||||
// An indirect array that lists itself, directly or through other arrays, is left
|
||||
// as it is where it comes back.
|
||||
if let Some(r) = r
|
||||
&& !self.chain.insert(r)
|
||||
{
|
||||
self.looped = true;
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut changed = false;
|
||||
for item in items {
|
||||
match self.without(doc, item, depth + 1) {
|
||||
Some(rest) => {
|
||||
changed = true;
|
||||
out.extend(rest);
|
||||
}
|
||||
None => out.push(item.clone()),
|
||||
}
|
||||
}
|
||||
if let Some(r) = r {
|
||||
self.chain.remove(&r);
|
||||
}
|
||||
changed.then_some(out)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `items`, or an indirect array they list, lists a form action. Each value looked at
|
||||
/// counts as a step.
|
||||
fn reaches_form_action(&mut self, doc: &Document, items: &[Object]) -> bool {
|
||||
let mut seen = HashSet::new();
|
||||
let mut pending = Vec::new();
|
||||
if self.lists_form_action(doc, items, &mut seen, &mut pending) {
|
||||
return true;
|
||||
}
|
||||
while let Some(r) = pending.pop() {
|
||||
if let Object::Array(items) = &*doc.get(r)
|
||||
&& self.lists_form_action(doc, items, &mut seen, &mut pending)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `items`, or a direct array inside them, lists a form action; indirect arrays not
|
||||
/// yet seen go to `pending`.
|
||||
fn lists_form_action(&mut self, doc: &Document, items: &[Object], seen: &mut HashSet<ObjRef>, pending: &mut Vec<ObjRef>) -> bool {
|
||||
let mut stack: Vec<&Object> = items.iter().collect();
|
||||
while let Some(item) = stack.pop() {
|
||||
self.steps += 1;
|
||||
if self.steps > self.limit {
|
||||
self.exhausted = true;
|
||||
return false;
|
||||
}
|
||||
match item {
|
||||
Object::Dict(a) if is_form_action(doc, a) => return true,
|
||||
Object::Array(inner) => stack.extend(inner.iter()),
|
||||
Object::Ref(r) => match &*doc.get(*r) {
|
||||
Object::Dict(a) if is_form_action(doc, a) => return true,
|
||||
Object::Array(_) if seen.insert(*r) => pending.push(*r),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the walk would take a form action out of `o` or the direct objects inside it: a form
|
||||
/// action, or an action entry (or a `/Next` array or `/AA` dictionary, direct or indirect) that
|
||||
/// refers to one. Looks through `o` without recursion.
|
||||
fn holds_form_action(doc: &Document, o: &Object) -> bool {
|
||||
let mut stack = vec![o];
|
||||
while let Some(o) = stack.pop() {
|
||||
match o {
|
||||
Object::Dict(d) => {
|
||||
if is_form_action(doc, d) {
|
||||
return true;
|
||||
}
|
||||
for key in [b"A".as_slice(), b"OpenAction", b"Next"] {
|
||||
let refers = match d.get(key) {
|
||||
Some(Object::Array(items)) => items.iter().any(|item| refers_to_form_action(doc, item)),
|
||||
Some(v) => refers_to_form_action(doc, v),
|
||||
None => false,
|
||||
};
|
||||
if refers {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(aa) = d.get(b"AA")
|
||||
&& let Object::Dict(t) = &*doc.resolve(aa)
|
||||
&& t.iter().any(|(_, v)| is_or_refers_to_form_action(doc, v))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
stack.extend(d.iter().map(|(_, v)| v));
|
||||
}
|
||||
Object::Array(items) => stack.extend(items.iter()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `v` is a direct form action or a reference to one.
|
||||
fn is_or_refers_to_form_action(doc: &Document, v: &Object) -> bool {
|
||||
match v {
|
||||
Object::Dict(a) => is_form_action(doc, a),
|
||||
Object::Ref(r) => matches!(&*doc.get(*r), Object::Dict(a) if is_form_action(doc, a)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` refers to a form action, or to an indirect array that lists one.
|
||||
fn refers_to_form_action(doc: &Document, v: &Object) -> bool {
|
||||
let Object::Ref(r) = v else { return false };
|
||||
match &*doc.get(*r) {
|
||||
Object::Dict(a) => is_form_action(doc, a),
|
||||
Object::Array(items) => items.iter().any(|item| is_or_refers_to_form_action(doc, item)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One action value for a list of actions run in order: the first action, with the others
|
||||
/// after its own `/Next` actions. `None` when no action remains.
|
||||
fn chain(doc: &Document, actions: Vec<Object>) -> Option<Object> {
|
||||
let mut actions = actions.into_iter();
|
||||
loop {
|
||||
let first = actions.next()?;
|
||||
let rest: Vec<Object> = actions.as_slice().to_vec();
|
||||
if rest.is_empty() {
|
||||
return Some(first);
|
||||
}
|
||||
// A list needs a dictionary at its head to carry the rest; anything else is dropped.
|
||||
let Some(mut head) = doc.resolve(&first).as_dict().cloned() else { continue };
|
||||
let mut next = match head.get(b"Next") {
|
||||
None => Vec::new(),
|
||||
Some(n) => match &*doc.resolve(n) {
|
||||
Object::Array(items) => items.clone(),
|
||||
_ => vec![n.clone()],
|
||||
},
|
||||
};
|
||||
next.extend(rest);
|
||||
head.set(b"Next".to_vec(), Object::Array(next));
|
||||
return Some(Object::Dict(head));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
|
||||
//! Flate once resampled or packed, when that is smaller.
|
||||
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
|
||||
//! print settings.
|
||||
//! print settings, form submission, import and reset actions.
|
||||
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
|
||||
//! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or
|
||||
//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead.
|
||||
@@ -29,6 +29,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod audit;
|
||||
mod form_actions;
|
||||
pub mod fragments;
|
||||
mod images;
|
||||
mod links;
|
||||
@@ -49,6 +50,8 @@ pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
|
||||
pub enum OptimizeError {
|
||||
#[error("the document has no page tree")]
|
||||
NoPages,
|
||||
#[error("object {0} nests or chains its form actions too deeply, or the document holds too many actions, to discard them")]
|
||||
ActionLimit(ObjRef),
|
||||
#[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")]
|
||||
IncompatibleEncryption { required: &'static str, chosen: &'static str },
|
||||
#[error(transparent)]
|
||||
@@ -197,6 +200,8 @@ pub struct Settings {
|
||||
pub discard_alternate_images: bool,
|
||||
pub discard_tags: bool,
|
||||
pub discard_print_settings: bool,
|
||||
/// Remove form submission, import and reset actions; the fields and links that ran them stay.
|
||||
pub discard_form_actions: bool,
|
||||
/// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or
|
||||
/// ASCIIHex layer from streams that have one.
|
||||
pub flate_unencoded: bool,
|
||||
@@ -233,6 +238,7 @@ impl Default for Settings {
|
||||
discard_alternate_images: true,
|
||||
discard_tags: false,
|
||||
discard_print_settings: false,
|
||||
discard_form_actions: false,
|
||||
flate_unencoded: true,
|
||||
object_streams: true,
|
||||
lzw_to_flate: true,
|
||||
@@ -297,6 +303,8 @@ pub struct Report {
|
||||
pub alternate_images: usize,
|
||||
pub tags_removed: bool,
|
||||
pub print_settings: usize,
|
||||
/// Form submission, import and reset actions removed.
|
||||
pub form_actions: usize,
|
||||
pub streams_compressed: usize,
|
||||
/// Streams whose LZW compression was replaced with Flate (Clean Up).
|
||||
pub lzw_streams: usize,
|
||||
@@ -426,6 +434,9 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if settings.discard_form_actions {
|
||||
report.form_actions = form_actions::discard(doc)?;
|
||||
}
|
||||
if settings.remove_invalid_links {
|
||||
let (l, b) = links::remove_invalid(doc, &pages)?;
|
||||
report.invalid_links = l;
|
||||
|
||||
@@ -2450,6 +2450,347 @@ fn discards_and_clean_up() {
|
||||
assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay");
|
||||
}
|
||||
|
||||
fn action(kind: &str) -> Dict {
|
||||
let mut a = Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name(kind));
|
||||
a
|
||||
}
|
||||
|
||||
fn with(mut d: Dict, key: &str, value: impl Into<Object>) -> Dict {
|
||||
d.set(key.as_bytes().to_vec(), value);
|
||||
d
|
||||
}
|
||||
|
||||
/// The kinds of the actions `o` runs, in order: each action, then its `/Next` actions.
|
||||
fn kinds(doc: &Document, o: Option<&Object>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Some(o) = o else { return out };
|
||||
match &*doc.resolve(o) {
|
||||
Object::Array(items) => {
|
||||
for item in items {
|
||||
out.extend(kinds(doc, Some(item)));
|
||||
}
|
||||
}
|
||||
Object::Dict(a) => {
|
||||
out.push(String::from_utf8_lossy(a.name(b"S").unwrap_or_default()).into_owned());
|
||||
out.extend(kinds(doc, a.get(b"Next")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dict(doc: &Document, r: ObjRef) -> Dict {
|
||||
doc.get(r).as_dict().cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
let triggers = with(Dict::new(), "U", action("ResetForm"));
|
||||
let triggers = with(triggers, "E", with(action("JavaScript"), "JS", Object::String(pdfcraft_cos::PdfString::literal("app.alert(1)"))));
|
||||
let button = with(with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Btn")), "A", action("SubmitForm"));
|
||||
let button = doc.add(Object::Dict(with(button, "AA", triggers)));
|
||||
let import = doc.add(Object::Dict(action("ImportData")));
|
||||
let field = with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Tx"));
|
||||
let field = doc.add(Object::Dict(with(field, "AA", with(Dict::new(), "K", Object::Ref(import)))));
|
||||
let link = with(with(Dict::new(), "Subtype", Object::name("Link")), "A", with(action("GoTo"), "Next", action("SubmitForm")));
|
||||
let link = doc.add(Object::Dict(with(link, "D", Object::Array(vec![Object::Ref(p), Object::name("Fit")]))));
|
||||
let reset = doc.add(Object::Dict(action("ResetForm")));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(button), Object::Ref(field), Object::Ref(link)]))).unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(reset))).unwrap();
|
||||
|
||||
let mut kept = doc.clone();
|
||||
assert_eq!(optimize(&mut kept, &Settings::default()).unwrap().form_actions, 0, "the option is off by default");
|
||||
assert_eq!(kinds(&kept, dict(&kept, button).get(b"A")), ["SubmitForm"]);
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 5, "{r:?}");
|
||||
let annots = dict(&doc, p).get(b"Annots").and_then(|a| a.as_array().cloned()).unwrap();
|
||||
assert_eq!(annots.len(), 3, "the fields and the link stay");
|
||||
let b = dict(&doc, button);
|
||||
assert_eq!(b.name(b"FT"), Some(&b"Btn"[..]));
|
||||
assert!(!b.contains(b"A"), "{b:?}");
|
||||
let aa = b.get(b"AA").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]), "other triggers stay");
|
||||
assert!(!dict(&doc, field).contains(b"AA"), "an emptied /AA goes");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discarded_form_action_hands_its_place_to_the_actions_after_it() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
// A reset whose /S is an indirect name.
|
||||
let name = doc.add(Object::name("ResetForm"));
|
||||
let reset = doc.add(Object::Dict(with(with(Dict::new(), "S", Object::Ref(name)), "Next", action("URI"))));
|
||||
let submit = with(
|
||||
action("SubmitForm"),
|
||||
"Next",
|
||||
Object::Array(vec![Object::Dict(action("JavaScript")), Object::Ref(reset), Object::Dict(action("Named"))]),
|
||||
);
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit)));
|
||||
// A reset whose chain comes back to itself.
|
||||
let looped = doc.add(Object::Dict(action("ResetForm")));
|
||||
doc.update_dict(looped, |a| a.set(b"Next".to_vec(), Object::Ref(looped))).unwrap();
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(looped))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 3, "{r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "URI", "Named"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_action_listed_twice_in_a_chain_gives_way_both_times() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let reset = doc.add(Object::Dict(with(action("ResetForm"), "Next", action("JavaScript"))));
|
||||
let submit = with(action("SubmitForm"), "Next", Object::Array(vec![Object::Ref(reset), Object::Ref(reset)]));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
// An indirect submit that holds a direct reset in its /Next.
|
||||
let root = doc.root().unwrap();
|
||||
let opening = doc.add(Object::Dict(with(action("SubmitForm"), "Next", action("ResetForm"))));
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(opening))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 4, "each action counts once: {r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "JavaScript"]);
|
||||
assert!(!dict(&doc, root).contains(b"OpenAction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_actions_chained_past_the_limit_stop_the_optimization() {
|
||||
let mut doc = Document::new_empty();
|
||||
page(&mut doc, &[], "");
|
||||
let root = doc.root().unwrap();
|
||||
let mut chain = action("ResetForm");
|
||||
for _ in 0..80 {
|
||||
chain = with(action("SubmitForm"), "Next", chain);
|
||||
}
|
||||
doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), chain)).unwrap();
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() });
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == root), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_next_array_that_lists_itself_is_left_as_it_is() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let looped = doc.add(Object::Null);
|
||||
doc.set(looped, Object::Array(vec![Object::Ref(looped)]));
|
||||
let (first, second) = (doc.add(Object::Null), doc.add(Object::Null));
|
||||
doc.set(first, Object::Array(vec![Object::Ref(second)]));
|
||||
doc.set(second, Object::Array(vec![Object::Ref(first)]));
|
||||
let link = |doc: &mut Document, next: ObjRef| {
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)))
|
||||
};
|
||||
let (own, pair) = (link(&mut doc, looped), link(&mut doc, first));
|
||||
let submit = with(action("SubmitForm"), "Next", Object::Ref(looped));
|
||||
let button = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", submit)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(own), Object::Ref(pair), Object::Ref(button)]))).unwrap();
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
|
||||
let mut cleaned = doc.clone();
|
||||
let r = optimize(&mut cleaned, &settings).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
for (l, next) in [(own, looped), (pair, first)] {
|
||||
let a = dict(&cleaned, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}");
|
||||
}
|
||||
assert_eq!(dict(&cleaned, button).get(b"A"), Some(&Object::Ref(looped)), "the submit's /Next takes its place");
|
||||
|
||||
// An array that lists itself and a submit, directly or in an array inside it: a cleaned copy
|
||||
// would still reach the submit.
|
||||
for submit in [Object::Dict(action("SubmitForm")), Object::Array(vec![Object::Dict(action("SubmitForm"))])] {
|
||||
let mut mixed = doc.clone();
|
||||
mixed.set(first, Object::Array(vec![Object::Ref(second), submit]));
|
||||
let r = optimize(&mut mixed, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `referrers` actions whose `/Next` is one shared array of 2,000 actions, a submit among them.
|
||||
fn shared_next_array(referrers: usize) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
page(&mut doc, &[], "");
|
||||
let script = doc.add(Object::Dict(action("JavaScript")));
|
||||
let mut items = vec![Object::Ref(script); 1_999];
|
||||
items.push(Object::Dict(action("SubmitForm")));
|
||||
let shared = doc.add(Object::Array(items));
|
||||
for _ in 0..referrers {
|
||||
doc.add(Object::Dict(with(action("GoTo"), "Next", Object::Ref(shared))));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_next_array_cleaned_for_too_many_actions_stops_the_optimization() {
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
let mut doc = shared_next_array(400);
|
||||
assert_eq!(optimize(&mut doc, &settings).unwrap().form_actions, 1);
|
||||
// Each referrer is under the limit for one object; together they pass the document's.
|
||||
let mut doc = shared_next_array(600);
|
||||
let r = optimize(&mut doc, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}");
|
||||
}
|
||||
|
||||
/// 80 direct dictionaries nested in each other under `/Deep`, the innermost one being `inner`.
|
||||
fn nested(inner: Dict) -> Object {
|
||||
let mut o = Object::Dict(inner);
|
||||
for _ in 0..80 {
|
||||
o = Object::Dict(with(Dict::new(), "Deep", o));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let plain = doc.add(nested(with(action("GoTo"), "Next", action("URI"))));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", action("SubmitForm"))));
|
||||
doc.update_dict(p, |d| {
|
||||
d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]));
|
||||
d.set(b"PieceInfo".to_vec(), Object::Ref(plain));
|
||||
})
|
||||
.unwrap();
|
||||
let settings = Settings { discard_form_actions: true, ..Settings::default() };
|
||||
let mut ok = doc.clone();
|
||||
let r = optimize(&mut ok, &settings).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "deep nesting with no form action in it is left as it is: {r:?}");
|
||||
|
||||
let reset = doc.add(Object::Dict(action("ResetForm")));
|
||||
for (n, inner) in [
|
||||
action("SubmitForm"),
|
||||
with(Dict::new(), "A", Object::Ref(reset)),
|
||||
with(Dict::new(), "Next", Object::Ref(doc.add(Object::Array(vec![Object::Ref(reset)])))),
|
||||
with(Dict::new(), "AA", Object::Ref(doc.add(Object::Dict(with(Dict::new(), "U", action("ImportData")))))),
|
||||
with(Dict::new(), "AA", with(Dict::new(), "U", Object::Ref(reset))),
|
||||
with(Dict::new(), "Next", Object::Array(vec![Object::Ref(reset)])),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut deep = doc.clone();
|
||||
let held = deep.add(nested(inner));
|
||||
deep.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(held))).unwrap();
|
||||
let r = optimize(&mut deep, &settings);
|
||||
assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == held), "case {n}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let triggers = with(with(Dict::new(), "U", action("SubmitForm")), "E", action("JavaScript"));
|
||||
let shared = doc.add(Object::Dict(triggers));
|
||||
let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "AA", Object::Ref(shared))));
|
||||
let (first, second) = (widget(&mut doc), widget(&mut doc));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
for w in [first, second] {
|
||||
assert_eq!(dict(&doc, w).get(b"AA"), Some(&Object::Ref(shared)), "the widgets still share one dictionary: {w:?}");
|
||||
}
|
||||
let aa = dict(&doc, shared);
|
||||
assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// A shared submit whose /Next is a script that resets the form after it runs.
|
||||
let script = with(action("JavaScript"), "Next", action("ResetForm"));
|
||||
let submit = doc.add(Object::Dict(with(action("SubmitForm"), "Next", script)));
|
||||
let next = doc.add(Object::Array(vec![Object::Dict(action("ImportData")), Object::Dict(action("URI")), Object::Dict(action("Named"))]));
|
||||
let link = |doc: &mut Document| {
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)))
|
||||
};
|
||||
let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit))));
|
||||
let (w1, w2, l1, l2) = (widget(&mut doc), widget(&mut doc), link(&mut doc), link(&mut doc));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([w1, w2, l1, l2].map(Object::Ref).to_vec()))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 3, "{r:?}");
|
||||
for w in [w1, w2] {
|
||||
assert_eq!(dict(&doc, w).get(b"A"), Some(&Object::Ref(submit)), "{w:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, w).get(b"A")), ["JavaScript"], "{w:?}");
|
||||
}
|
||||
for l in [l1, l2] {
|
||||
let a = dict(&doc, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, l).get(b"A")), ["GoTo", "URI", "Named"], "{l:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_action_in_an_array_inside_an_indirect_next_array_is_counted() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
let next = doc.add(Object::Array(vec![Object::Array(vec![Object::Dict(action("ResetForm"))]), Object::Dict(action("URI"))]));
|
||||
let a = with(action("GoTo"), "Next", Object::Ref(next));
|
||||
let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo", "URI"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_action_run_as_a_next_action_and_as_an_a_entry_stays_a_dictionary() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// A reset before two actions, run by one link after a GoTo and by another as its /A.
|
||||
let reset = doc.add(Object::Dict(with(
|
||||
action("ResetForm"),
|
||||
"Next",
|
||||
Object::Array(vec![Object::Dict(action("JavaScript")), Object::Dict(action("URI"))]),
|
||||
)));
|
||||
let link = |doc: &mut Document, a: Object| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a)));
|
||||
let after = link(&mut doc, Object::Dict(with(action("GoTo"), "Next", Object::Ref(reset))));
|
||||
let runs = link(&mut doc, Object::Ref(reset));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([after, runs].map(Object::Ref).to_vec()))).unwrap();
|
||||
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(dict(&doc, reset).name(b"S"), Some(&b"JavaScript"[..]), "the reset's place is taken by a dictionary");
|
||||
assert_eq!(dict(&doc, runs).get(b"A"), Some(&Object::Ref(reset)));
|
||||
assert_eq!(kinds(&doc, dict(&doc, runs).get(b"A")), ["JavaScript", "URI"]);
|
||||
let a = dict(&doc, after).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap();
|
||||
assert_eq!(a.get(b"Next"), Some(&Object::Ref(reset)));
|
||||
assert_eq!(kinds(&doc, dict(&doc, after).get(b"A")), ["GoTo", "JavaScript", "URI"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_submit_whose_script_runs_it_again_gives_way_to_the_script() {
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "");
|
||||
// The widget comes first, so the walk reaches the submit through it.
|
||||
let widget = doc.add(Object::Null);
|
||||
let submit = doc.add(Object::Null);
|
||||
doc.set(widget, Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit))));
|
||||
doc.set(submit, Object::Dict(with(action("SubmitForm"), "Next", with(action("JavaScript"), "Next", Object::Ref(submit)))));
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)]))).unwrap();
|
||||
let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.form_actions, 1, "{r:?}");
|
||||
assert_eq!(dict(&doc, widget).get(b"A"), Some(&Object::Ref(submit)));
|
||||
let script = dict(&doc, submit);
|
||||
assert_eq!((script.name(b"S"), script.get(b"Next")), (Some(&b"JavaScript"[..]), Some(&Object::Ref(submit))), "the script runs itself again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_shares_the_file_out_by_kind() {
|
||||
let mut doc = Document::new_empty();
|
||||
|
||||
@@ -251,6 +251,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo
|
||||
ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails")));
|
||||
ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags")));
|
||||
ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings")));
|
||||
ui.add(widgets::checkbox(&mut s.discard_form_actions, tl!("Discard all form submission, import and reset actions")));
|
||||
discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks");
|
||||
discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields");
|
||||
discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content");
|
||||
|
||||
@@ -7678,11 +7678,11 @@ title = "Discard form submit, import and reset actions"
|
||||
area = "K"
|
||||
tier = "P2"
|
||||
milestone = "M11"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["optimize.advanced"]
|
||||
tools = ["doc_optimize"]
|
||||
evidence = ["crates/optimize/tests/optimize.rs::form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay", "crates/optimize/tests/optimize.rs::a_discarded_form_action_hands_its_place_to_the_actions_after_it", "crates/optimize/tests/optimize.rs::a_form_action_listed_twice_in_a_chain_gives_way_both_times", "crates/optimize/tests/optimize.rs::a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it", "crates/optimize/tests/optimize.rs::shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared", "crates/optimize/tests/optimize.rs::deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit", "crates/automation/tests/it/automation.rs::optimizing_discards_form_actions_through_tools"]
|
||||
notes = "PDF Optimizer ▸ Discard Objects, off by default. Removes /SubmitForm, /ImportData and /ResetForm actions from link, widget and bookmark /A, catalog /OpenAction, every /AA trigger and /Next chains; the actions after a removed one still run in order. An object that holds a form action more than 64 direct objects deep, chains form actions more than 64 deep, or makes the walk look at more than 100,000 action values, or a document that makes it look at more than 1,000,000 in all, stops the optimization with an error. Not changed: JavaScript that calls submitForm or resetForm (Discard all links, actions and JavaScript removes the JavaScript in links, widgets, /AA triggers, /OpenAction and the document's scripts), and XFA form submissions."
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.flatten-form-fields"
|
||||
|
||||
Reference in new issue
Block a user