mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 05:58:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
9883da3bc3
commit
3d6e11ecdb
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user