diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 57e583a078..33e60256e0 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -604,6 +604,59 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option Option { + 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; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3fce1dadba..8993c598f8 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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 { + 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::>())), + ]); + 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();