diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a719..cdc89e5632 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 85284b8cc5..7bd87a2efc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -14,7 +14,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; @@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object { const MAX_LAYER_STATE: usize = 1024; /// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): // clicking it picks a picture, as `buttonImportIcon()` does. if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { @@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option af::button_script(&script(doc, &action)?), + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), _ => return None, }) } @@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { (!parts.is_empty()).then(|| parts.join(".")) } -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { let a = doc.resolve(action); let js = a.as_dict()?.get(b"JS")?.clone(); match &*doc.resolve(&js) { Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } /// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only /// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { (widgets.len() == 1) .then(|| doc.get(widgets[0])) @@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { }); let mut out = af::Actions::default(); let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { match af::parse_format(&f) { Some(fm) => out.format = fm, @@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() }; if let Some(af) = &af { for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { if let Some(r) = f.as_ref() { @@ -604,7 +614,7 @@ fn adopt_page_fields( pages: &[ObjRef], base: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, ) { for p in pages { @@ -613,7 +623,7 @@ fn adopt_page_fields( let Some(annots) = annots.as_array() else { continue }; for e in annots { let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { + if seen.fields.contains(&r) { continue; } let obj = doc.get(r); @@ -624,7 +634,7 @@ fn adopt_page_fields( if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { continue; } - if let Some(root) = unlisted_root(doc, r, seen) { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { walk(doc, root, base, page_of, seen, out, 0); } } @@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +/// What one reading of the form's fields has used: the field dictionaries read, and the script +/// budget their actions share. +struct Seen { + fields: std::collections::HashSet, + scripts: DecodeBudget, +} + fn walk( doc: &Document, r: ObjRef, parent: &Inherited, page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, + seen: &mut Seen, out: &mut Vec, depth: usize, ) { - if depth > 64 || !seen.insert(r) { + if depth > 64 || !seen.fields.insert(r) { return; } let obj = doc.get(r); @@ -808,9 +825,10 @@ fn walk( return; } let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); let ff = inh.ff.unwrap_or(0); let kind = match inh.ft.as_deref() { Some(b"Tx") => FieldKind::Text, diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index f5b8274459..7dd7bfde6c 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b2e4994ecf..b2b8573ebb 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { assert_eq!(walk.next(), None); assert_eq!(page_refs(&doc), [page]); } + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +}