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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 01:05:58 +01:00
co-authored by Claude Opus 5.5
parent e2adbe7faa
commit 6ad95f0aa4
4 changed files with 108 additions and 16 deletions
+5 -4
View File
@@ -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%
+35 -11
View File
@@ -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<Arc<Object>> {
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<bool> {
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<bool> {
}
}
/// 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<u32> {
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<u32> = 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
+67
View File
@@ -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<u8> = 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() {