M11: lossless JBIG2 for black-and-white images

pdfcraft_filters::encode_jbig2_generic writes 1-bit rows as a JBIG2
stream for JBIG2Decode (embedded organisation: a page information
segment and an immediate lossless generic region, no globals): template
0 with its nominal AT pixels, typical prediction, and the MQ arithmetic
coder (mq.rs), written from the JBIG committee's ISO/IEC 14492 Final
Committee Draft. The coder reproduces its Annex H.2 test sequence byte
for byte; output round-trips through hayro-jbig2, and Poppler renders
it the same as the original.

The optimizer's Monochrome Images gets JBIG2 (lossless) beside CCITT
Group 4 (still the default), doc_optimize mono jbig2; a document below
PDF 1.4 gets /Version /1.4 in its catalog. On 300 dpi text JBIG2 is
about a third smaller than Group 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-10-29 20:47:45 +01:00
1 parent 1b7164cab1
commit 5c4cadce59
51 files changed
+720 -30

No files matched your search

Generated
+2
View File
@@ -2731,6 +2731,7 @@ version = "0.3.0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
"image",
]
[[package]]
@@ -4427,6 +4428,7 @@ dependencies = [
"fax",
"flate2",
"hayro-ccitt",
"hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
+2 -1
View File
@@ -67,8 +67,9 @@ flate2 = { version = "1.1", default-features = false, features = ["rust_backend"
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
# An independent CCITT decoder, for round-trip tests; already in the tree through hayro.
# Independent CCITT and JBIG2 decoders, for round-trip tests; already in the tree through hayro.
hayro-ccitt = "0.3"
hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
+1
View File
@@ -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-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text. JBIG2 symbol coding, where its largest gains on text are, is next.
- **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.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
+4 -2
View File
@@ -318,8 +318,9 @@ impl Automation {
settings.color = images("color", settings.color)?;
settings.gray = images("gray", settings.gray)?;
if let Some(mono) = a.get("mono") {
let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, retain)"));
let mono = mono.as_object().ok_or_else(|| ToolError::InvalidArgs("mono must be an object: { compression: ccitt_g4|retain }".into()))?;
let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)"));
let mono =
mono.as_object().ok_or_else(|| ToolError::InvalidArgs("mono must be an object: { compression: ccitt_g4|jbig2|retain }".into()))?;
// A misspelled key would otherwise leave the preset's setting in place unnoticed.
if let Some(key) = mono.keys().find(|k| *k != "compression") {
return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} (compression)")));
@@ -327,6 +328,7 @@ impl Automation {
if let Some(c) = mono.get("compression") {
settings.mono = match c.as_str() {
Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4,
Some("jbig2") => MonoCompression::Jbig2,
Some("retain") => MonoCompression::Retain,
_ => return Err(unknown(c)),
};
+1 -1
View File
@@ -1433,7 +1433,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { compression: ccitt_g4|retain } (default ccitt_g4: black-and-white images and image masks are stored losslessly as CCITT Group 4 when that is smaller; counted in images_recompressed). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { compression: ccitt_g4|jbig2|retain } (default ccitt_g4: black-and-white images and image masks are stored losslessly as CCITT Group 4, or with jbig2 as lossless JBIG2, usually smaller still, when that is smaller than the image as it is; counted in images_recompressed). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
+25 -1
View File
@@ -6431,6 +6431,30 @@ fn scanned_page() -> Vec<u8> {
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2,
/// smaller than as Group 4.
#[test]
fn doc_optimize_stores_black_and_white_images_as_jbig2() {
let dir = workdir("optimize-jbig2");
std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" }));
let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } }));
assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}");
let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap();
let filters: Vec<Vec<u8>> = written
.object_numbers()
.into_iter()
.filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
_ => None,
})
.collect();
assert_eq!(filters, [b"JBIG2Decode".to_vec()]);
}
/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with
/// `mono: { compression: retain }`.
#[test]
@@ -6457,7 +6481,7 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() {
assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]);
// An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an
// object is refused, not read as the default.
for mono in [json!({ "compression": "jbig2" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
}
}
+1
View File
@@ -17,5 +17,6 @@ thiserror.workspace = true
[dev-dependencies]
hayro-ccitt.workspace = true
hayro-jbig2.workspace = true
proptest.workspace = true
weezl.workspace = true
+6 -2
View File
@@ -3,7 +3,7 @@
PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3).
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder.
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder. JBIG2 generic region encoding is written here from the JBIG committee's ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999), the public draft of ITU-T T.88; its Annex H.2 test sequence checks the arithmetic coder byte for byte.
- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
## API
@@ -23,6 +23,7 @@ decode_tolerant_counted(chain, data, max_output) -> Result<(Vec<u8>, bool /*part
encode(filter, params, data) -> Result<Vec<u8>, FilterError>
encode_flate(data) -> Vec<u8> // zlib, level 6
encode_g4(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS
encode_jbig2_generic(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS
```
- `chain` is applied first to last, as in a `/Filter` array.
@@ -42,6 +43,7 @@ encode_g4(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows a
| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
| CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
| JBIG2Decode | not here (`Unsupported`; renderers decode it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
## Tests
@@ -49,8 +51,10 @@ encode_g4(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows a
- LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings.
- `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams).
- `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics.
- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions.
- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics.
- `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`).
## Status
M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode, DCT encode), CCITT Group 3 encoding and Flate encode levels other than 6.
M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2 and CCITT decode, DCT encode), CCITT Group 3 encoding, JBIG2 symbol coding (text regions, symbol dictionaries, lossy) and Flate encode levels other than 6.
+313
View File
@@ -0,0 +1,313 @@
//! JBIG2 generic region encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode`
//! (ISO 32000-2 §7.4.7): lossless, template 0 with its nominal adaptive pixels, typical prediction
//! on, MQ arithmetic coding ([`crate::mq`]). Written from the standard's 6.2.5 (coding a generic
//! region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 (the segments) and D.3 (the embedded
//! organisation PDF uses).
use crate::FilterError;
use crate::mq::{Context, MqEncoder};
/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a
/// row's buffers small and its positions in range.
pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20;
/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the
/// context in this order: the four on the current row, the five fixed ones on the row above, the
/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5).
const TEMPLATE: [(i32, i32); 16] = [
(-1, 0),
(-2, 0),
(-3, 0),
(-4, 0),
(-2, -1),
(-1, -1),
(0, -1),
(1, -1),
(2, -1),
(-1, -2),
(0, -2),
(1, -2),
(3, -1),
(-3, -1),
(2, -2),
(-2, -2),
];
/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in
/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row,
/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that.
const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1];
/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it.
const PAD: usize = 4;
/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on
/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an
/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits
/// without `/JBIG2Globals`.
///
/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's
/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole
/// byte.
///
/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`]
/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]).
pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, FilterError> {
if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS {
return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples")));
}
let width = columns as usize;
let stride = width.div_ceil(8);
let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt {
filter: "JBIG2Decode",
detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()),
})?;
let region = generic_region(samples, width, stride);
let mut out = Vec::with_capacity(region.len() + 64);
// Page information (7.4.8): the region's size, resolution unknown, eventually lossless,
// default pixel 0 (white), OR combination, not striped.
let mut page = Vec::with_capacity(19);
page.extend_from_slice(&columns.to_be_bytes());
page.extend_from_slice(&rows.to_be_bytes());
page.extend_from_slice(&[0; 8]);
page.push(0x01);
page.extend_from_slice(&[0, 0]);
segment(&mut out, 0, 48, &page);
// Immediate lossless generic region (7.4.6): region information (7.4.1) at (0, 0) with OR,
// flags (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then the data.
let mut generic = Vec::with_capacity(26 + region.len());
generic.extend_from_slice(&columns.to_be_bytes());
generic.extend_from_slice(&rows.to_be_bytes());
generic.extend_from_slice(&[0; 8]);
generic.push(0);
generic.push(0x08);
for (x, y) in &TEMPLATE[12..] {
generic.push(*x as i8 as u8);
generic.push(*y as i8 as u8);
}
generic.extend_from_slice(&region);
segment(&mut out, 1, 39, &generic);
Ok(out)
}
/// A segment (7.2): its header (number, type, no referred-to segments, page 1, data length), then
/// its data.
fn segment(out: &mut Vec<u8>, number: u32, kind: u8, data: &[u8]) {
out.extend_from_slice(&number.to_be_bytes());
out.push(kind);
out.push(0);
out.push(1);
// A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide.
out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(data);
}
/// The arithmetic-coded data of a generic region (6.2.5.7) with template 0, the nominal AT pixels
/// and typical prediction.
fn generic_region(samples: &[u8], width: usize, stride: usize) -> Vec<u8> {
let mut contexts: Vec<Context> = vec![0; 1 << 16];
let mut mq = MqEncoder::new();
let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k);
// The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded.
let blank = vec![0u8; width + 2 * PAD];
let (mut above2, mut above1) = (blank.clone(), blank.clone());
let mut row = blank;
let mut ltp = false;
for packed in samples.chunks_exact(stride) {
for (x, pixel) in row.iter_mut().skip(PAD).take(width).enumerate() {
let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0);
*pixel = u8::from(!sample);
}
// Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision.
let typical = row == above1;
if let Some(cx) = contexts.get_mut(sltp) {
mq.encode(cx, typical != ltp);
}
ltp = typical;
if !typical {
for x in 0..width {
let at = |dx: i32, dy: i32| {
let line = match dy {
0 => &row,
-1 => &above1,
_ => &above2,
};
let i = (PAD + x).checked_add_signed(dx as isize);
i.and_then(|i| line.get(i)).copied().unwrap_or(0)
};
let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k);
if let Some(state) = contexts.get_mut(cx) {
mq.encode(state, row.get(PAD + x) == Some(&1));
}
}
}
std::mem::swap(&mut above2, &mut above1);
std::mem::swap(&mut above1, &mut row);
}
mq.finish()
}
#[cfg(test)]
mod tests {
use super::*;
/// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true
/// for white (a JBIG2 0).
fn decode(data: &[u8]) -> Vec<Vec<bool>> {
struct Rows(Vec<Vec<bool>>, Vec<bool>, usize);
impl hayro_jbig2::Decoder for Rows {
fn push_pixel(&mut self, black: bool) {
self.1.push(!black);
}
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8));
}
fn next_line(&mut self) {
let mut row = std::mem::take(&mut self.1);
row.truncate(self.2);
self.0.push(row);
}
}
let image = hayro_jbig2::Image::new_embedded(data, None).unwrap();
let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize);
image.decode(&mut rows).unwrap();
rows.0
}
/// The rows of packed samples `data`, true for a 1 bit.
fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec<Vec<bool>> {
let stride = (columns as usize).div_ceil(8);
(0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect()
}
/// Rows of `columns` samples from `runs`: alternating white and black runs one longer than
/// those values (never empty, even when shrinking makes them all 0), until the rows are full;
/// or, with `noise`, the bytes of `runs` as they are.
fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec<u8> {
let stride = (columns as usize).div_ceil(8);
let mut data = vec![0u8; stride * rows as usize];
if runs.is_empty() {
return data;
}
if noise {
data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r);
return data;
}
let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1);
for y in 0..rows as usize {
for x in 0..columns as usize {
if left == 0 {
white = !white;
left = 1 + usize::from(runs[next % runs.len()]);
next += 1;
}
if white {
data[y * stride + x / 8] |= 0x80 >> (x % 8);
}
left -= 1;
}
}
data
}
#[test]
fn a_page_of_text_like_marks_round_trips_and_shrinks() {
let (w, h) = (1700u32, 220u32);
let stride = (w as usize).div_ceil(8);
let mut data = vec![0xFF; stride * h as usize];
for y in (20..h as usize).step_by(40) {
for dy in 0..12 {
for x in (100..1600).step_by(9) {
if (x / 9 + dy) % 5 != 0 {
data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8));
}
}
}
}
let jbig2 = encode_jbig2_generic(&data, w, h).unwrap();
assert_eq!(decode(&jbig2), unpack(&data, w, h));
let g4 = crate::encode_g4(&data, w, h).unwrap();
assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len());
}
#[test]
fn edges_padding_and_typical_rows_round_trip() {
// 3 columns: the last 5 bits of each byte pad the row; set them to anything.
let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD];
assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]);
for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] {
let data = vec![fill; (w as usize).div_ceil(8) * h as usize];
assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}");
}
}
/// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1.
fn samples(pixels: &[Vec<u8>]) -> Vec<u8> {
let width = pixels.first().map_or(0, Vec::len);
let stride = width.div_ceil(8);
let mut data = vec![0xFF; stride * pixels.len()];
for (y, row) in pixels.iter().enumerate() {
for (x, p) in row.iter().enumerate() {
if *p == 1 {
data[y * stride + x / 8] &= !(0x80 >> (x % 8));
}
}
}
data
}
/// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels.
#[test]
fn a_repeated_row_is_one_decision() {
let row: Vec<u8> = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect();
let pixels = vec![row; 1000];
let data = samples(&pixels);
crate::mq::DECISIONS.with(|n| n.set(0));
let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap();
// The first row's 800 pixels, and one typical-row decision for each of the 1000 rows.
assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000);
assert_eq!(decode(&jbig2), unpack(&data, 800, 1000));
}
/// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image
/// tiled with that very neighbourhood decodes only if both coders use the same context.
#[test]
fn the_typical_row_flag_shares_figure_8s_context() {
// Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up
// x = 1..7, the pixel's own row x = 0..3.
let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]];
let pixels: Vec<Vec<u8>> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect();
let data = samples(&pixels);
assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90));
}
#[test]
fn what_it_cannot_encode_is_an_error() {
assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_))));
assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes");
assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. })));
}
proptest::proptest! {
/// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder.
#[test]
fn bitmaps_round_trip(
columns in 1u32..300,
rows in 1u32..24,
runs in proptest::collection::vec(0u8..=255, 1..48),
noise in proptest::bool::ANY,
) {
let data = bitmap(columns, rows, &runs, noise);
let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap();
proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows));
}
/// Whatever it is given, it returns.
#[test]
fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) {
let _ = encode_jbig2_generic(&data, columns, rows);
}
}
}
+3
View File
@@ -18,13 +18,16 @@ mod ascii85;
mod asciihex;
mod ccitt;
mod flate;
mod jbig2;
mod lzw;
mod mq;
mod predictor;
mod runlength;
use std::borrow::Cow;
pub use ccitt::{MAX_G4_COLUMNS, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2_generic};
/// A PDF stream filter, identified by its `/Filter` name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+209
View File
@@ -0,0 +1,209 @@
//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the
//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1.
/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and
/// after an LPS renormalisation, and whether an LPS switches the MPS sense.
const STATES: [(u32, u8, u8, bool); 47] = [
(0x5601, 1, 1, true),
(0x3401, 2, 6, false),
(0x1801, 3, 9, false),
(0x0AC1, 4, 12, false),
(0x0521, 5, 29, false),
(0x0221, 38, 33, false),
(0x5601, 7, 6, true),
(0x5401, 8, 14, false),
(0x4801, 9, 14, false),
(0x3801, 10, 14, false),
(0x3001, 11, 17, false),
(0x2401, 12, 18, false),
(0x1C01, 13, 20, false),
(0x1601, 29, 21, false),
(0x5601, 15, 14, true),
(0x5401, 16, 14, false),
(0x5101, 17, 15, false),
(0x4801, 18, 16, false),
(0x3801, 19, 17, false),
(0x3401, 20, 18, false),
(0x3001, 21, 19, false),
(0x2801, 22, 19, false),
(0x2401, 23, 20, false),
(0x2201, 24, 21, false),
(0x1C01, 25, 22, false),
(0x1801, 26, 23, false),
(0x1601, 27, 24, false),
(0x1401, 28, 25, false),
(0x1201, 29, 26, false),
(0x1101, 30, 27, false),
(0x0AC1, 31, 28, false),
(0x09C1, 32, 29, false),
(0x08A1, 33, 30, false),
(0x0521, 34, 31, false),
(0x0441, 35, 32, false),
(0x02A1, 36, 33, false),
(0x0221, 37, 34, false),
(0x0141, 38, 35, false),
(0x0111, 39, 36, false),
(0x0085, 40, 37, false),
(0x0049, 41, 38, false),
(0x0025, 42, 39, false),
(0x0015, 43, 40, false),
(0x0009, 44, 41, false),
(0x0005, 45, 42, false),
(0x0001, 45, 43, false),
(0x5601, 46, 46, false),
];
#[cfg(test)]
thread_local! {
/// Decisions coded on this thread.
pub(crate) static DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7).
/// 0, the initial value, is index 0 with MPS 0 (E.2.8).
pub(crate) type Context = u8;
/// The encoder's registers (E.2.1) and the bytes written so far.
pub(crate) struct MqEncoder {
a: u32,
c: u32,
ct: u32,
/// The output, after the byte before the first one (B at INITENC), which is 0x00 and is
/// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out.
out: Vec<u8>,
}
impl MqEncoder {
/// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12.
pub(crate) fn new() -> Self {
MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] }
}
/// The byte B, the last one written (or the one before the first).
fn b(&self) -> u8 {
self.out.last().copied().unwrap_or(0)
}
/// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state.
pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) {
#[cfg(test)]
DECISIONS.with(|n| n.set(n.get() + 1));
let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1);
let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return };
self.a -= qe;
if d == mps {
// CODEMPS.
if self.a & 0x8000 != 0 {
self.c += qe;
return;
}
if self.a < qe {
self.a = qe;
} else {
self.c += qe;
}
*cx = nmps | (*cx & 0x80);
} else {
// CODELPS.
if self.a < qe {
self.c += qe;
} else {
self.a = qe;
}
let mps = mps != switch;
*cx = nlps | (u8::from(mps) << 7);
}
self.renorm();
}
/// RENORME (Figure E.8).
fn renorm(&mut self) {
loop {
self.a <<= 1;
self.c <<= 1;
self.ct -= 1;
if self.ct == 0 {
self.byte_out();
}
if self.a & 0x8000 != 0 {
break;
}
}
}
/// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before.
fn byte_out(&mut self) {
if self.b() == 0xFF {
self.stuffed();
} else if self.c < 0x800_0000 {
self.plain();
} else {
let b = self.b().wrapping_add(1);
if let Some(last) = self.out.last_mut() {
*last = b;
}
if b == 0xFF {
self.c &= 0x7FF_FFFF;
self.stuffed();
} else {
self.plain();
}
}
}
fn plain(&mut self) {
self.out.push(((self.c >> 19) & 0xFF) as u8);
self.c &= 0x7_FFFF;
self.ct = 8;
}
fn stuffed(&mut self) {
self.out.push(((self.c >> 20) & 0xFF) as u8);
self.c &= 0xF_FFFF;
self.ct = 7;
}
/// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker.
pub(crate) fn finish(mut self) -> Vec<u8> {
let top = self.c + self.a;
self.c |= 0xFFFF;
if self.c >= top {
self.c -= 0x8000;
}
self.c <<= self.ct;
self.byte_out();
self.c <<= self.ct;
self.byte_out();
if self.b() != 0xFF {
self.out.push(0xFF);
}
self.out.push(0xAC);
self.out.split_off(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes.
#[test]
fn the_annex_h_test_sequence_encodes_to_its_bytes() {
let decisions = [
0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E,
0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF,
];
let mut mq = MqEncoder::new();
let mut cx = 0;
for byte in decisions {
for bit in (0..8).rev() {
mq.encode(&mut cx, byte >> bit & 1 == 1);
}
}
let expected = [
0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37,
0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC,
];
assert_eq!(mq.finish(), expected);
}
}
+7 -5
View File
@@ -34,13 +34,15 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by
reference too, the numbers inside included (`Document::with_direct_parms`), here and below:
without them a predictor's tag bytes would shift every row of an image written back.
- **Monochrome images** (`Settings::mono`, CCITT Group 4 by default): a 1-bit image with one
colour component (grey, indexed, a separation) or an image mask, stored with no filter or with
general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`) when
that is smaller as stored. It is lossless: the samples decode to the same bits, so the colour
- **Monochrome images** (`Settings::mono`, CCITT Group 4 by default, or JBIG2): a 1-bit image
with one colour component (grey, indexed, a separation) or an image mask, stored with no filter
or with general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`)
or lossless JBIG2 (`encode_jbig2_generic`; a document below PDF 1.4 gets `/Version /1.4` in
its catalog) when that is smaller as stored. On 300 dpi text JBIG2 is about a third smaller
than Group 4. It is lossless: the samples decode to the same bits, so the colour
space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate;
on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet:
JBIG2, monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider
JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider
than 32,767 samples. Redaction doesn't decode Group 4 yet: a mark over part of an image stored
this way removes the whole image (nothing leaks). Nor does anything that exports an image:
Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML
+41 -14
View File
@@ -562,15 +562,17 @@ fn one_component(doc: &Document, cs: &Object) -> bool {
}
}
/// A black-and-white image (one bit per pixel, or an image mask) stored again as CCITT Group 4,
/// if `compression` asks for that and the result is smaller as stored. Only images whose samples
/// A black-and-white image (one bit per pixel, or an image mask) stored again as `compression`
/// asks (CCITT Group 4 or JBIG2), if the result is smaller as stored. Only images whose samples
/// PdfCraft decodes (no filter, or general-purpose filters: image codecs don't decode here)
/// qualify. The samples decode to the same bits, so the colour space, decode array and masks stay
/// as they are.
fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stream> {
if compression != MonoCompression::CcittG4 {
return None;
}
let widest = match compression {
MonoCompression::CcittG4 => pdfcraft_filters::MAX_G4_COLUMNS,
MonoCompression::Jbig2 => pdfcraft_filters::MAX_JBIG2_COLUMNS,
MonoCompression::Retain => return None,
};
let d = &s.dict;
let bits = d.int(b"BitsPerComponent");
let qualifies = if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
@@ -582,24 +584,31 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stre
return None;
}
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
if w == 0 || h == 0 || w > pdfcraft_filters::MAX_G4_COLUMNS || u64::from(w) * u64::from(h) > MAX_PIXELS {
if w == 0 || h == 0 || w > widest || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
// Through its parameters given by reference too, as `pixels` reads, and no further than its
// rows can need.
let bytes = usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?)?;
let g4 = pdfcraft_filters::encode_g4(&doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?, w, h).ok()?;
let samples = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?;
let mut new = d.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
new.remove(k);
}
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(h)));
new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
new.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let new = Stream::from_raw(new, g4);
let data = if compression == MonoCompression::Jbig2 {
// One embedded JBIG2 stream with its own page; no /JBIG2Globals.
new.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
pdfcraft_filters::encode_jbig2_generic(&samples, w, h).ok()?
} else {
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(h)));
new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
new.set(b"DecodeParms".to_vec(), Object::Dict(parms));
pdfcraft_filters::encode_g4(&samples, w, h).ok()?
};
let new = Stream::from_raw(new, data);
// Smaller as stored, counting the larger dictionary: a tiny image can lose by it.
(crate::stored_len(&new)? < crate::stored_len(s)?).then_some(new)
}
@@ -623,6 +632,7 @@ pub(crate) fn run(
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
refs.sort_by_key(|(r, _)| r.num);
let total = refs.len();
let mut jbig2 = false;
for (done, (r, ppi)) in refs.into_iter().enumerate() {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
@@ -634,6 +644,7 @@ pub(crate) fn run(
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
report.images_recompressed += 1;
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
doc.set(r, Object::Stream(new));
continue;
}
@@ -656,6 +667,22 @@ pub(crate) fn run(
if !progress(Stage::Images { done: total, total }) {
return Err(OptimizeError::Cancelled);
}
if jbig2 {
at_least_pdf_1_4(doc)?;
}
Ok(())
}
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
/// or more already, gets `/Version /1.4` in its catalog (ISO 32000-2 §7.7.2). A full save with
/// object streams writes a 1.5 header anyway.
fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> {
let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..];
let Some(root) = doc.root() else { return Ok(()) };
let catalog = doc.get(root).as_dict().and_then(|c| c.name(b"Version").map(<[u8]>::to_vec));
if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?;
}
Ok(())
}
+5 -2
View File
@@ -10,8 +10,8 @@
//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode
//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default,
//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform`
//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4,
//! losslessly, when that is smaller.
//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4
//! or JBIG2, losslessly, when that is smaller.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
@@ -124,6 +124,9 @@ pub enum Compression {
pub enum MonoCompression {
/// CCITT Group 4 (lossless), when smaller than the image as it is.
CcittG4,
/// JBIG2, lossless (a generic region), when smaller than the image as it is. JBIG2Decode is
/// PDF 1.4: an older document gets `/Version /1.4` in its catalog.
Jbig2,
/// Keep each image's own compression.
Retain,
}
+61
View File
@@ -588,6 +588,67 @@ 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");
}
/// The images of a document that are images, by object number.
fn image_refs(doc: &Document) -> Vec<ObjRef> {
doc.object_numbers()
.into_iter()
.map(|num| ObjRef { num, generation: doc.generation(num) })
.filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")))
.collect()
}
/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so
/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller
/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its
/// catalog.
#[test]
fn black_and_white_images_become_jbig2() {
let mut doc = Document::new_empty();
let (w, h) = (1200, 480);
let text = bilevel(w, h, false);
let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
let decode = Object::Array(vec![1.into(), 0.into()]);
let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]);
let indexed_cs = Object::Array(vec![
Object::name("Indexed"),
Object::name("DeviceRGB"),
1.into(),
Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])),
]);
let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]);
page(
&mut doc,
&[("G", gray), ("M", mask), ("I", indexed)],
"q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q",
);
// The same document, as PDF 1.3.
let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
bytes[..8].copy_from_slice(b"%PDF-1.3");
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.version(), "1.3");
let before = render(&doc);
let mut g4 = doc.clone();
let as_g4 = optimize(&mut g4, &Settings::default()).unwrap();
let mut jbig2 = doc.clone();
let r = optimize(&mut jbig2, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap();
assert_eq!(r.images_recompressed, 3);
assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after);
let images: Vec<Stream> = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect();
assert_eq!(images.len(), 3);
for s in &images {
assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false));
}
assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays");
let indexed_kept =
|s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed");
assert!(images.iter().any(indexed_kept), "the colour space stays");
let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap();
assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..]));
assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0");
assert!(render(&jbig2) == before, "the page looks the same");
}
#[test]
fn settings_choose_what_happens() {
let mut doc = Document::new_empty();
+1
View File
@@ -599,6 +599,7 @@
Grayscale Images الصور الرمادية
Monochrome Images الصور أحادية اللون
CCITT Group 4 CCITT المجموعة 4
JBIG2 (lossless) JBIG2 (بدون فقدان)
Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى
for images above للصور التي تتجاوز
Compression الضغط
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -261,6 +261,7 @@
Grayscale Images Изображения в сива скала
Monochrome Images Монохромни изображения
CCITT Group 4 CCITT група 4
JBIG2 (lossless) JBIG2 (без загуби)
Bicubic downsampling to Бикубично понижаване на резолюцията до
for images above за изображения над
Compression Компресия
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -562,6 +562,7 @@
Grayscale Images Imatges en escala de grisos
Monochrome Images Imatges monocromes
CCITT Group 4 CCITT grup 4
JBIG2 (lossless) JBIG2 (sense pèrdua)
Bicubic downsampling to Reducció bicúbica de resolució a
for images above per a imatges de més de
Compression Compressió
Can't render this file because it contains an unexpected character in line 7 and column 3.
+1
View File
@@ -569,6 +569,7 @@
Grayscale Images وێنە خۆڵەمێشییەکان
Monochrome Images وێنە یەکڕەنگەکان
CCITT Group 4 CCITT گرووپی 4
JBIG2 (lossless) JBIG2 (بێ لەدەستدان)
Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ
for images above بۆ ئەو وێنانەی سەرووی
Compression پەستاندن
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -683,6 +683,7 @@ key Space Mezerník
Grayscale Images Obrázky ve stupních šedi
Monochrome Images Monochromatické obrázky
CCITT Group 4 CCITT skupina 4
JBIG2 (lossless) JBIG2 (bezeztrátová)
Bicubic downsampling to Bikubické převzorkování na
for images above u obrázků nad
Compression Komprese
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -572,6 +572,7 @@
Grayscale Images Graustufenbilder
Monochrome Images Monochrome Bilder
CCITT Group 4 CCITT Gruppe 4
JBIG2 (lossless) JBIG2 (verlustfrei)
Bicubic downsampling to Bikubische Neuberechnung auf
for images above für Bilder über
Compression Komprimierung
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -1908,6 +1908,7 @@
Grayscale Images Εικόνες σε κλίμακα του γκρι
Monochrome Images Μονόχρωμες εικόνες
CCITT Group 4 CCITT Ομάδα 4
JBIG2 (lossless) JBIG2 (χωρίς απώλειες)
Bicubic downsampling to Δικυβική μείωση ανάλυσης σε
for images above για εικόνες πάνω από
ZIP ZIP
Can't render this file because it contains an unexpected character in line 482 and column 9.
+1
View File
@@ -595,6 +595,7 @@
Grayscale Images Imágenes en escala de grises
Monochrome Images Imágenes monocromas
CCITT Group 4 CCITT grupo 4
JBIG2 (lossless) JBIG2 (sin pérdida)
Bicubic downsampling to Reducción de resolución bicúbica a
for images above para imágenes por encima de
Compression Compresión
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -562,6 +562,7 @@
Grayscale Images Harmaasävykuvat
Monochrome Images Mustavalkokuvat
CCITT Group 4 CCITT ryhmä 4
JBIG2 (lossless) JBIG2 (häviötön)
Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen
for images above kuville, joiden tarkkuus yli
Compression Pakkaus
Can't render this file because it contains an unexpected character in line 5 and column 47.
+1
View File
@@ -572,6 +572,7 @@
Grayscale Images Images en niveaux de gris
Monochrome Images Images monochromes
CCITT Group 4 CCITT groupe 4
JBIG2 (lossless) JBIG2 (sans perte)
Bicubic downsampling to Sous-échantillonnage bicubique à
for images above pour les images au-dessus de
Compression Compression
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -546,6 +546,7 @@
Grayscale Images ग्रेस्केल छवियाँ
Monochrome Images मोनोक्रोम छवियाँ
CCITT Group 4 CCITT समूह 4
JBIG2 (lossless) JBIG2 (हानिरहित)
Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक
for images above इससे बड़ी छवियों के लिए
Compression संपीड़न
1 # Hindi (hi) interface catalog. Columns, separated by a TAB:
546
547
548
549
550
551
552
+1
View File
@@ -570,6 +570,7 @@
Grayscale Images Szürkeárnyalatos képek
Monochrome Images Monokróm képek
CCITT Group 4 CCITT 4. csoport
JBIG2 (lossless) JBIG2 (veszteségmentes)
Bicubic downsampling to Kettős köbös újramintázás erre
for images above az ennél nagyobb képeknél
Compression Tömörítés
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -2057,6 +2057,7 @@ comment menu Edit Edit
Grayscale Images Gambar Skala Abu-abu
Monochrome Images Gambar Monokrom
CCITT Group 4 CCITT Grup 4
JBIG2 (lossless) JBIG2 (lossless)
Green Hijau
Green on black Hijau di atas hitam
H / Space (hold) H / Space (tahan)
Can't render this file because it contains an unexpected character in line 483 and column 12.
+1
View File
@@ -596,6 +596,7 @@
Grayscale Images Immagini in scala di grigi
Monochrome Images Immagini monocromatiche
CCITT Group 4 CCITT gruppo 4
JBIG2 (lossless) JBIG2 (senza perdita)
Bicubic downsampling to Sottocampionamento bicubico a
for images above per immagini oltre
Compression Compressione
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -599,6 +599,7 @@
Grayscale Images グレースケール画像
Monochrome Images 白黒画像
CCITT Group 4 CCITT グループ 4
JBIG2 (lossless) JBIG2(可逆)
Bicubic downsampling to バイキュービック法でダウンサンプル:
for images above 対象画像の解像度:
Compression 圧縮
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -559,6 +559,7 @@
Grayscale Images Сұр реңкті кескіндер
Monochrome Images Монохромды кескіндер
CCITT Group 4 CCITT 4-топ
JBIG2 (lossless) JBIG2 (шығынсыз)
Bicubic downsampling to Бикубтық төмендету
for images above мұнан асатын кескіндер үшін
Compression Сығымдау
1 Menu Мәзір
559 Monochrome Images Монохромды кескіндер
560 CCITT Group 4 CCITT 4-топ
561 Bicubic downsampling to JBIG2 (lossless) Бикубтық төмендету JBIG2 (шығынсыз)
562 Bicubic downsampling to Бикубтық төмендету
563 for images above мұнан асатын кескіндер үшін
564 Compression Сығымдау
565 ZIP ZIP
+1
View File
@@ -546,6 +546,7 @@
Grayscale Images 회색조 이미지
Monochrome Images 흑백 이미지
CCITT Group 4 CCITT 그룹 4
JBIG2 (lossless) JBIG2(무손실)
Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링
for images above 다음 값을 초과하는 이미지에 적용
Compression 압축
1 # Korean (ko) interface catalog. Columns, separated by a TAB:
546
547
548
549
550
551
552
+1
View File
@@ -567,6 +567,7 @@
Grayscale Images ຮູບພາບລະດັບສີເທົາ
Monochrome Images ຮູບພາບຂາວດຳ
CCITT Group 4 CCITT ກຸ່ມ 4
JBIG2 (lossless) JBIG2 (ບໍ່ສູນເສຍ)
Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ:
for images above ສຳລັບຮູບພາບທີ່ເກີນ:
Compression ການບີບອັດ
Can't render this file because it contains an unexpected character in line 6 and column 32.
+1
View File
@@ -2125,6 +2125,7 @@ comment menu Edit Redaguoti
Grayscale Images Pilkų atspalvių paveikslėliai
Monochrome Images Vienspalviai paveikslėliai
CCITT Group 4 CCITT 4 grupė
JBIG2 (lossless) JBIG2 (be nuostolių)
Green Žalia
Green on black Žalia juodame fone
H / Space (hold) H / tarpo klavišas (laikyti)
Can't render this file because it contains an unexpected character in line 406 and column 9.
+1
View File
@@ -2062,6 +2062,7 @@
Grayscale Images Gråtonebilder
Monochrome Images Svart-hvitt-bilder
CCITT Group 4 CCITT gruppe 4
JBIG2 (lossless) JBIG2 (tapsfri)
Green Grønn
Green on black Grønn på svart
H / Space (hold) H / Mellomrom (hold)
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -594,6 +594,7 @@
Grayscale Images Obrazy w skali szarości
Monochrome Images Obrazy monochromatyczne
CCITT Group 4 CCITT grupa 4
JBIG2 (lossless) JBIG2 (bezstratna)
Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do
for images above dla obrazów powyżej
Compression Kompresja
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -218,6 +218,7 @@
Grayscale Images Imagens em escala de cinza
Monochrome Images Imagens monocromáticas
CCITT Group 4 CCITT grupo 4
JBIG2 (lossless) JBIG2 (sem perdas)
Bicubic downsampling to Redução bicúbica de amostragem para
for images above para imagens acima de
Compression Compactação
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -2068,6 +2068,7 @@ comment menu Edit Editare
Grayscale Images Imagini în tonuri de gri
Monochrome Images Imagini monocrome
CCITT Group 4 CCITT grupa 4
JBIG2 (lossless) JBIG2 (fără pierderi)
Green Verde
Green on black Verde pe negru
H / Space (hold) H / Spațiu (ținut apăsat)
Can't render this file because it contains an unexpected character in line 3 and column 110.
+1
View File
@@ -592,6 +592,7 @@
Grayscale Images Изображения в оттенках серого
Monochrome Images Монохромные изображения
CCITT Group 4 CCITT группа 4
JBIG2 (lossless) JBIG2 (без потерь)
Bicubic downsampling to Бикубическое уменьшение до
for images above для изображений выше
Compression Сжатие
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -893,6 +893,7 @@
Grayscale Images Sivinske slike
Monochrome Images Enobarvne slike
CCITT Group 4 CCITT skupina 4
JBIG2 (lossless) JBIG2 (brez izgub)
Green Zelena
H / Space (hold) H / preslednica (drži)
HTML web page Spletna stran HTML
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -882,6 +882,7 @@
Grayscale Images Imazhe në nuanca gri
Monochrome Images Imazhe bardhezi
CCITT Group 4 CCITT grupi 4
JBIG2 (lossless) JBIG2 (pa humbje)
Green Jeshile
H / Space (hold) H / Hapësira (mbaje shtypur)
HTML web page Faqe interneti HTML
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -576,6 +576,7 @@ comment menu Edit సవరించు
Grayscale Images గ్రేస్కేల్ చిత్రాలు
Monochrome Images మోనోక్రోమ్ చిత్రాలు
CCITT Group 4 CCITT గ్రూప్ 4
JBIG2 (lossless) JBIG2 (నష్టరహితం)
Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం:
for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు:
Compression కుదింపు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -556,6 +556,7 @@
Grayscale Images รูปภาพระดับสีเทา
Monochrome Images รูปภาพขาวดำ
CCITT Group 4 CCITT กลุ่ม 4
JBIG2 (lossless) JBIG2 (ไม่สูญเสียข้อมูล)
Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น
for images above สำหรับรูปภาพที่สูงกว่า
Compression การบีบอัด
1 # Thai (th) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
556
557
558
559
560
561
562
+1
View File
@@ -557,6 +557,7 @@
Grayscale Images Gri Tonlamalı Resimler
Monochrome Images Tek Renkli Resimler
CCITT Group 4 CCITT Grup 4
JBIG2 (lossless) JBIG2 (kayıpsız)
Bicubic downsampling to Şu değere bikübik alt örnekleme:
for images above bundan büyük resimler için:
Compression Sıkıştırma
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
557
558
559
560
561
562
563
+1
View File
@@ -597,6 +597,7 @@
Grayscale Images Зображення у відтінках сірого
Monochrome Images Монохромні зображення
CCITT Group 4 CCITT група 4
JBIG2 (lossless) JBIG2 (без втрат)
Bicubic downsampling to Бікубічне зниження роздільної здатності до
for images above для зображень із роздільною здатністю понад
Compression Стиснення
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2113,6 +2113,7 @@ comment menu Edit Sửa
Grayscale Images Ảnh thang xám
Monochrome Images Ảnh đơn sắc
CCITT Group 4 CCITT nhóm 4
JBIG2 (lossless) JBIG2 (không mất dữ liệu)
Green Xanh lá
Green on black Xanh lá trên nền đen
H / Space (hold) H / Space (giữ)
Can't render this file because it contains an unexpected character in line 493 and column 12.
+1
View File
@@ -267,6 +267,7 @@
Grayscale Images 灰度图像
Monochrome Images 单色图像
CCITT Group 4 CCITT 第 4 组
JBIG2 (lossless) JBIG2(无损)
Bicubic downsampling to 双三次降采样至
for images above (高于此分辨率的图像)
Compression 压缩
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -223,6 +223,7 @@
Grayscale Images 灰階圖片
Monochrome Images 單色圖片
CCITT Group 4 CCITT 第 4 組
JBIG2 (lossless) JBIG2(無損)
Bicubic downsampling to 雙三次降取樣至
for images above (高於此解析度的圖片)
Compression 壓縮
Can't render this file because it contains an unexpected character in line 9 and column 16.
@@ -150,10 +150,12 @@ fn mono_row(ui: &mut egui::Ui, m: &mut MonoCompression) {
ui.label(tl!("Compression"));
let label = match m {
MonoCompression::CcittG4 => tl!("CCITT Group 4"),
MonoCompression::Jbig2 => tl!("JBIG2 (lossless)"),
MonoCompression::Retain => tl!("Retain existing"),
};
craft_ui::ComboBox::from_id_salt(("mono", "compression")).selected_text(label).show_ui(ui, |ui| {
ui.selectable_value(m, MonoCompression::CcittG4, tl!("CCITT Group 4"));
ui.selectable_value(m, MonoCompression::Jbig2, tl!("JBIG2 (lossless)"));
ui.selectable_value(m, MonoCompression::Retain, tl!("Retain existing"));
});
});
+2
View File
@@ -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-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2; 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)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
@@ -256,6 +257,7 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Symbol coding not yet. 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. |
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
+2 -2
View File
@@ -7384,8 +7384,8 @@ milestone = "M11"
status = "partial"
commands = ["optimize.advanced", "optimize.reduce"]
tools = ["doc_optimize", "doc_reduce"]
evidence = ["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. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out."
evidence = ["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/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "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 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (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. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out."
[[feature]]
id = "optimize.only-if-smaller"