M2.6: Optimize leaves a page damaged partway as it is stored

Optimize read a page's content up to any damage in its streams as the whole
of it. Merging a page's image strips then wrote the page's content again as
one stream, without what followed the damage, and the image pass measured
each image by its uses before the damage only, so one drawn larger past it
could be resampled below that use.

- `DecodeBudget::decode_contents_with_damage` reads a page's `/Contents` as
  `decode_contents` does, each stream also saying whether it is damaged
  partway; `decode_contents` is it without the flag.
- `content_bytes` gives `None` for a page with a damaged stream, as for one
  that doesn't decode: the content is not all known. Its strips aren't merged
  (`merge_page` already leaves such a page alone), and no image is resampled
  (`effective_resolutions` gives every image `INFINITY`).
- A form damaged partway leaves the measuring incomplete in the same way.

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-06 00:46:59 +01:00
1 parent 29c5488fb3
commit 2e2ba7e1d1
4 files changed
+100 -9

No files matched your search

+26 -2
View File
@@ -348,12 +348,18 @@ impl DecodeBudget {
/// 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`]).
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(s)],
Object::Stream(s) => vec![self.decode_with_damage(s, usize::MAX)],
Object::Array(list) => list
.iter()
.filter_map(|entry| match &*doc.resolve(entry) {
Object::Stream(s) => Some(self.decode(s)),
Object::Stream(s) => Some(self.decode_with_damage(s, usize::MAX)),
_ => None,
})
.collect(),
@@ -842,6 +848,24 @@ mod tests {
assert_eq!(budget.decode_with_damage(&hex, usize::MAX).ok(), Some((b"AB".to_vec(), true)));
}
/// A page's content streams each say whether they are damaged, in their places.
#[test]
fn a_pages_streams_each_say_whether_they_are_damaged() {
let mut doc = Document::new_empty();
let whole = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 m 1 1 l S")));
let Object::Stream(s) = &*doc.get(whole) else { panic!("not a stream") };
let cut = Stream::from_raw(s.dict.clone(), s.raw.get(..s.raw.len() - 6).unwrap_or_default().to_vec());
let cut = doc.add(Object::Stream(cut));
let contents = Object::Array(vec![Object::Ref(whole), Object::Null, Object::Ref(cut)]);
let mut budget = DecodeBudget::page();
let read: Vec<_> = budget.decode_contents_with_damage(&doc, &contents).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(read, [Some(false), Some(true)]);
let one: Vec<_> =
budget.decode_contents_with_damage(&doc, &Object::Ref(cut)).into_iter().map(|r| r.map(|(_, damaged)| damaged).ok()).collect();
assert_eq!(one, [Some(true)]);
assert_eq!(budget.decode_contents(&doc, &contents).iter().filter(|r| r.is_ok()).count(), 2, "read up to the damage");
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
+4 -1
View File
@@ -22,11 +22,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
A page whose content doesn't decode, or is damaged partway, is left as it is: written again from
what decoded, it would lose the rest.
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
through form XObjects, up to 12 deep; the smallest over all uses).
Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches.
If a use can't be
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
measured (forms nested deeper or drawn too many times, or content that doesn't decode or is
damaged partway, which may draw an image larger past the damage), no image is resampled. Colour and grayscale images above a
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is.
+7 -6
View File
@@ -32,9 +32,10 @@ fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> {
}
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
/// doesn't decode or the budget runs out: the content is not all known.
/// doesn't decode, is damaged partway or the budget runs out: the content is not all known.
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
let reads = budget.decode_contents_with_damage(doc, contents);
let pieces = reads.into_iter().map(|read| read.ok().filter(|(_, damaged)| !damaged).map(|(data, _)| data)).collect::<Option<Vec<_>>>()?;
Some(Pieces::join(&pieces).data().to_vec())
}
@@ -214,7 +215,7 @@ impl Walk<'_> {
self.complete = false;
return;
}
let Ok(data) = self.budget.decode(s) else {
let Ok((data, false)) = self.budget.decode_with_damage(s, usize::MAX) else {
self.complete = false;
return;
};
@@ -245,9 +246,9 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool {
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
///
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
/// among those missed.
/// If forms nest too deeply, are drawn too many times, or a page's or form's content doesn't
/// decode within its budget or is damaged partway, every image is given `INFINITY` (never
/// resampled), since its largest use may be among those missed.
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
walk_pages(doc, pages).0
}
+63
View File
@@ -3157,6 +3157,69 @@ fn images_that_do_not_tile_a_picture_are_left_alone() {
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
Stream::from_raw(dict, raw)
}
/// A page whose content is damaged partway isn't all known: its strips aren't merged, which would
/// write its content again without what follows the damage.
#[test]
fn strips_on_a_page_damaged_partway_are_left_as_they_are() {
let (mut doc, _) = printed_page(40, |_| false, |_, s| s);
let p = pdfcraft_annot::page_refs(&doc).unwrap()[0];
let bad = damaged_flate(b"0 g\n", b"q 1000 0 0 1 0 0 cm /R0 Do Q");
let damaged = doc.add(Object::Stream(bad.clone()));
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
let contents = Object::Array(vec![first, Object::Ref(damaged)]);
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), contents.clone())).unwrap();
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}");
assert_eq!(doc.get(p).as_dict().unwrap().get(b"Contents"), Some(&contents));
assert_eq!(stream(&doc, damaged), bad);
}
/// An image may be drawn larger past the damage in a page's or a form's content, so no image is
/// resampled for the uses before it.
#[test]
fn content_damaged_partway_leaves_every_image_at_its_resolution() {
for in_form in [false, true] {
let mut doc = Document::new_empty();
let img = image(&mut doc, 720, 720, 1, false, None);
let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q");
assert_eq!(effective_resolutions(&doc, &[p])[&img], 720.0);
let mut bad = damaged_flate(b"q 72 0 0 72 0 0 cm /Im Do Q\n", b"q 720 0 0 720 0 0 cm /Im Do Q");
let added = if in_form {
bad.dict.set(b"Subtype".to_vec(), Object::name("Form"));
bad.dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 1.into(), 1.into()]));
bad.dict.set(b"Resources".to_vec(), Object::Dict(resources(xobjects(&[("Im", img)]))));
let f = doc.add(Object::Stream(bad));
let res = Object::Dict(resources(xobjects(&[("Im", img), ("F", f)])));
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), res)).unwrap();
content(&mut doc, "/F Do")
} else {
doc.add(Object::Stream(bad))
};
let first = doc.get(p).as_dict().unwrap().get(b"Contents").cloned().unwrap();
doc.update_dict(p, |d| d.set(b"Contents".to_vec(), Object::Array(vec![first, Object::Ref(added)]))).unwrap();
// Not measured, or measured as never resampled: the image pass reads only what was measured.
let ppi = effective_resolutions(&doc, &[p]).get(&img).copied().unwrap_or(f64::INFINITY);
assert_eq!(ppi, f64::INFINITY, "in a form: {in_form}");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!(r.images_resampled, 0, "{r:?}");
}
}
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");