diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 2c90981770..13aa82aadb 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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 diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index bb768551d5..42c9a742c8 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -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(); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index f9676144c4..f1e5015761 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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 { + 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(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ea60fc2857..ecadea7b64 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"