M6: field detection reads operators one at a time; the PDF/A check reads XMP as preflight does

From the Resources review of 66415d737:
- page_shapes collected every parsed operator, about 80 bytes for each
  decoded byte of dense content, so a page within its 256 MiB allowance
  could take some 20 GB. It now reads them one at a time
  (pdfcraft_content::ops) and drops the pieces once joined.
- declares_pdfa decoded through a DecodeBudget, which counts every filter
  stage against the 8 MiB cap, so XMP stored through a chain of filters
  stopped declaring PDF/A while preflight's Standards panel, which caps
  each stage, still read it. It reads the metadata as preflight does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-04 10:19:46 +01:00
1 parent 66415d7373
commit c00e3d2fb4
3 files changed
+27 -9

No files matched your search

+9 -6
View File
@@ -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,
}
}
+5 -1
View File
@@ -156,7 +156,11 @@ pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudg
let mut subpaths: Vec<DetectionPath> = 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),
+13 -2
View File
@@ -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("<x:xmpmeta><dc:title>pdfaid</dc:title></x:xmpmeta>".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<u8> = 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,