diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 3211efb273..41dff04ed4 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -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, 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, 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] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9acb076908..fd7c60ae85 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 159ba04e8c..21f6953a8d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -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> { - let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().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::>>()?; 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 { walk_pages(doc, pages).0 } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 1387cae528..7250e9a0a0 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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");