diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 6c81080bb0..8860ae90e4 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -1,6 +1,6 @@ use super::read::{acroform, fields}; use super::values::merged_appearance; -use super::{DecodeBudget, Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { @@ -212,18 +212,21 @@ impl FieldAppearanceCache { } } -/// The most of the XMP metadata stream decoded to find a PDF/A declaration, as preflight's -/// Standards panel reads it: past it, metadata declares nothing there either. +/// The most each filter stage of the XMP metadata stream may decode for its PDF/A declaration to +/// be read, as preflight's Standards panel reads it: past it, metadata declares nothing there +/// either. pub(crate) const MAX_XMP_BYTES: usize = 8 << 20; -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). Metadata that -/// decodes past [`MAX_XMP_BYTES`] doesn't. +/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). Metadata a +/// filter stage of which decodes past [`MAX_XMP_BYTES`] doesn't. pub fn declares_pdfa(doc: &Document) -> bool { let Some(root) = doc.root() else { return false }; let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; let m = doc.resolve(&m); match &*m { - Object::Stream(s) => DecodeBudget::new(MAX_XMP_BYTES, 0).decode(s).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + // Not a `DecodeBudget`, which counts every stage against one allowance: preflight bounds + // each stage, and the two must agree on metadata stored through a chain of filters. + Object::Stream(s) => s.decoded_within(MAX_XMP_BYTES).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), _ => false, } } diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index b1636775f3..bc227dbf2c 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -156,7 +156,11 @@ pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudg let mut subpaths: Vec = Vec::new(); let mut path = DetectionPath::default(); let mut cur: Option<[f64; 2]> = None; - for op in Pieces::join(&pieces).parse().ops { + let joined = Pieces::join(&pieces); + drop(pieces); + // An operator at a time: dense content (`n n n …`) parsed whole holds about 80 bytes for each + // byte it decoded. + for op in pdfcraft_content::ops(joined.data()) { match op.op.as_slice() { b"q" => stack.push(ctm), b"Q" => ctm = stack.pop().unwrap_or(ctm), diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 15a3ae4072..efc0719128 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3645,8 +3645,9 @@ fn page_shapes_decode_a_repeated_content_stream_within_the_budget() { assert!(budget.exhausted()); } -/// The PDF/A declaration is read from at most [`crate::appearances::MAX_XMP_BYTES`] of metadata, as -/// preflight reads it: a field drawn with characters its font lacks decodes no more than that. +/// The PDF/A declaration is read from metadata each filter stage of which decodes within +/// [`crate::appearances::MAX_XMP_BYTES`], as preflight reads it: a field drawn with characters its +/// font lacks decodes no more than that a stage. #[test] fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() { let declared = |xmp: String| declares_pdfa(&document(&["<< /Type /Catalog /Metadata 2 0 R >>".into(), raw_stream(&xmp)])); @@ -3654,6 +3655,16 @@ fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() { assert!(declared(pdfa(1 << 20))); assert!(!declared(pdfa(crate::appearances::MAX_XMP_BYTES)), "metadata past the cap declares nothing"); assert!(!declared("pdfaid".into()), "metadata without pdfaid:part declares nothing"); + // 3.5 MiB stored as hex, then Flate: its stages decode 7 and 3.5 MiB, each within the cap. + let xmp = pdfa(7 << 19); + let hex: Vec = xmp.bytes().flat_map(|b| [b"0123456789ABCDEF"[usize::from(b >> 4)], b"0123456789ABCDEF"[usize::from(b & 15)]]).collect(); + let mut stream = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &hex); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let mut doc = Document::new_empty(); + let metadata = doc.add(Object::Stream(stream)); + let catalog = doc.add(Object::Dict([(b"Metadata".to_vec(), Object::Ref(metadata))].into_iter().collect())); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert!(declares_pdfa(&doc), "metadata through a chain of filters declares PDF/A, as preflight reads it"); } /// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share,