diff --git a/ROADMAP.md b/ROADMAP.md index baae83997d..5a441aa553 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,6 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too. - **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`. - **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images. - **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 13aa82aadb..0b159229a0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -43,12 +43,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than - were coded, and an image resampled, or stored again as Group 4 from behind another filter, - keeps them), and an eight-bit grey image of - only 0 and 255, packed to one bit (not an indexed image, whose samples are palette indices, nor - one with a colour-key `/Mask`) unless the grey settings make it smaller still, its soft mask counted - (resampled with it there, kept as it is here), as they can a dithered scan by downsampling it to - JPEG. + were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's + own values: eight-bit grey of only 0 and 255; eight-bit `DeviceRGB` of only black and white, + which becomes `DeviceGray` (not calibrated RGB or RGB with a profile); and an indexed image of 2, + 4 or 8 bits that uses only palette entries 0 and 1, which keeps its palette, whatever its two + colours. A decode array stays when it means the same at one bit (grey's, or RGB's if the same + for each component, as grey's); otherwise the image is left alone. The grey or colour settings + take a packed image instead when they make it smaller still, its soft mask counted (resampled + with it there, kept as it is here), as they can a dithered scan by downsampling it to JPEG. - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 42c9a742c8..8b55d4aeb2 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -562,14 +562,26 @@ fn one_component(doc: &Document, cs: &Object) -> bool { } } +/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither. +type Pixel = fn(&[u8]) -> Option; + /// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, -/// with its width and height, and whether they were packed from eight bits. Two kinds count: +/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit +/// image. These count: /// - one bit per pixel in one colour component, or an image mask, stored with no filter, with -/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further than its rows can need); -/// - with `eight_bit`, eight-bit grey whose every sample is 0 or 255. A decode array maps 0 and 255 -/// at eight bits as it maps 0 and 1 at one bit, so it stays. Left out: indexed images (their -/// samples are palette indices) and a colour-key `/Mask` (its ranges are in eight-bit values). -fn one_bit(doc: &Document, s: &Stream, eight_bit: bool) -> Option<(Vec, u32, u32, bool)> { +/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// than its rows can need); +/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): +/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array +/// maps 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays; +/// - eight-bit `DeviceRGB` whose every pixel is black or white, which becomes `DeviceGray`. A +/// decode array the same for all three components becomes that one pair; another stops it. +/// Calibrated RGB and RGB with a profile are left out, as the profile could make them other +/// than black and white; +/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and +/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than +/// the default stops it: in index units it would mean something else at one bit. +fn one_bit(doc: &Document, s: &Stream, pack: bool) -> Option<(Vec, u32, u32, Option)> { let d = &s.dict; // 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()?); @@ -593,33 +605,92 @@ fn one_bit(doc: &Document, s: &Stream, eight_bit: bool) -> Option<(Vec, u32, return None; } data.truncate(len); - return Some((data, w, h, false)); + return Some((data, w, h, None)); } - 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 !eight_bit || bits != Some(8) || mask || colour_key || !d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs) && !indexed(cs)) { + if !pack || mask || colour_key { return None; } - // Through its parameters given by reference too: without a PNG predictor's tag bytes taken - // off, rows of 0 and 255 would still pass below, each shifted by a byte. - let samples = doc.with_direct_parms(s).decoded_within(allowance(wu.checked_mul(hu)?, h)?).ok()?; - let samples = samples.get(..wu.checked_mul(hu)?)?; - let mut packed = vec![0u8; stride.checked_mul(hu)?]; - for (i, v) in samples.iter().enumerate() { - match v { - 0 => {} - 255 => { - if let Some(b) = packed.get_mut((i / wu) * stride + (i % wu) / 8) { - *b |= 0x80 >> (i % wu % 8); + let cs = d.get(b"ColorSpace")?; + let decode = d.get(b"Decode").map(|v| doc.resolve(v)); + let mut packed = d.clone(); + packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // Components a pixel, bits a component, and a pixel's samples as 1 or 0. + let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { + Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; + match decode.as_deref() { + None => {} + Some(Object::Array(v)) if v[..] == [Object::Int(0), Object::Int((1 << bpc) - 1)] => { + packed.remove(b"Decode"); } + Some(_) => return None, + } + (1, usize::try_from(bpc).ok()?, |v| match v { + [0] => Some(false), + [1] => Some(true), + _ => None, + }) + } + Object::Name(name) if matches!(name.as_slice(), b"DeviceRGB" | b"RGB") && bits == Some(8) => { + match decode.as_deref() { + None => {} + Some(Object::Array(v)) if v.len() == 6 && v.chunks(2).all(|pair| Some(pair) == v.get(..2)) => { + packed.set(b"Decode".to_vec(), Object::Array(v.get(..2)?.to_vec())); + } + Some(_) => return None, + } + packed.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + (3, 8, |v| match v { + [0, 0, 0] => Some(false), + [255, 255, 255] => Some(true), + _ => None, + }) + } + _ if bits == Some(8) && one_component(doc, cs) => (1, 8, |v| match v { + [0] => Some(false), + [255] => Some(true), + _ => None, + }), + _ => return None, + }; + let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); + // Through its parameters given by reference too: without a PNG predictor's tag bytes taken + // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. + let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) +} + +/// `h` rows of `w` pixels, each row `row_len` bytes of `n` samples a pixel at `bpc` bits (8, or +/// a divisor of 8 with `n` 1), as rows of 1-bit samples each starting on a new byte: 1 where +/// `white` says the pixel is white. `None` when a pixel is neither, `samples` is short, or the +/// rows have another shape: no pixels in a row, other sample sizes, or too few bytes for their +/// pixels. +fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, white): (usize, usize, Pixel)) -> Option> { + let stride = w.div_ceil(8); + let shape = (n > 0 && bpc == 8) || (n == 1 && matches!(bpc, 1 | 2 | 4)); + if stride == 0 || !shape || row_len < w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8) { + return None; + } + // From here no row is empty and x·n·bpc stays within one, and the output is no larger than + // the samples it is read from. + let samples = samples.get(..row_len.checked_mul(h)?)?; + let mut out = vec![0u8; stride.checked_mul(h)?]; + for (row, out_row) in samples.chunks(row_len).zip(out.chunks_mut(stride)) { + for x in 0..w { + let on = if bpc == 8 { + white(row.get(x * n..x * n + n)?)? + } else { + // Samples narrower than a byte, the first in its high bits; bpc divides 8. + let at = x * bpc; + white(&[(row.get(at / 8)? >> (8 - bpc - at % 8)) & ((1u8 << bpc) - 1)])? + }; + if on { + *out_row.get_mut(x / 8)? |= 0x80 >> (x % 8); } - _ => return None, } } - Some((packed, w, h, true)) + Some(out) } /// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array @@ -704,13 +775,14 @@ pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dar /// dither: most of its new pixels would cover both black and white, and taking the sample that /// 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 +/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again /// 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. +/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the +/// grey or colour settings. /// -/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay as they are. +/// The samples keep their meaning, so the decode array and `/Mask` stay as they are, and the +/// colour space too, but for black-and-white RGB, which becomes grey. fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option<(Stream, bool)> { let d = &s.dict; let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); @@ -724,7 +796,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option if (retain || g4_alone) && !downsample { return None; } - let (data, w, h, eight_bit) = one_bit(doc, s, !retain)?; + let (data, w, h, packed) = one_bit(doc, s, !retain)?; + let packed_from_more = packed.is_some(); let k = settings.target_ppi / ppi; // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, // so a hostile strip of 80 million can't make it 640 MB. @@ -736,7 +809,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option (w, h) }; let averaged = if (nw, nh) != (w, h) { - let (out, mixed) = downsample_bits(&data, w, h, nw, nh, dark_is_one(doc, d))?; + // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. + let (out, mixed) = downsample_bits(&data, w, h, nw, nh, dark_is_one(doc, packed.as_ref().unwrap_or(d)))?; // A halftone or dither: most new pixels cover both samples. Text covers both only along // its edges. (mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) @@ -751,15 +825,12 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option if (retain || g4_alone) && !resampled { return None; } - let mut dict = d.clone(); + let mut dict = packed.unwrap_or_else(|| d.clone()); for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { dict.remove(key); } dict.set(b"Width".to_vec(), Object::Int(i64::from(nw))); dict.set(b"Height".to_vec(), Object::Int(i64::from(nh))); - if eight_bit { - dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); - } let g4 = if settings.compression == MonoCompression::CcittG4 || (retain && ccitt) { pdfcraft_filters::encode_g4(&data, nw, nh).ok().map(|coded| { let mut dict = dict.clone(); @@ -774,7 +845,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, settings: &MonoSettings) -> Option } else { None }; - let flate = ((resampled || eight_bit) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. let best = [g4, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) @@ -1033,3 +1104,24 @@ mod downsample_tests { } } } + +#[cfg(test)] +mod pack_tests { + use super::{Pixel, packed_rows}; + + /// Rows of one pixel of one bit, 1 white: each packed as it is. + const WHITE_IS_1: Pixel = |v| Some(v == [1]); + + /// [`packed_rows`] packs rows it can read whole, and refuses any other shape, without reading + /// past a row or allocating more than its samples: no pixels in a row, no components, samples + /// of 3 bits, several components under 8 bits, rows shorter than their pixels, and a width far + /// past the samples. + #[test] + fn packed_rows_refuses_rows_it_cannot_read_whole() { + let samples = [0x80, 0x00, 0x80, 0x00]; + assert_eq!(packed_rows(&samples, 1, 1, 4, (1, 1, WHITE_IS_1)), Some(vec![0x80, 0x00, 0x80, 0x00])); + for (row_len, w, n, bpc) in [(1, 0, 1, 1), (1, 1, 0, 8), (1, 1, 1, 3), (1, 1, 2, 4), (1, 9, 1, 1), (1, usize::MAX / 8, 1, 1)] { + assert_eq!(packed_rows(&samples, row_len, w, 4, (n, bpc, WHITE_IS_1)), None, "row of {row_len} for {w} × {n} × {bpc}"); + } + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index f1e5015761..cc8f2f4683 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -486,9 +486,10 @@ fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() { /// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them /// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored /// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit -/// grey one with a PNG predictor at 600 ppi (resampled), and an eight-bit one of only black and -/// white with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and -/// white; packed to one bit) come out byte for byte as with direct parameters. +/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white +/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white; +/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with +/// direct parameters. #[test] fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { let build = |how: &str| { @@ -519,8 +520,21 @@ fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { parms.set(b"Columns".to_vec(), Object::Int(w as i64)); d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap(); - let packed = doc.add(Object::Stream(Stream::from_raw(d, black_and_white))); - for r in [one_bit, eight_bit, packed] { + let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white))); + let palette = vec![0, 0, 255, 255, 255, 255]; + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]), + ); + let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8); + let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap(); + let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries))); + for r in [one_bit, eight_bit, packed, indexed] { let mut s = stream(&doc, r); let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) }; let made = match how { @@ -540,16 +554,19 @@ fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. page( &mut doc, - &[("A", one_bit), ("B", eight_bit), ("C", packed)], - "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + &[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \ + q 280 0 0 112 325 200 cm /D Do Q", ); - let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw); let r = optimize(&mut doc, &Settings::default()).unwrap(); - assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}"); - let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r)); + assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}"); + let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r)); assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again"); assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); - assert_eq!((after[2].dict.int(b"BitsPerComponent"), after[2].dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + for s in &after[2..] { + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } after.map(|s| s.raw) }; let direct = build("direct"); @@ -788,15 +805,129 @@ fn eight_bit_black_and_white_images_become_one_bit() { assert!(render(&doc) == before, "the page looks the same"); } +/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample +/// (each row starting on a new byte). +fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec { + let (w, h) = (w as usize, h as usize); + let stride = w.div_ceil(8); + let mut out = Vec::new(); + for y in 0..h { + let row: Vec = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect(); + let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (i, s) in row.iter().enumerate() { + packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) }; + } + out.extend(packed); + } + out +} + +/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only +/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4, +/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the +/// same for each component becomes grey's, and an indexed image's default one goes. Not touched: +/// RGB with any other colour, a decode array that differs between components, a colour-key mask or +/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and +/// everything with `mono: Retain`. +#[test] +fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, 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.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), cs); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let rgb_cs = Object::name("DeviceRGB"); + let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]); + let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect()); + let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]); + let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]); + let mut red = rgb_data.clone(); + red[3000..3003].copy_from_slice(&[255, 0, 0]); + let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]); + let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]); + let palette = |entries: &[u8]| { + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + (entries.len() as i64 / 3 - 1).into(), + Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())), + ]) + }; + let blue_white = palette(&[0, 0, 255, 255, 255, 255]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]); + let default_decode = Object::Array(vec![0.into(), 255.into()]); + let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]); + let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]); + let mut third = indices.clone(); + third[3000] = 2; + let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]); + let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]); + let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + let content: String = + (0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Colour and grey settings that leave 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 { color: keep, gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is"); + + let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}"); + for (i, cs) in [ + (rgb, Object::name("DeviceGray")), + (rgb_inverted, Object::name("DeviceGray")), + (blue, blue_white.clone()), + (blue_decoded, blue_white.clone()), + (blue_4, blue_white), + ] { + let s = stream(&doc, i); + assert_eq!( + (s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")), + (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), + "{i:?}" + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}"); + let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; + assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); + } + assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + /// Black-and-white images above 450 ppi are averaged down to 300 ppi, by default: 1-bit Flate, CCITT -/// Group 4 (alone and behind ASCIIHexDecode), an image mask (its inverting decode array stays) and -/// eight-bit grey of only black and white, all drawn at 600 ppi, are stored at half the size (as +/// Group 4 (alone and behind ASCIIHexDecode), an image mask (its inverting decode array stays), and +/// eight-bit grey and RGB of only black and white (the RGB also the other way round, with a decode +/// array that inverts it, so its ties are 1s), all drawn at 600 ppi, are stored at half the size (as /// Group 4, or 1-bit Flate when that is smaller, as it is on this clean text), each new pixel the /// majority of the 2 × 2 it covers (a tie black, or for the mask painted). Left at their size: an image at 400 ppi (stored /// again as Group 4), a Group 4 one at 400 ppi (not even decoded), one behind ASCIIHexDecode at /// 400 ppi (stored again as Group 4 alone), and one with a soft mask. The page looks the same at /// screen size. With `downsample` off nothing is resampled; with Retain, Flate stays Flate, CCITT -/// stays CCITT, and eight-bit grey is left to the grey settings. +/// stays CCITT, and eight-bit grey and RGB are left to the grey and colour settings. #[test] fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let mut doc = Document::new_empty(); @@ -842,7 +973,11 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { d.set(b"Height".to_vec(), Object::Int(h.into())); d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); - let eight = doc.add(Object::Stream(Stream::flate(d, &eight_bit(&text, w, h)))); + let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h)))); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8)))); + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec())); + let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8)))); let at_400 = mono_image(&mut doc, w, h, &text, None, &grey); let fax_at_400 = g4(&mut doc, &grey); let mut sm = Dict::new(); @@ -856,10 +991,23 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { // 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi. page( &mut doc, - &[("A", flate), ("B", fax), ("C", mask), ("D", eight), ("E", at_400), ("F", fax_at_400), ("G", soft), ("H", hex_fax), ("I", hex_at_400)], + &[ + ("A", flate), + ("B", fax), + ("C", mask), + ("D", eight), + ("E", at_400), + ("F", fax_at_400), + ("G", soft), + ("H", hex_fax), + ("I", hex_at_400), + ("J", rgb), + ("K", rgb_inverted), + ], "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \ q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \ - q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q", + q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \ + q 144 0 0 57.6 430 480 cm /K Do Q", ); let before = render(&doc); let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; @@ -886,17 +1034,19 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { let (halved, halved_mask) = (halve(false), halve(true)); assert_ne!(halved, halved_mask, "the picture has ties"); - // The grey settings left alone too: an eight-bit image would otherwise take them when smaller. + // The grey and colour settings left alone too: an image of more bits would otherwise take + // them when smaller. let mut off = doc.clone(); let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE }; - let r = optimize(&mut off, &Settings { mono, gray: keep, ..Settings::default() }).unwrap(); + let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap(); assert_eq!(r.images_resampled, 0, "{r:?}"); - assert!([flate, fax, mask, eight, hex_fax].iter().all(|i| size(&off, *i) == (1200, 480))); + assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480))); let mut kept = doc.clone(); let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); - assert_eq!(r.images_resampled, 5, "{r:?}"); + // Not the inverted RGB: the colour settings leave an image with a decode array alone. + assert_eq!(r.images_resampled, 6, "{r:?}"); assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); for i in [fax, hex_fax] { assert_eq!(stream(&kept, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "and CCITT stays CCITT: {i:?}"); @@ -905,19 +1055,29 @@ fn black_and_white_images_above_450_ppi_are_averaged_down_to_300() { for i in [flate, fax, hex_fax] { assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((600, 240), halved.clone()), "{i:?}"); } - assert_eq!(stream(&kept, eight).dict.int(b"BitsPerComponent"), Some(8), "Retain leaves eight bits to the grey settings"); + let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); + assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); let untouched = [fax_at_400].map(|r| raw(&doc, r)); let hex_len = raw(&doc, hex_at_400).len(); let r = optimize(&mut doc, &Settings::default()).unwrap(); - assert_eq!((r.images_resampled, r.images_recompressed), (5, 3), "{r:?}"); - for i in [flate, fax, mask, eight, hex_fax] { + assert_eq!((r.images_resampled, r.images_recompressed), (7, 3), "{r:?}"); + assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); + for i in [flate, fax, mask, eight, hex_fax, rgb] { let s = stream(&doc, i); assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((600, 240), if i == mask { None } else { Some(1) })); assert!(matches!(s.dict.name(b"Filter"), Some(b"CCITTFaxDecode" | b"FlateDecode")), "{i:?}"); assert_eq!(doc.decoded_image(&s).unwrap(), if i == mask { halved_mask.clone() } else { halved.clone() }, "{i:?}"); } assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. + let s = stream(&doc, rgb_inverted); + let inverting = Object::Array(vec![1.into(), 0.into()]); + assert_eq!( + (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), + ((600, 240), Some(&Object::name("DeviceGray")), Some(&inverting)) + ); + assert_eq!(doc.decoded_image(&s).unwrap(), halved.iter().map(|b| !b).collect::>(), "the same picture, ties black"); for i in [at_400, soft, hex_at_400] { assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 56b10853a2..1cef4f5965 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the optimizer stores Group 3 fax images as Group 4, and packs black-and-white RGB and two-entry indexed images to one bit; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white images above 450 ppi are averaged down to 300 ppi by the optimizer; estimates unchanged) > **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (M11: black-and-white pictures stay one bit when PDFs are created and optimized; CCITT images export; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) @@ -258,6 +259,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | M11: the PDF Optimizer and Reduce File Size store Group 3 fax images as Group 4 when smaller (`Document::fax_params`), and pack eight-bit `DeviceRGB` of only black and white (as grey) and indexed images of 2, 4 or 8 bits that use only palette entries 0 and 1 (keeping the palette) to one bit, so Monochrome Images compresses and downsamples them. Estimates unchanged. | | 2026-10-24 | minor | M11: monochrome downsampling: the PDF Optimizer and Reduce File Size average black-and-white images (1-bit, CCITT fax, eight-bit black-and-white grey) above 450 ppi down to 300 ppi, by area with ties black, as Monochrome Images ▸ Downsample to and `doc_optimize` `mono: { downsample, ppi, above_ppi }`; CCITT decoding moves into `pdfcraft-filters` (`decode_ccitt`) behind `Document::decoded_image`. Estimates unchanged. | | 2026-10-24 | minor | M11: Create PDF stores pictures of only black and white at one bit (Group 4 or Flate, whichever is smaller) instead of eight-bit grey; the optimizer packs eight-bit black-and-white grey images to one bit before Monochrome Images compresses them, unless the grey settings make them smaller, as they can a dithered scan, soft mask counted; Export ▸ Images decodes CCITT fax images. Estimates unchanged. | | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ecadea7b64..bc4c68d538 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7361,7 +7361,7 @@ status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::black_and_white_images_above_450_ppi_are_averaged_down_to_300", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome (1-bit, CCITT fax and eight-bit black-and-white grey) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, ink in a separation, after the decode array; an indexed image's entry 0, its palette not read yet), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not monochrome images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB and indexed images of two palette entries) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, ink in a separation, after the decode array; an indexed image's entry 0, its palette not read yet), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not monochrome images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" @@ -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/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." +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/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "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. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB and RGB with a profile aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour 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"