mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fix(forms): script streams share one decode budget per reading
Each field action and document-script entry decoded its /JS stream with a fresh 1 GiB allowance and kept its own copy, so many fields or name-tree pairs naming one compressed stream retained gigabytes. A reading of the fields, one field's actions, and the document scripts each share a 64 MiB DecodeBudget for their script streams.
This commit is contained in:
@@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option<FieldAction> {
|
||||
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
|
||||
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<Vec<(Trigger, FieldAc
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut scripts = crate::script_budget();
|
||||
for t in Trigger::ALL {
|
||||
let o = match t.key() {
|
||||
None => 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));
|
||||
}
|
||||
}
|
||||
|
||||
+35
-17
@@ -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<af::ButtonAction> {
|
||||
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option<af::ButtonAction> {
|
||||
// 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::But
|
||||
let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false)));
|
||||
af::ButtonAction::SetLayers { changes, preserve_rb }
|
||||
}
|
||||
b"JavaScript" => 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<String> {
|
||||
(!parts.is_empty()).then(|| parts.join("."))
|
||||
}
|
||||
|
||||
/// The JavaScript of an action dictionary (`/JS` string or stream).
|
||||
fn script(doc: &Document, action: &Object) -> Option<String> {
|
||||
/// 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<String> {
|
||||
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<Field>, 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<ObjRef, usize>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
seen: &mut Seen,
|
||||
out: &mut Vec<Field>,
|
||||
) {
|
||||
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<ObjRef>,
|
||||
scripts: DecodeBudget,
|
||||
}
|
||||
|
||||
fn walk(
|
||||
doc: &Document,
|
||||
r: ObjRef,
|
||||
parent: &Inherited,
|
||||
page_of: &std::collections::HashMap<ObjRef, usize>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
seen: &mut Seen,
|
||||
out: &mut Vec<Field>,
|
||||
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<ObjRef> = 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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object> = (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<Object> =
|
||||
(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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user