M11: the optimizer stores Group 3 fax images as Group 4 when smaller

A CCITT fax image that wasn't resampled was left as it was, so Group 3
images (the 1-D and 2-D codings fax software writes) stayed Group 3.
Group 4 codes each row against the one above and is usually much
smaller. Now only Group 4 alone skips decoding; Group 3 is stored again
as Group 4 when that is smaller as stored, and Retain still keeps it.

It reads an image's CCITT parameters (its /DecodeParms entry with Table
11's defaults) through pdfcraft_cos::Document::fax_params, from #4384.

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:03 +01:00
co-authored by Claude Opus 5.5
parent 1ad951e25b
commit e221d552f9
4 changed files with 88 additions and 14 deletions
+5 -4
View File
@@ -61,10 +61,11 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
column. On a 600 ppi text page, Group 4 at 300 ppi is half the size.
- **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is
smaller, which only a resampled image or one packed from eight bits may be: a 1-bit image that
is only stored again becomes Group 4 or stays. A CCITT image is only decoded to be resampled,
unless another filter such as `ASCII85Decode` wraps it, which storing it again drops. With Retain, a resampled image keeps its kind of
compression (CCITT, or Flate) and nothing else changes; eight-bit grey is then left to the grey
settings. Every change is kept only when it is smaller as stored.
is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled;
Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4
alone when that is smaller. With Retain, a resampled image keeps its kind of compression (CCITT,
or Flate) and nothing else changes; eight-bit grey is then left to the grey settings. Every
change is kept only when it is smaller as stored.
- Apart from the downsampling it is lossless: the samples keep their meaning, so the colour
space, decode array and `/Mask` stay. Group 4 wins on scans, whose ragged edges defeat Flate;
on clean computer-drawn 1-bit art Flate can be smaller. Not yet: JBIG2, a lone Group 3 image to
+9 -8
View File
@@ -705,7 +705,8 @@ pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dar
/// covers more would turn its tones solid (a 50% screen black, a 25% one white).
/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only
/// a resampled image or one packed from eight bits may be: a 1-bit image that is only stored again
/// becomes Group 4 or stays. With `Retain`, a resampled image keeps its kind of compression
/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored
/// again like any other. With `Retain`, a resampled image keeps its kind of compression
/// (CCITT, or Flate), and nothing else changes: eight-bit grey stays a grey image like any
/// other, which the grey settings handle.
///
@@ -714,13 +715,13 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option
let d = &s.dict;
let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask");
let retain = settings.compression == MonoCompression::Retain;
let chain = s.filters();
let fax = doc.fax_params(s);
// Its kind of compression, which Retain keeps, behind any other filters.
let ccitt = matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _)));
// A fax image is only worth decoding to resample it; behind ASCII85Decode, say, storing it
// again as Group 4 also drops that filter.
let fax_alone = ccitt && chain.len() == 1;
if (retain || fax_alone) && !downsample {
let ccitt = fax.is_some();
// Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind
// ASCII85Decode, say, can be smaller stored again as Group 4 alone.
let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1;
if (retain || g4_alone) && !downsample {
return None;
}
let (data, w, h, eight_bit) = one_bit(doc, s, !retain)?;
@@ -747,7 +748,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option
Some(out) => (out, nw, nh),
None => (data, w, h),
};
if (retain || fax_alone) && !resampled {
if (retain || g4_alone) && !resampled {
return None;
}
let mut dict = d.clone();
+72
View File
@@ -1116,6 +1116,78 @@ fn monochrome_images_keep_what_they_should() {
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..])), "Retain: Flate stays Flate: {r:?}");
}
/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first,
/// with ITU-T T.4's terminating codes for the few lengths used here.
fn group_3(rows: &[&[u32]]) -> Vec<u8> {
let code = |white: bool, run: u32| match (white, run) {
(true, 0) => "00110101",
(true, 8) => "10011",
(true, 16) => "101010",
(true, 32) => "00011011",
(false, 8) => "000101",
(false, 16) => "0000010111",
(false, 32) => "000001101010",
_ => panic!("no code here for a run of {run}"),
};
let bits: String = rows.iter().flat_map(|row| row.iter().enumerate().map(|(i, run)| code(i % 2 == 0, *run))).collect();
bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect()
}
/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the
/// pixel on the page); with Retain it stays Group 3.
#[test]
fn group_3_fax_images_become_group_4() {
let (w, h) = (64u32, 64u32);
let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect();
// White is 1, as CCITTFaxDecode gives it without /BlackIs1.
let mut bits = Vec::new();
for row in &runs {
let mut packed = [0u8; 8];
let mut x = 0;
for (i, run) in row.iter().enumerate() {
for p in x..x + *run as usize {
if i % 2 == 0 {
packed[p / 8] |= 0x80 >> (p % 8);
}
}
x += *run as usize;
}
bits.extend(packed);
}
let g3 = group_3(&runs);
let p = pdfcraft_filters::CcittParams { k: 0, columns: w.into(), ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "the Group 3 data is the picture");
let mut doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w.into()));
d.set(b"Height".to_vec(), Object::Int(h.into()));
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(0));
parms.set(b"Columns".to_vec(), Object::Int(w.into()));
parms.set(b"Rows".to_vec(), Object::Int(h.into()));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q");
let before = render(&doc);
let mut kept = doc.clone();
optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
assert_eq!(stream(&kept, fax).raw[..], g3[..], "Retain keeps Group 3");
let r = optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "{r:?}");
let s = stream(&doc, fax);
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "Group 4");
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "the same picture");
assert!(render(&doc) == before, "the page looks the same");
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();
+2 -2
View File
@@ -7384,8 +7384,8 @@ milestone = "M11"
status = "partial"
commands = ["optimize.advanced", "optimize.reduce"]
tools = ["doc_optimize", "doc_reduce"]
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. An eight-bit grey image of only black and white is packed to one bit first, then stored the same way (indexed images and colour-key masks aside), unless the grey settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), a lone Group 3 image to Group 4 without resampling (one behind another filter is stored again as Group 4; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded), inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"]
notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. An eight-bit grey image of only black and white is packed to one bit first, then stored the same way (indexed images and colour-key masks aside), unless the grey settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller. CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
[[feature]]
id = "optimize.only-if-smaller"