M2.6: a page's content is read with its /DecodeParms given by reference

`decode_with_damage` and `decode_contents_with_damage` read a stream's
`/DecodeParms` only when direct, as `Stream::decoded` does, so content behind
a predictor whose parameters, or a number in them, are an indirect object
decoded with the predictor's tag bytes left in, and was taken as whole. Edit
then listed and wrote back the wrong bytes, and pruning dropped an image such
a form draws. ISO 32000-2 lets those values be indirect.

- `decode_with_damage` takes the document and reads the parameters through
  it, and so does `decode_contents_with_damage`, and `decode_contents` with
  it: printing, accessibility's reading of a page and a page copied as a form
  read them too. Code that calls `decode` on a stream itself doesn't yet.
- It reads through `Document::decoding_view`: `with_direct_parms`, but a copy
  of the stream holds only its `/Filter` and the parameters, its data shared,
  so a stream a page's `/Contents` lists again and again isn't copied whole
  for each.

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-07 12:37:35 +01:00
1 parent 3fec35e1ea
commit 69e26167e4
9 files changed
+146 -28

No files matched your search

+1 -1
View File
@@ -389,7 +389,7 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
for entry in entries {
let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let (data, damaged) = match budget.decode_with_damage(s, usize::MAX) {
let (data, damaged) = match budget.decode_with_damage(doc, s, usize::MAX) {
Ok(read) => read,
// Content past the budget may hold more of the tag: editing the rest
// would leave that part marked as a tag the tree no longer has.
+53 -15
View File
@@ -276,12 +276,13 @@ impl DecodeBudget {
self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data)
}
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), also saying
/// whether the stream is damaged: `true` when a filter stopped at corrupt data, so the bytes
/// are only what decoded before it. A stream written back from them loses everything after
/// the damage, which other readers may still read.
pub fn decode_with_damage(&mut self, stream: &Stream, max: usize) -> Result<(Vec<u8>, bool), CosError> {
self.charge(stream, max, Stream::decoded_within_counted)
/// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's
/// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged:
/// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it.
/// A stream written back from them loses everything after the damage, which other readers may
/// still read.
pub fn decode_with_damage(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result<(Vec<u8>, bool), CosError> {
self.charge(&doc.decoding_view(stream), max, Stream::decoded_within_counted)
}
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
@@ -343,23 +344,24 @@ impl DecodeBudget {
self.seen.insert(r)
}
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
/// place, so the caller chooses between skipping it and giving up.
/// Each stream `contents` names, decoded against the allowance, in order, its `/DecodeParms`
/// given by reference read in `doc`. `contents` is a page's `/Contents`: one stream, or an
/// array whose non-stream entries are left out. A stream that fails to decode, or arrives
/// after the allowance is spent, is an `Err` in its place, so the caller chooses between
/// skipping it and giving up.
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
self.decode_contents_with_damage(doc, contents).into_iter().map(|read| read.map(|(data, _)| data)).collect()
}
/// [`DecodeBudget::decode_contents`], each stream also saying whether it is damaged partway
/// ([`DecodeBudget::decode_with_damage`]).
/// ([`DecodeBudget::decode_with_damage`], which reads `/DecodeParms` given by reference).
pub fn decode_contents_with_damage(&mut self, doc: &Document, contents: &Object) -> Vec<Result<(Vec<u8>, bool), CosError>> {
match &*doc.resolve(contents) {
Object::Stream(s) => vec![self.decode_with_damage(s, usize::MAX)],
Object::Stream(s) => vec![self.decode_with_damage(doc, s, usize::MAX)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode_with_damage(s, usize::MAX)),
Object::Stream(s) => Some(self.decode_with_damage(doc, s, usize::MAX)),
_ => None,
})
.collect(),
@@ -823,7 +825,8 @@ mod tests {
/// checksum, no filter at all.
#[test]
fn a_stream_damaged_partway_says_so() {
let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(s, max).map(|(d, damaged)| (d.len(), damaged)).ok();
let doc = Document::new_empty();
let read = |budget: &mut DecodeBudget, s: &Stream, max| budget.decode_with_damage(&doc, s, max).map(|(d, damaged)| (d.len(), damaged)).ok();
let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
let cut = Stream::from_raw(whole.dict.clone(), whole.raw.get(..whole.raw.len() / 2).unwrap_or_default().to_vec());
let partial = cut.decoded().map(|d| d.len()).unwrap_or(0);
@@ -845,7 +848,42 @@ mod tests {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
let hex = Stream::from_raw(dict, Stream::flate(Dict::new(), b"4142zz4344>").raw.to_vec());
assert_eq!(budget.decode_with_damage(&hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
assert_eq!(budget.decode_with_damage(&doc, &hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
}
/// `/DecodeParms` given by reference, or a parameter given by reference in them, is read: a
/// predicted stream decodes as with them direct. Of the stream, decoding copies no more than
/// its filters and their parameters.
#[test]
fn parameters_given_by_reference_are_read() {
let content = b"q 10 0 0 10 0 0 cm /Im Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let raw = Stream::flate(Dict::new(), &rows).raw;
let mut doc = Document::new_empty();
let direct: Dict = [(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect();
let by_reference = Object::Ref(doc.add(Object::Dict(direct.clone())));
let predictor = doc.add(Object::Int(12));
let inner = Object::Dict([(b"Predictor".to_vec(), Object::Ref(predictor))].into_iter().collect());
for parms in [Object::Dict(direct), by_reference, inner] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), parms.clone());
dict.set(b"Other".to_vec(), Object::Array(vec![Object::Int(0); 1000]));
let s = Stream::from_raw(dict, raw.to_vec());
assert_eq!(DecodeBudget::page().decode_with_damage(&doc, &s, usize::MAX).ok(), Some((content.to_vec(), false)), "{parms:?}");
let r = doc.add(Object::Stream(s.clone()));
let read = DecodeBudget::page().decode_contents_with_damage(&doc, &Object::Array(vec![Object::Ref(r)]));
assert_eq!(read, [Ok((content.to_vec(), false))], "{parms:?}");
assert_eq!(DecodeBudget::page().decode_contents(&doc, &Object::Ref(r)), [Ok(content.to_vec())], "{parms:?}");
let view = doc.decoding_view(&s);
assert!(matches!(view, std::borrow::Cow::Borrowed(_)) || view.dict.len() == 2, "{:?}", view.dict);
}
}
/// A page's content streams each say whether they are damaged, in their places.
+25 -8
View File
@@ -288,7 +288,29 @@ impl Document {
/// ([`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 Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
}
/// [`Document::with_direct_parms`] for decoding alone: where that copies `s`, this makes a
/// stream of `s`'s data, shared, and a dictionary of its `/Filter` and the parameters made
/// direct, all its filters read. A stream named again and again, as a page's `/Contents` may
/// list one, isn't copied whole each time.
pub(crate) fn decoding_view<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(made) = self.direct_parms(s) else { return Cow::Borrowed(s) };
let mut dict = Dict::new();
if let Some(f) = s.dict.get(b"Filter") {
dict.set(b"Filter".to_vec(), f.clone());
}
dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(Stream { dict, raw: s.raw.clone() })
}
/// The `/DecodeParms` [`Document::with_direct_parms`] gives `s`, or `None` when it keeps `s`.
fn direct_parms(&self, s: &Stream) -> Option<Object> {
let p = s.dict.get(b"DecodeParms")?;
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(),
@@ -296,7 +318,7 @@ impl Document {
None => Vec::new(),
};
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
return None;
}
let mut changed = matches!(p, Object::Ref(_));
// Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
@@ -323,12 +345,7 @@ impl Document {
}
_ => one(p, &mut changed),
};
if !changed {
return Cow::Borrowed(s);
}
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
changed.then_some(made)
}
/// `o`, or the object it refers to, without copying either.
+1 -1
View File
@@ -316,7 +316,7 @@ fn checked_streams(doc: &Document, page: usize) -> Result<Vec<ContentStream>, Ed
return Err(EditError::Invalid("a page content stream is missing".into()));
};
let (bytes, damaged) = decode
.decode_with_damage(stream, remaining.min(64 * 1024 * 1024))
.decode_with_damage(doc, stream, remaining.min(64 * 1024 * 1024))
.map_err(|_| EditError::Invalid("the page content cannot be decoded within its size limit".into()))?;
if damaged {
return Err(EditError::Invalid("a page content stream is damaged partway".into()));
+32
View File
@@ -5218,6 +5218,38 @@ fn a_content_stream_that_does_not_decode_is_kept_and_edited_around() {
assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok());
}
/// A content stream whose `/DecodeParms`, or a parameter in them, is given by reference is read
/// through the reference: its text is listed and edited, not read with the predictor's tag bytes
/// left in it.
#[test]
fn content_with_parameters_given_by_reference_is_read_and_edited() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let content = b"BT /F1 12 Tf 72 700 Td (Predicted) Tj ET";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let texts = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
for given in ["parameters", "predictor"] {
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET");
let predictor = Object::Int(12);
let parms = if given == "parameters" {
Object::Ref(doc.add(Object::Dict([(b"Predictor".to_vec(), predictor)].into_iter().collect::<Dict>())))
} else {
Object::Dict([(b"Predictor".to_vec(), Object::Ref(doc.add(predictor)))].into_iter().collect())
};
let mut s = Stream::flate(Dict::new(), &rows);
s.dict.set(b"DecodeParms".to_vec(), parms);
doc.set(ObjRef::new(4, 0), s);
assert_eq!(texts(&doc), ["Predicted", "Whole"], "{given} by reference");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
assert_eq!(texts(&reopen(&doc)), ["Edited", "Whole"], "{given} by reference");
}
}
/// Objects are selected only from content read whole: what a damaged stream hides past the
/// damage could change the graphics state of every later item.
#[test]
+1 -1
View File
@@ -92,7 +92,7 @@ pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut D
let mut out = Vec::with_capacity(list.len());
for entry in list {
let (data, damaged) = match &*doc.resolve(&entry) {
Object::Stream(s) => match budget.decode_with_damage(s, usize::MAX) {
Object::Stream(s) => match budget.decode_with_damage(doc, s, usize::MAX) {
Ok(read) => read,
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
Err(_) => (Vec::new(), false),
+1 -1
View File
@@ -215,7 +215,7 @@ impl Walk<'_> {
self.complete = false;
return;
}
let Ok((data, false)) = self.budget.decode_with_damage(s, usize::MAX) else {
let Ok((data, false)) = self.budget.decode_with_damage(doc, s, usize::MAX) else {
self.complete = false;
return;
};
+1 -1
View File
@@ -219,7 +219,7 @@ impl Pruner<'_> {
}
#[cfg(test)]
crate::work(1);
match DecodeBudget::new(usize::MAX, 0).decode_with_damage(s, usize::MAX) {
match DecodeBudget::new(usize::MAX, 0).decode_with_damage(self.doc, s, usize::MAX) {
Ok((data, false)) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown, past damage too: keep everything it could reach.
Ok((_, true)) | Err(_) => {
+31
View File
@@ -2669,6 +2669,37 @@ fn extract_keeps_images_content_damaged_partway_might_draw() {
}
}
/// A form whose `/DecodeParms` is given by reference is read through it: the image it draws is
/// kept, not lost to content read with the predictor's tag bytes left in it.
#[test]
fn extract_keeps_images_a_form_with_parameters_by_reference_draws() {
use pdfcraft_cos::ObjRef;
let content = b"q 10 0 0 10 0 0 cm /ImB Do Q";
// PNG Up, one column: each byte a row of its own, tagged 2.
let mut rows = Vec::new();
let mut above = 0u8;
for &b in content {
rows.extend([2, b.wrapping_sub(above)]);
above = b;
}
let mut src = doc_images_with(&raw("/Fm Do"), "");
let parms = src.add(Object::Dict([(b"Predictor".to_vec(), Object::Int(12))].into_iter().collect::<Dict>()));
let mut fm = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
fm.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
fm.dict.set(b"Subtype".to_vec(), Object::name("Form"));
fm.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
let fm = src.add(fm);
let xobjects: Dict =
[(b"ImA".to_vec(), Object::Ref(ObjRef::new(8, 0))), (b"ImB".to_vec(), Object::Ref(ObjRef::new(9, 0))), (b"Fm".to_vec(), Object::Ref(fm))]
.into_iter()
.collect();
src.update_dict(ObjRef::new(7, 0), |d| d.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let part = full_roundtrip(&extract_pages(&src, &[1]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "{names:?}");
assert!(!names.iter().any(|n| n.as_slice() == b"ImA"), "what the part doesn't draw still goes: {names:?}");
}
#[test]
fn extract_keeps_images_type3_glyphs_may_draw() {
// A Type 3 font without its own /Resources draws through the page's: its glyphs could `Do`.