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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 00:20:10 +01:00
co-authored by Claude Opus 5.5
parent 7fbb40a2c1
commit d1677b4b3f
3 changed files with 51 additions and 5 deletions
+2 -1
View File
@@ -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%
+14 -4
View File
@@ -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<u32> = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect();
defaults.extend(used_elsewhere(doc, &rgb, &read));
let checked: HashSet<u32> =
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<ObjRef> = ppi
.keys()
+35
View File
@@ -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() {