mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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:
1 parent
66415d7373
commit
c00e3d2fb4
3 files changed
+27
-9
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
use super::read::{acroform, fields};
|
use super::read::{acroform, fields};
|
||||||
use super::values::merged_appearance;
|
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`).
|
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
|
||||||
pub fn need_appearances(doc: &Document) -> bool {
|
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
|
/// The most each filter stage of the XMP metadata stream may decode for its PDF/A declaration to
|
||||||
/// Standards panel reads it: past it, metadata declares nothing there either.
|
/// 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;
|
pub(crate) const MAX_XMP_BYTES: usize = 8 << 20;
|
||||||
|
|
||||||
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). Metadata that
|
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). Metadata a
|
||||||
/// decodes past [`MAX_XMP_BYTES`] doesn't.
|
/// filter stage of which decodes past [`MAX_XMP_BYTES`] doesn't.
|
||||||
pub fn declares_pdfa(doc: &Document) -> bool {
|
pub fn declares_pdfa(doc: &Document) -> bool {
|
||||||
let Some(root) = doc.root() else { return false };
|
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 Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false };
|
||||||
let m = doc.resolve(&m);
|
let m = doc.resolve(&m);
|
||||||
match &*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,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 subpaths: Vec<DetectionPath> = Vec::new();
|
||||||
let mut path = DetectionPath::default();
|
let mut path = DetectionPath::default();
|
||||||
let mut cur: Option<[f64; 2]> = None;
|
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() {
|
match op.op.as_slice() {
|
||||||
b"q" => stack.push(ctm),
|
b"q" => stack.push(ctm),
|
||||||
b"Q" => ctm = stack.pop().unwrap_or(ctm),
|
b"Q" => ctm = stack.pop().unwrap_or(ctm),
|
||||||
|
|||||||
@@ -3645,8 +3645,9 @@ fn page_shapes_decode_a_repeated_content_stream_within_the_budget() {
|
|||||||
assert!(budget.exhausted());
|
assert!(budget.exhausted());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The PDF/A declaration is read from at most [`crate::appearances::MAX_XMP_BYTES`] of metadata, as
|
/// The PDF/A declaration is read from metadata each filter stage of which decodes within
|
||||||
/// preflight reads it: a field drawn with characters its font lacks decodes no more than that.
|
/// [`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]
|
#[test]
|
||||||
fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() {
|
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)]));
|
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(1 << 20)));
|
||||||
assert!(!declared(pdfa(crate::appearances::MAX_XMP_BYTES)), "metadata past the cap declares nothing");
|
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");
|
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,
|
/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share,
|
||||||
|
|||||||
Reference in new issue
Block a user