From c412093dc779a783568dec316db4718e9ef594c2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 18:12:33 +0100 Subject: [PATCH 1/5] M11: an indexed image's palette says which of its samples is dark, so it is averaged too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5426 left indexed images at their resolution rather than guess which sample is dark. The palette says: dark_is_one now maps samples 0 and 1 through the decode array to palette entries (rounded and clipped to hival, ISO 32000-2 §8.9.5.2) and compares how light those entries are (`lightness`: the grey, or Rec. 601 luma over DeviceRGB). Only DeviceGray and DeviceRGB bases count, the table a string or a stream (within 4 KiB), directly or by reference: there the palette's values are the colour, and under /DefaultGray or /DefaultRGB no polarity is asked for. Other bases (CMYK, calibrated, ICC-based, a resource name), two entries alike, an entry the table doesn't hold or a hival past 255 still say nothing; separations and DeviceN still aren't averaged, their tint transforms unread. #5426's tone guard applies as to any image. #5426's indexed thin-rule test now checks what it was for: with the palette either way round and the decode array either way, the averaged image holds the averaged DeviceGray one's samples (flipped where its samples are) and renders the same. A unit test covers each base, both orders, an inverting decode array, the weights of luma, the table by reference and in a stream, and every refusal; the older polarity test's doc says what is known now. Co-Authored-By: Claude Opus 5.5 --- crates/optimize/README.md | 14 ++-- crates/optimize/src/images.rs | 107 +++++++++++++++++++++++++++--- crates/optimize/src/lib.rs | 2 +- crates/optimize/tests/optimize.rs | 17 +++-- parity/acrobat-features.toml | 4 +- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index af9b5385e0..b8346a626a 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -59,12 +59,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going downsampling it to JPEG. - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a - tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image - mask (a 0, or a 1 with an inverting decode array). Only images whose dark sample is known so - are averaged: `DeviceGray` (grey and RGB packed to one bit among them) and image masks, with no - decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or - `/DefaultRGB` is or may be in force. Not a separation, a DeviceN, calibrated or ICC grey, or an - indexed image, whose palette isn't read. Not one with a soft mask, whose own size would have to + tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image + mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two + palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and + RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two + different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be + in force. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% one white), nor one with a square of four new pixels that each would, as a small screened diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 4c5cf54667..2929b98845 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -911,8 +911,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit Some(out) } -/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom -/// colour spaces stay at their original resolution rather than guessing which entry is dark. +/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array +/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so +/// is the painted part of an image mask. An indexed image's samples pick palette entries (its +/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and +/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and +/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike. fn dark_is_one(doc: &Document, d: &Dict) -> Option { let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { @@ -928,10 +932,42 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let hival = a.get(2).and_then(|v| doc.resolve(v).as_int()).filter(|h| (0..=255).contains(h))?; + // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. + let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; + let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); + (l0 != l1).then_some(l1 < l0) + } _ => None, } } +/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) +/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only +/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or +/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base +/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. +fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { + let n = match doc.resolve(a.get(1)?).as_name()? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + _ => return None, + }; + // At most 256 entries of three components: a stream decoding to more is no palette. + let table = match &*doc.resolve(a.get(3)?) { + Object::String(s) => s.bytes.clone(), + Object::Stream(s) => doc.with_direct_parms(s).decoded_within(4096).ok()?, + _ => return None, + }; + let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); + Some(match c.as_slice() { + [v] => v * 1000, + [r, g, b] => 299 * r + 587 * g + 114 * b, + _ => return None, + }) +} + /// `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` @@ -1003,12 +1039,13 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. /// /// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, -/// if its dark sample is known ([`dark_is_one`]: grey or an image mask, not under `defaults`), -/// 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). So would a -/// square of four new pixels that each cover both, as a small screened picture on a white page -/// has (and ragged scanned text, which is then left at its resolution too). +/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose +/// palette says which entry is darker, not under `defaults`), 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). So would a square of four new pixels that each cover +/// both, as a small screened picture on a white page has (and ragged scanned text, which is then +/// left at its resolution too). /// - **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 more bits may be: a 1-bit image that is only stored again /// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored @@ -1313,8 +1350,9 @@ mod downsample_tests { 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 - /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + /// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode + /// array; none known for a separation, nor for an indexed image without a palette (see the next + /// test), nor with a decode array that isn't two numbers. #[test] fn the_dark_sample_follows_the_colour_space_and_decode_array() { let doc = Document::new_empty(); @@ -1338,6 +1376,7 @@ mod downsample_tests { (vec![mask, inverted.clone()], Some(true)), (vec![separation.clone()], None), (vec![separation, inverted.clone()], None), + // A palette that isn't there says nothing. (vec![indexed.clone()], None), (vec![indexed, inverted], None), (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), @@ -1346,6 +1385,54 @@ mod downsample_tests { } } + /// An indexed image's dark sample is the one picking the darker palette entry, after its decode + /// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string + /// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a + /// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255 + /// say nothing. + #[test] + fn an_indexed_images_palette_says_which_sample_is_dark() { + let mut doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec())); + let referenced = doc.add(string(&[255, 0])); + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255]))); + let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]); + let indexed = |base: Object, hival: i64, table: Object| { + let mut d = Dict::new(); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table])); + d + }; + let inverted = |mut d: Dict| { + d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()])); + d + }; + for (d, dark_one, what) in [ + (indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"), + (indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"), + (indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"), + (inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"), + (indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"), + (indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"), + // Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685). + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"), + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"), + // Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it. + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"), + (indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"), + (indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"), + (indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"), + (indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"), + (indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"), + (indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"), + (indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"), + (indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"), + ] { + assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}"); + } + } + #[test] fn what_it_cannot_resample_is_none() { assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 6e8988fd1b..a4a0a810e0 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -13,7 +13,7 @@ //! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, //! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images //! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a -//! target resolution when grey or image masks, halftones and dithers aside (see the README), and +//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and //! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8f55d90214..92dc2e2d86 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3445,10 +3445,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } -/// Unknown palette polarity is never guessed. Both palette orders and Decode directions -/// retain the original thin rule and exact samples through a full save and render. +/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does +/// in grey: with the palette either way round, and the decode array either way, the averaged image +/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same +/// through a full save and render. #[test] -fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { +fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { let (w, h) = (1200u32, 480u32); let stride = (w as usize).div_ceil(8); let mut gray_data = vec![0xFF; stride * h as usize]; @@ -3460,6 +3462,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); let gray_render = render(&gray); optimize(&mut gray, &Settings::default()).unwrap(); + let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap()); let gray_stream = stream(&gray, gray_image); let gray_samples = gray.decoded_image(&gray_stream).unwrap(); assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); @@ -3491,10 +3494,12 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, image); - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); - assert_eq!(doc.decoded_image(&s).unwrap(), data); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}"); + // The rows are 600 samples, whole bytes: flipping each byte flips each sample. + let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect(); + assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); } } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4878eac627..4430e5fac4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7384,8 +7384,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey images and image masks (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations, DeviceN, calibrated or ICC grey, or indexed images (their dark sample isn't read), images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" From d1677b4b3ff39117405e8fb3f92d7e73a5a15cca Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 00:20:10 +0100 Subject: [PATCH 2/5] M11: an indexed image also drawn where the optimizer doesn't read keeps its resolution From CodeRabbit's review: default colour spaces apply to an indexed colour space's device base, so one in force where an annotation's appearance (or a pattern, or another image's /Alternates) draws the image may change which palette entry is darker, and the tie a resampled image keeps. Only black-and-white RGB went through used_elsewhere; indexed images now do too when downsampling is on, and one used beyond the page and form resources the walk read keeps its resolution, as such RGB keeps its colour space. A grey image stays out of this check, since sample 0 stays dark in a grey space, and #5426's rule for grey is unchanged: the test now has a grey image drawn on the page and in an annotation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/README.md | 3 ++- crates/optimize/src/images.rs | 18 ++++++++++++---- crates/optimize/tests/optimize.rs | 35 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index b8346a626a..184053c797 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -65,7 +65,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be - in force. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + in force (an indexed image referred to from beyond the page and form resources read, as an + annotation's appearance, may be). Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 2929b98845..843486f917 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -300,6 +300,14 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) } +/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]), +/// which default colour spaces in force where it is drawn may change. +fn indexed(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I"))) +} + /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary /// too, but not those in objects referred to. fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { @@ -1144,11 +1152,13 @@ pub(crate) fn run( progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { let (ppi, mut defaults, read) = walk_pages(doc, pages); - // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used - // where the walk didn't look may be drawn so. + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an + // indexed image's palette says which of its samples a resampled one keeps, which they may + // change: one used where the walk didn't look may be drawn so. let packs = settings.mono.compression != MonoCompression::Retain; - let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); - defaults.extend(used_elsewhere(doc, &rgb, &read)); + let checked: HashSet = + ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &checked, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 92dc2e2d86..3fe59a1325 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3504,6 +3504,41 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { } } +/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where +/// other default colour spaces may be in force and change which palette entry is darker) keeps its +/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is +/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an +/// indexed image's palette depends on what's in force where it is drawn. +#[test] +fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]); + let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)]; + for (cs, in_annotation, width) in cases { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]); + // 144 × 57.6 pt is 600 ppi. + let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + if in_annotation { + let resources = Object::Dict({ + let mut r = Dict::new(); + r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)]))); + r + }); + let appearance = drawing(&mut doc, Some(resources), &form_entries()); + annotate(&mut doc, p, appearance); + } + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}"); + } +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { From e2adbe7faa44a405878da33ed76842ff37bc6963 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 00:41:23 +0100 Subject: [PATCH 3/5] M11: tests: an indexed palette is read by its colours' luma, and only a real palette counts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/tests/optimize.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3fe59a1325..34df39cb15 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3539,6 +3539,33 @@ fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { } } +/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than +/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has +/// no palette, so its samples aren't averaged. +#[test] +fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0])); + let optimized = |space: &str| { + let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + (s.dict.int(b"Width"), doc.decoded_image(&s).unwrap()) + }; + let (width, samples) = optimized("Indexed"); + assert_eq!(width, Some(600)); + assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample"); + assert_eq!(optimized("NotIndexed").0, Some(1200)); +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { From 6ad95f0aa481d64c8e574ae8fefb00a99cf9b4fd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:05:58 +0100 Subject: [PATCH 4/5] M11: an indexed image's palette is read once it matters, within 4 KiB; a colour key keeps its image From a review of the spellings an indexed image can take: - The palette was read for every 1-bit indexed image, resampled or not, and twice each. Images share a table, so 1,000 images sharing one with 16 MiB of raw data took 16 to 60 s, none of them resampled. It is now read only for an image about to be resampled, and a table over 4 KiB is refused before it is copied or decoded, since no palette needs that. - A colour key can hide the dark sample a tie goes to, so a thin rule of the other was lost: a white rule on a hidden black went from 255 to the backdrop's 128. An image with a colour key now keeps its resolution. Grey had this since #5426 and is covered too. - `/Indexed` by reference, and a base of `[/DeviceRGB]`, are the same colour space and are read so. `/I`, an inline abbreviation, and a real hival aren't, and such an image is left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/README.md | 9 +++-- crates/optimize/src/images.rs | 46 ++++++++++++++++----- crates/optimize/tests/optimize.rs | 67 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 184053c797..994ccbd9d0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -63,10 +63,11 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or - `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two - different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be - in force (an indexed image referred to from beyond the page and form resources read, as an - annotation's appearance, may be). Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no + decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or + `/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form + resources read, as an annotation's appearance, may be). Not one with a colour key, which can + hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 843486f917..0f0e67c0d9 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,6 +1,7 @@ //! Image resampling and recompression. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; @@ -305,7 +306,12 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { fn indexed(doc: &Document, r: ObjRef) -> bool { let Object::Stream(s) = &*doc.get(r) else { return false }; let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); - matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I"))) + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed"))) +} + +/// The first entry of colour space array `a`, its family, read through a reference. +fn family(doc: &Document, a: &[Object]) -> Option> { + a.first().map(|f| doc.resolve(f)) } /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary @@ -762,7 +768,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool { let cs = doc.resolve(cs); match &*cs { Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), - Object::Array(a) => match a.first().and_then(Object::as_name) { + Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) { Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), @@ -838,7 +844,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); // Components a pixel, bits a component, and a pixel's samples as 1 or 0. let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { - Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; match decode.as_deref() { None => {} @@ -938,10 +944,18 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { return Some(d1 < d0); } + // A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the + // sample that shows. + if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) { + return None; + } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), - Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { - let hival = a.get(2).and_then(|v| doc.resolve(v).as_int()).filter(|h| (0..=255).contains(h))?; + Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => { + let hival = a.get(2).and_then(|v| match &*doc.resolve(v) { + Object::Int(h) if (0..=255).contains(h) => Some(*h), + _ => None, + })?; // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); @@ -951,21 +965,31 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { } } +/// The most bytes a palette is read to (see [`lightness`]). +const MAX_PALETTE: usize = 4096; + /// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) /// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only /// those two bases count, where the palette's values are the colour: under `/DefaultGray` or /// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base /// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { - let n = match doc.resolve(a.get(1)?).as_name()? { + // A device space is its name, or that name alone in an array. + let base = doc.resolve(a.get(1)?); + let base = match &*base { + Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?), + _ => base, + }; + let n = match base.as_name()? { b"DeviceGray" => 1, b"DeviceRGB" => 3, _ => return None, }; - // At most 256 entries of three components: a stream decoding to more is no palette. + // At most 256 entries of three components: more is no palette. Images share a table, and this + // is read for each of them, so a large one is refused before it's copied or decoded. let table = match &*doc.resolve(a.get(3)?) { - Object::String(s) => s.bytes.clone(), - Object::Stream(s) => doc.with_direct_parms(s).decoded_within(4096).ok()?, + Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(), + Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?, _ => return None, }; let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); @@ -1089,8 +1113,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { (w, h) }; - let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; - let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if polarity.is_some() { // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; // A halftone or dither: most new pixels cover both samples. Text covers both only along diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 34df39cb15..1db90f92be 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3566,6 +3566,73 @@ fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { assert_eq!(optimized("NotIndexed").0, Some(1200)); } +/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base +/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival` +/// aren't one, and neither is a table too large to be a palette, which images share and which +/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of +/// the other would be lost, in grey too; a stencil `/Mask` doesn't. +#[test] +fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]); + let rgb = || Object::name("DeviceRGB"); + let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table()); + let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| { + let mut doc = Document::new_empty(); + let mut entries = vec![("BitsPerComponent", Object::Int(1))]; + entries.extend(make(&mut doc)); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + stream(&doc, image).dict.int(b"Width") + }; + let averaged = Some(600); + let kept = Some(1200); + assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct"); + assert_eq!( + width(&|doc: &mut Document| { + let family = doc.add(Object::name("Indexed")); + vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))] + }), + averaged, + "the family by reference" + ); + let base = Object::Array(vec![rgb()]); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); + let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', 16385)).chain(*b">").collect(); + assert_eq!( + width(&|doc: &mut Document| { + let large = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(large)))] + }), + kept, + "a stream too large to be read for each image, whatever it decodes to" + ); + let key = || Object::Array(vec![0.into(), 0.into()]); + assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); + assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey"); + assert_eq!( + width(&|doc: &mut Document| { + let key = doc.add(key()); + vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))] + }), + kept, + "a colour key by reference" + ); +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1e637d1068..c62a1bacc6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base or with a table over 4 KiB, images drawn or listed where /DefaultGray or /DefaultRGB is or may be in force (an indexed image also used beyond the page and form resources read, as an annotation's appearance), images with a colour key, which can hide the dark sample, or a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" From 24c6529f772d33a83199263d5f27770afe3bae15 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:15:52 +0100 Subject: [PATCH 5/5] M11: test: a palette stream is read up to exactly 16 KiB of raw data Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/tests/optimize.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 1db90f92be..0efb5df8e3 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3609,17 +3609,25 @@ fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + // 16 KiB of raw data is all a table may take, whatever it decodes to. let mut d = Dict::new(); d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); - let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', 16385)).chain(*b">").collect(); - assert_eq!( - width(&|doc: &mut Document| { - let large = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))); - vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(large)))] - }), - kept, - "a stream too large to be read for each image, whatever it decodes to" - ); + let table_of = |raw: usize| { + let d = d.clone(); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect(); + move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))) + }; + for (raw, expected) in [(16384, averaged), (16385, kept)] { + let add = table_of(raw); + assert_eq!( + width(&|doc: &mut Document| { + let table = add(doc); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))] + }), + expected, + "a stream of {raw} bytes" + ); + } let key = || Object::Array(vec![0.into(), 0.into()]); assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey");