M11: /Crypt doesn't count against with_direct_parms' chain limit

The decoders leave /Crypt out wherever it stands (Stream::filters), so
a chain of /Crypt and eight other filters decodes, but with_direct_parms
counted nine and left its parameters as they were: a predictor given by
reference was then skipped. Only the other filters count now, and a
/Crypt entry is null in the copy, as no decoder reads it. Tests cover
that chain and one exactly as long as a decoder takes.

From reviews with the pdfcraft-review skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG
This commit is contained in:
Grzegorz Martens
2026-10-29 20:47:39 +01:00
co-authored by Claude Opus 5.5
parent 58945d1f5b
commit e6fddf155b
2 changed files with 31 additions and 8 deletions
+12 -8
View File
@@ -162,18 +162,20 @@ impl Document {
/// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at
/// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of
/// each dictionary only the entries the decoders read are kept, numbers and flags given by
/// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. `s` itself
/// when none of those is indirect, or when the chain is longer than any decoder takes
/// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt`
/// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself
/// when none of those is indirect, or when the other filters are more than any decoder takes
/// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
/// large object copied once for each key and filter.
pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
let filters = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.len(),
Some(_) => 1,
None => 0,
let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
let filters: Vec<bool> = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().map(crypt).collect(),
Some(f) => vec![crypt(f)],
None => Vec::new(),
};
if filters > pdfcraft_filters::MAX_CHAIN {
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
}
let mut changed = matches!(p, Object::Ref(_));
@@ -196,7 +198,9 @@ impl Document {
Object::Dict(made)
};
let made = match &*self.held(p) {
Object::Array(a) => Object::Array(a.get(..filters.min(a.len())).unwrap_or_default().iter().map(|e| one(e, &mut changed)).collect()),
Object::Array(a) => {
Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect())
}
_ => one(p, &mut changed),
};
if !changed {
+19
View File
@@ -58,6 +58,25 @@ fn decode_parms_given_by_reference_are_read_once_direct() {
assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1));
let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9]));
assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_)));
// A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as
// the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's
// Flate, its parameters by reference.
let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data));
for crypt in [false, true] {
let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1];
names.push(Object::name("FlateDecode"));
if crypt {
names.insert(0, Object::name("Crypt"));
}
let mut entries = vec![Object::Null; names.len() - 1];
entries.push(Object::Ref(r));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::Array(names));
d.set(b"DecodeParms".to_vec(), Object::Array(entries));
let s = Stream::from_raw(d, wrapped.clone());
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}");
}
}
#[test]