From ff8a07f283757b875179c6b6e773b2ce66c9f682 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 17:19:30 +0100 Subject: [PATCH] M11: monochrome downsampling leaves halftones alone; bounds; an indexed tie under an inverting decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Halftones and dithers: most of their new pixels cover both black and white, and taking the sample that covers more turned their tones solid (a 50% screen black, a 25% one white). An image whose new pixels mostly cover both is now left at its resolution; text covers both only along its edges and is still averaged down. downsample_bits says how many new pixels cover both. - A counter for each column: an image wider than a fax row (MAX_CCITT_COLUMNS) isn't averaged, so a strip of 80 million columns can't make the counters 640 MB. - A fax image's decoding is bounded at about a byte a pixel, as redaction's is (decoded_image_within), not 1 GiB; behind a Flate bomb it is left alone. - An indexed image's tie went to sample 0, which an inverting decode array maps to entry 1, though the docs say entry 0. It goes to the sample that decodes to entry 0 now (ISO 32000-2 §8.9.5.2). Each has a test that fails without it. From reviews with the pdfcraft-review skill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/optimize/src/images.rs | 89 ++++++++++++++++++------------ crates/optimize/tests/optimize.rs | 90 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 33 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 4c20c1b3e7..bb768551d5 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -565,7 +565,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool { /// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, /// with its width and height, and whether they were packed from eight bits. Two kinds count: /// - one bit per pixel in one colour component, or an image mask, stored with no filter, with -/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image`]); +/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further than its rows can need); /// - with `eight_bit`, eight-bit grey whose every sample is 0 or 255. A decode array maps 0 and 255 /// at eight bits as it maps 0 and 1 at one bit, so it stays. Left out: indexed images (their /// samples are palette indices) and a colour-key `/Mask` (its ranges are in eight-bit values). @@ -581,13 +581,14 @@ fn one_bit(doc: &Document, s: &Stream, eight_bit: bool) -> Option<(Vec, u32, let bits = d.int(b"BitsPerComponent"); let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true))); if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) { - // A fax image through its decoder; any other no further than its rows can need. + // No further than its rows can need: a fax image's code (the filters before its decoder + // produce it) takes at most about a byte a pixel and each row's end, as redaction allows. let len = stride.checked_mul(hu)?; - let mut data = match s.filters().last() { - Some((pdfcraft_filters::Filter::CcittFax, _)) => doc.decoded_image(s), - _ => doc.with_direct_parms(s).decoded_within(allowance(len, h)?), - } - .ok()?; + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + _ => allowance(len, h)?, + }; + let mut data = doc.decoded_image_within(s, max).ok()?; if data.len() < len { return None; } @@ -624,8 +625,9 @@ fn one_bit(doc: &Document, s: &Stream, eight_bit: bool) -> Option<(Vec, u32, /// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array /// (`[0 1]` when it has none of two numbers): the painted part of an image mask and black in grey /// decode to 0, and full ink in a separation (or a DeviceN of one colourant) to 1. An indexed image -/// says 0: its palette, not its decode array, says which entry is darker, and the palette isn't -/// read yet, so a tie goes to entry 0 whatever its colour. +/// says the sample that decodes to entry 0 (its decode array maps samples to entries, ISO 32000-2 +/// §8.9.5.2): its palette says which entry is darker, and the palette isn't read yet, so a tie goes +/// to entry 0 whatever its colour. fn dark_is_one(doc: &Document, d: &Dict) -> bool { let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()); let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { @@ -637,7 +639,6 @@ fn dark_is_one(doc: &Document, d: &Dict) -> bool { } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Array(a)) => match a.first().and_then(Object::as_name) { - Some(b"Indexed" | b"I") => false, Some(b"Separation" | b"DeviceN") => d1 > d0, _ => d1 < d0, }, @@ -648,8 +649,9 @@ fn dark_is_one(doc: &Document, d: &Dict) -> bool { /// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: /// each new pixel takes the sample that covers more of its area, and the dark one when the two tie /// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` -/// when the rows are short or the new size isn't smaller in both directions or empty. -pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option> { +/// when the rows are short or the new size isn't smaller in both directions or empty. Also says +/// how many new pixels cover both samples. +pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64)> { if nw == 0 || nh == 0 || nw > w || nh > h { return None; } @@ -662,6 +664,7 @@ pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dar let overlap = |a0: u64, a1: u64, b0: u64, b1: u64| a1.min(b1).saturating_sub(a0.max(b0)); // Per source column, the area its 1 samples cover in the new row. let mut ones = vec![0u64; usize::try_from(w).ok()?]; + let mut mixed = 0u64; for j in 0..nh { ones.fill(0); let (y0, y1) = (j * h, (j + 1) * h); @@ -683,20 +686,23 @@ pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dar // Against what the 0 samples cover: covered is at most w·h, which can't overflow // (w·h < 2^64 for u32 sizes). let zeros = w * h - covered; + mixed += u64::from(covered > 0 && zeros > 0); if covered > zeros || (dark_is_one && covered == zeros) { let i = usize::try_from(i).ok()?; *out_row.get_mut(i / 8)? |= 0x80 >> (i % 8); } } } - Some(out) + Some((out, mixed)) } /// A black-and-white image (see [`one_bit`]) resampled and stored again per `settings`, if that is /// smaller as stored; `ppi` is its effective resolution. Also says whether it was resampled. /// /// - **Resampled** ([`downsample_bits`]) when its resolution is above `above_ppi`, to `target_ppi`, -/// unless it has a soft mask, whose own size would then have to follow. +/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a +/// dither: most of its new pixels would cover both black and white, and taking the sample that +/// covers more would turn its tones solid (a 50% screen black, a 25% one white). /// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only /// a resampled image or one packed from eight bits may be: a 1-bit image that is only stored again /// becomes Group 4 or stays. With `Retain`, a resampled image keeps its kind of compression @@ -719,14 +725,31 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option } let (data, w, h, eight_bit) = one_bit(doc, s, !retain)?; let k = settings.target_ppi / ppi; + // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, + // so a hostile strip of 80 million can't make it 640 MB. + let narrow = w <= pdfcraft_filters::MAX_CCITT_COLUMNS; // At most `w` × `h`, as k < 1: no overflow. - let (nw, nh) = - if downsample && k < 1.0 { (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) } else { (w, h) }; - let resampled = (nw, nh) != (w, h); + let (nw, nh) = if downsample && narrow && k < 1.0 { + (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) + } else { + (w, h) + }; + let averaged = if (nw, nh) != (w, h) { + let (out, mixed) = downsample_bits(&data, w, h, nw, nh, dark_is_one(doc, d))?; + // A halftone or dither: most new pixels cover both samples. Text covers both only along + // its edges. + (mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) + } else { + None + }; + let resampled = averaged.is_some(); + let (data, nw, nh) = match averaged { + Some(out) => (out, nw, nh), + None => (data, w, h), + }; if (retain || fax_alone) && !resampled { return None; } - let data = if resampled { downsample_bits(&data, w, h, nw, nh, dark_is_one(doc, d))? } else { data }; let mut dict = d.clone(); for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { dict.remove(key); @@ -931,18 +954,18 @@ mod downsample_tests { #[test] fn each_new_pixel_takes_the_sample_covering_more_of_it() { // 4 × 2 to 2 × 1: three 1s of four on the left, none on the right. - assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); // A tie gives the dark sample: 0, black in grey, so a black line half a new pixel wide // stays (the second of four white columns is black); a white one that thin on black doesn't. - assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false), Some(vec![0b0100_0000])); - assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false), Some(vec![0])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b0100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0])); // With 1 the dark sample (an inverting decode array, a separation), that line is a 1. - assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true), Some(vec![0b1000_0000])); - assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true), Some(vec![0b1100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1100_0000])); // The same size gives the same bits; padding bits are 0. - assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false), Some(vec![0b1010_0000])); + assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false).map(|(out, _)| out), Some(vec![0b1010_0000])); // 3 to 2 across: the middle column counts half to each new one. - assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); } /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round @@ -971,7 +994,7 @@ mod downsample_tests { (vec![separation.clone()], true), (vec![separation, inverted.clone()], false), (vec![indexed.clone()], false), - (vec![indexed, inverted], false), + (vec![indexed, inverted], true), (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], false), ] { assert_eq!(dark_is_one(&doc, &image(&entries)), dark_one, "{entries:?}"); @@ -980,11 +1003,11 @@ mod downsample_tests { #[test] fn what_it_cannot_resample_is_none() { - assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false), None, "wider"); - assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false), None, "taller"); - assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false), None); - assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false), None, "short rows"); - assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true), None); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false).map(|(out, _)| out), None, "taller"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false).map(|(out, _)| out), None); + assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false).map(|(out, _)| out), None, "short rows"); + assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true).map(|(out, _)| out), None); } proptest::proptest! { @@ -1002,8 +1025,8 @@ mod downsample_tests { let (nw, nh) = (1 + (f64::from(w - 1) * nw_part) as u32, 1 + (f64::from(h - 1) * nh_part) as u32); let len = (w as usize).div_ceil(8) * h as usize; let data: Vec = seed.iter().cycle().take(len).copied().collect(); - proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one), Some(naive(&data, w, h, nw, nh, dark_one))); - let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).unwrap(); + proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one).map(|(out, _)| out), Some(naive(&data, w, h, nw, nh, dark_one))); + let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).map(|(out, _)| out).unwrap(); proptest::prop_assert_eq!(white, naive(&vec![0xFF; len], w, h, nw, nh, dark_one)); proptest::prop_assert!(naive(&vec![0xFF; len], w, h, nw, nh, dark_one).iter().any(|b| *b != 0)); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index ea2961513b..4efe4072d2 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -929,6 +929,96 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { assert!(differ * 200 < before.len(), "{differ} of {} channel values differ visibly", before.len()); } +/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels +/// would cover both black and white and taking the sample covering more would turn its tones +/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi. +/// Text at 600 ppi, which covers both only along its edges, is averaged down. Rows no wider than +/// a fax row (`MAX_CCITT_COLUMNS`) are averaged down too, but not one column wider: the averaging +/// keeps a counter for each column. +#[test] +fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pattern = |white: &dyn Fn(usize, usize) -> bool| { + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if white(x, y) { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + } + } + data + }; + let diffused = dithered(w, h); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let mut doc = Document::new_empty(); + let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey); + let halftones = + [pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)] + .map(|data| mono_image(&mut doc, w, h, &data, None, &grey)); + // 144 × 57.6 pt is 600 ppi. + let names = ["T", "A", "B", "C"]; + let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::>(), &content); + optimize(&mut doc, &Settings::default()).unwrap(); + let size = |r: ObjRef| { + let d = stream(&doc, r).dict; + (d.int(b"Width"), d.int(b"Height")) + }; + assert_eq!(size(text), (Some(600), Some(240)), "text"); + for (i, r) in halftones.iter().enumerate() { + assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); + } + + for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] { + let mut doc = Document::new_empty(); + let white = vec![0xFF; (columns as usize).div_ceil(8) * 2]; + let img = mono_image(&mut doc, columns, 2, &white, None, &grey); + // Far above 450 ppi both ways. + page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}"); + } +} + +/// A fax image behind a decompression bomb is left alone: the filters before its decoder may +/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB +/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its +/// code; without them it is averaged down. +#[test] +fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { + let (w, h) = (1200u32, 480u32); + let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let mut code = g4.clone(); + code.resize(g4.len() + padding, 0); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code)))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let before = stream(&doc, img).raw; + let r = optimize(&mut doc, &Settings::default()).unwrap(); + if padding == 0 { + assert_eq!(r.images_resampled, 1, "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, img).raw, before); + } + } +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty();