M11: the optimizer stores eight-bit black-and-white grey images at one bit

An eight-bit grey image whose samples are all 0 or 255 is packed to one
bit per pixel and then stored as Monochrome Images asks (Group 4, or
JBIG2 with #4320), when that is smaller as stored; its decode array
stays. Indexed images, a colour-key /Mask and Retain are left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:02 +01:00
co-authored by Claude Opus 5.5
parent 9883da3bc3
commit 3d6e11ecdb
2 changed files with 124 additions and 1 deletions
+54 -1
View File
@@ -604,6 +604,59 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stre
(crate::stored_len(&new)? < crate::stored_len(s)?).then_some(new)
}
/// An eight-bit grey image whose every sample is 0 or 255, stored at one bit per pixel and then as
/// `compression` asks (see [`mono`]), if that is smaller as stored. Lossless: a decode array maps
/// 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays. Left alone: indexed images
/// (their samples are palette indices), a colour-key `/Mask` (its ranges are in eight-bit values),
/// and everything when `compression` is Retain.
fn bilevel_grey(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stream> {
if compression == MonoCompression::Retain {
return None;
}
let d = &s.dict;
let indexed = |cs: &Object| match &*doc.resolve(cs) {
Object::Array(a) => matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")),
_ => false,
};
let colour_key = d.get(b"Mask").is_some_and(|m| doc.resolve(m).as_array().is_some());
if d.int(b"BitsPerComponent") != Some(8)
|| matches!(d.get(b"ImageMask"), Some(Object::Bool(true)))
|| colour_key
|| !d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs) && !indexed(cs))
{
return None;
}
// Through u32 first, as everywhere here, so the product can't overflow.
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
let samples = s.decoded().ok()?;
let samples = samples.get(..w.checked_mul(h)?)?;
let stride = w.div_ceil(8);
let mut packed = vec![0u8; stride.checked_mul(h)?];
for (i, v) in samples.iter().enumerate() {
match v {
0 => {}
255 => {
if let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) {
*b |= 0x80 >> (i % w % 8);
}
}
_ => return None,
}
}
let mut one_bit = d.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
one_bit.remove(k);
}
one_bit.set(b"BitsPerComponent".to_vec(), Object::Int(1));
let one_bit = Stream::flate(one_bit, &packed);
let best = mono(doc, &one_bit, compression).unwrap_or(one_bit);
(crate::stored_len(&best)? < crate::stored_len(s)?).then_some(best)
}
pub(crate) fn run(
doc: &mut Document,
pages: &[ObjRef],
@@ -630,7 +683,7 @@ pub(crate) fn run(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
report.images += 1;
if let Some(new) = mono(doc, s, settings.mono) {
if let Some(new) = mono(doc, s, settings.mono).or_else(|| bilevel_grey(doc, s, settings.mono)) {
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
report.images_recompressed += 1;
+70
View File
@@ -588,6 +588,76 @@ fn images_whose_data_decodes_far_past_their_samples_are_left_alone() {
assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled");
}
/// Eight-bit samples (0 or 255) from rows of one-bit ones.
fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec<u8> {
let stride = (w as usize).div_ceil(8);
(0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect()
}
/// Eight-bit grey images of only black and white (as some scanners and converters write scans)
/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array
/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples
/// are palette indices), one masked by colour key, and everything with `mono: Retain`.
#[test]
fn eight_bit_black_and_white_images_become_one_bit() {
let mut doc = Document::new_empty();
let (w, h) = (1200, 480);
let grey = eight_bit(&bilevel(w, h, false), w, h);
let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w as i64));
d.set(b"Height".to_vec(), Object::Int(h as i64));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
for (k, v) in entries {
d.set(k.as_bytes().to_vec(), v.clone());
}
doc.add(Object::Stream(Stream::flate(d, data)))
};
let gray_cs = ("ColorSpace", Object::name("DeviceGray"));
let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs));
let decode = Object::Array(vec![1.into(), 0.into()]);
let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]);
let mut shaded = grey.clone();
shaded[1000] = 128;
let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs));
let palette = Object::Array(vec![
Object::name("Indexed"),
Object::name("DeviceGray"),
255.into(),
Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::<Vec<_>>())),
]);
let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]);
let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]);
page(
&mut doc,
&[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)],
"q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \
q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q",
);
let before = render(&doc);
let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw;
// Grey settings that leave grey images alone, so only this conversion acts.
let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain };
let settings = Settings { gray: keep, ..Settings::default() };
let mut kept = doc.clone();
let r = optimize(&mut kept, &Settings { mono: MonoCompression::Retain, ..settings.clone() }).unwrap();
assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is");
let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r));
let r = optimize(&mut doc, &settings).unwrap();
assert_eq!(r.images_recompressed, 2);
for i in [plain, inverted] {
let s = stream(&doc, i);
assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])));
}
assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays");
assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched);
assert!(render(&doc) == before, "the page looks the same");
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();