diff --git a/Cargo.lock b/Cargo.lock index 8fd7c9133b..42426bc688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4424,7 +4424,9 @@ dependencies = [ name = "pdfcraft-filters" version = "0.15.0" dependencies = [ + "fax", "flate2", + "hayro-ccitt", "proptest", "thiserror 2.0.21", "weezl 0.2.1", diff --git a/Cargo.toml b/Cargo.toml index 64a3cf5b60..5a92b67475 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,10 @@ hayro-syntax = "0.7.2" lopdf = "0.45" 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. +hayro-ccitt = "0.3" # foundation thiserror = "2" diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..42f70325bc 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-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. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 476c451028..1b26dec7f1 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -279,7 +279,7 @@ impl Automation { } pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset}; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; @@ -317,6 +317,13 @@ impl Automation { }; settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; + if let Some(c) = a.get("mono").and_then(|m| m.get("compression")).and_then(Value::as_str) { + settings.mono = match c { + "ccitt_g4" | "ccitt" | "g4" => MonoCompression::CcittG4, + "retain" => MonoCompression::Retain, + other => return Err(ToolError::InvalidArgs(format!("unknown mono compression {other:?} (ccitt_g4, retain)"))), + }; + } for (key, flag) in [ ("merge_fragments", &mut settings.merge_fragments), ("discard_thumbnails", &mut settings.discard_thumbnails), diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..c4a9f283b5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1433,7 +1433,7 @@ pub fn tools() -> Vec { .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). 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|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.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1444,6 +1444,7 @@ pub fn tools() -> Vec { "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, + "mono": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..aa4b50d087 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6372,6 +6372,95 @@ fn lzw_streams_are_stored_with_flate_through_tools() { assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops)); } +/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner +/// leaves them) of 1000 × 400 samples, stored with Flate. +fn scanned_page() -> Vec { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let (w, h) = (1000usize, 400usize); + let stride = w.div_ceil(8); + let mut data = vec![0xFFu8; stride * h]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for line in (4..h - 20).step_by(24) { + let mut x = 8; + while x + 12 < w { + let wide = 3 + (next() % 5) as usize; + let (top, bottom) = (line + (next() % 4) as usize, line + 14); + for y in top..bottom { + let left = x + usize::from(next() % 8 == 0); + let right = x + wide - usize::from(next() % 8 == 0); + for xx in left..right { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += wide + 2 + (next() % 4) as usize; + } + } + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(d, &data))); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 500 0 0 200 50 500 cm /Scan Do Q".to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// 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] +fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { + let dir = workdir("optimize-mono"); + 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 kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}"); + // The copy written stores the image as Group 4. + let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let filters: Vec> = small + .object_numbers() + .into_iter() + .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.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"CCITTFaxDecode".to_vec()]); + assert!(matches!( + a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": { "compression": "jbig2" } })), + Err(ToolError::InvalidArgs(_)) + )); +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 9d2ae34b94..2ed64eb8bd 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding" version.workspace = true edition.workspace = true license.workspace = true @@ -11,9 +11,11 @@ repository.workspace = true workspace = true [dependencies] +fax.workspace = true flate2.workspace = true thiserror.workspace = true [dev-dependencies] +hayro-ccitt.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 959180d87a..413dff37c1 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -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). +- **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. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -22,6 +22,7 @@ decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 +encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -40,14 +41,16 @@ encode_flate(data) -> Vec // zlib, level 6 | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | 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 | ## Tests - Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows). - 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. - `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 and G4 encode, DCT encode) 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, CCITT G3/G4 decode, DCT encode), CCITT Group 3 encoding and Flate encode levels other than 6. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs new file mode 100644 index 0000000000..3c8018525a --- /dev/null +++ b/crates/filters/src/ccitt.rs @@ -0,0 +1,182 @@ +//! CCITT Group 4 (ITU-T T.6) encoding, for `CCITTFaxDecode` with `/K -1` (ISO 32000-2 §7.4.6). +//! The coding is the `fax` crate's; this module checks what it is given. + +use fax::encoder::Encoder; +use fax::{Color, VecWriter}; + +use crate::FilterError; + +/// The widest row [`encode_g4`] encodes. The encoder counts the positions in a row in 16 bits +/// and adds two of them, so a row stays within half that range. +pub const MAX_G4_COLUMNS: u32 = 32_767; + +/// `rows` rows of `columns` 1-bit samples, most significant bit first, each row starting on a new +/// byte (as a PDF image's samples are packed), encoded as CCITT Group 4 with an end-of-block mark. +/// +/// A 0 bit is coded black and a 1 bit white, so the stream decodes to the same bits with +/// `/BlackIs1 false`, the default: an image keeps its colour space and decode array. 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_G4_COLUMNS`] ([`FilterError::Unsupported`]); +/// fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let width = match u16::try_from(columns) { + Ok(w) if w > 0 && columns <= MAX_G4_COLUMNS => w, + _ => return Err(FilterError::Unsupported(format!("CCITT Group 4 encoding of rows {columns} samples wide"))), + }; + if rows == 0 { + return Err(FilterError::Unsupported("CCITT Group 4 encoding of an image without rows".into())); + } + let stride = usize::from(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: "CCITTFaxDecode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut encoder = Encoder::new(VecWriter::new()); + for row in samples.chunks_exact(stride) { + let pels = (0..usize::from(width)).map(|x| { + let white = row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + if white { Color::White } else { Color::Black } + }); + // `VecWriter` writes to memory: its error type has no values. + if let Err(never) = encoder.encode_line(pels, width) { + match never {} + } + } + match encoder.finish() { + Ok(writer) => Ok(writer.finish()), + Err(never) => match never {}, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. + pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, 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 settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), columns as usize); + hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 + } + + /// The rows of packed samples `data`, as 1 for a 1 bit. + pub(crate) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + 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() + } + + #[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]; + // Short black strokes in lines, like glyphs on a white page. + 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 g4 = encode_g4(&data, w, h).unwrap(); + assert_eq!(decode(&g4, w, h), unpack(&data, w, h)); + assert!(g4.len() * 4 < data.len(), "{} bytes from {}", g4.len(), data.len()); + } + + #[test] + fn padding_bits_and_trailing_bytes_are_ignored() { + // 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]; + let g4 = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode(&g4, 3, 3), [[true, false, true], [false, true, false], [true, true, true]]); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_g4(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&vec![0; 4097], MAX_G4_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_g4(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + let widest = vec![0x55; (MAX_G4_COLUMNS as usize).div_ceil(8) * 2]; + let g4 = encode_g4(&widest, MAX_G4_COLUMNS, 2).unwrap(); + assert_eq!(decode(&g4, MAX_G4_COLUMNS, 2), unpack(&widest, MAX_G4_COLUMNS, 2)); + } + + /// Rows of `columns` samples built from `runs`: alternating white and black runs one longer + /// than those values (so never empty, even when shrinking makes them all 0), repeated 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 { + 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 + } + + 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..400, + 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 g4 = encode_g4(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&g4, columns, rows), 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..40_000, rows in 0u32..80) { + let _ = encode_g4(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 22a78aefdb..b9461435eb 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,8 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. +//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! ([`encode_g4`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -15,6 +16,7 @@ mod ascii85; mod asciihex; +mod ccitt; mod flate; mod lzw; mod predictor; @@ -22,6 +24,8 @@ mod runlength; use std::borrow::Cow; +pub use ccitt::{MAX_G4_COLUMNS, encode_g4}; + /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Filter { diff --git a/crates/optimize/README.md b/crates/optimize/README.md index cbe37f6b7b..6b4d04b071 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -32,6 +32,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left alone, since readers differ on which wins. +- **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 + 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 + than 32,767 samples. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..0a157b8c33 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; +use crate::{Compression, ImageSettings, MonoCompression, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; @@ -532,6 +532,61 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } +/// Whether colour space `cs` has one colour component, so a 1-bit image in it has one bit per +/// pixel: grey, a separation, an indexed space or a DeviceN of one colourant. +fn one_component(doc: &Document, cs: &Object) -> bool { + let cs = doc.resolve(cs); + match &*cs { + Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G" | b"CalGray" | b"Indexed" | b"I"), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, + Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), + Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), + _ => false, + }, + _ => false, + } +} + +/// 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 +/// 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 { + if compression != MonoCompression::CcittG4 { + return None; + } + let d = &s.dict; + let bits = d.int(b"BitsPerComponent"); + let qualifies = if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + bits.is_none_or(|b| b == 1) + } else { + bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs)) + }; + if !qualifies { + 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 { + return None; + } + let g4 = pdfcraft_filters::encode_g4(&s.decoded().ok()?, w, 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); + // 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) +} + pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -558,6 +613,13 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; + if let Some(new) = mono(doc, s, settings.mono) { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + report.images_recompressed += 1; + doc.set(r, Object::Stream(new)); + continue; + } let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue }; let st = if n == 1 { &settings.gray } else { &settings.color }; let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue }; diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..f4f7baf988 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -10,7 +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. +//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4, +//! 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 @@ -118,6 +119,15 @@ pub enum Compression { Retain, } +/// How black-and-white (1-bit) images are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonoCompression { + /// CCITT Group 4 (lossless), when smaller than the image as it is. + CcittG4, + /// Keep each image's own compression. + Retain, +} + /// Acrobat's JPEG quality steps. pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)]; @@ -147,6 +157,8 @@ pub struct Settings { pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, + /// Black-and-white (1-bit) images, and image masks. + pub mono: MonoCompression, pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, @@ -181,6 +193,7 @@ impl Default for Settings { merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, + mono: MonoCompression::CcittG4, discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, @@ -539,7 +552,7 @@ fn strip_ascii_wrapper(s: &Stream) -> Option { /// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer /// sets it, then its data. -fn stored_len(s: &Stream) -> Option { +pub(crate) fn stored_len(s: &Stream) -> Option { let mut dict = s.dict.clone(); dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?)); let mut out = Vec::new(); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3e69e27cb9..8c0afe89e7 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use pdfcraft_filters::{Filter, Params}; use pdfcraft_optimize::{ - Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, + Compatibility, Compression, ImageSettings, MonoCompression, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, }; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { @@ -218,6 +218,189 @@ fn an_identity_decode_array_does_not_stop_reduce() { assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); } +/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like +/// glyphs; or, with `noise`, random bits. +fn bilevel(w: u32, h: u32, noise: bool) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut black = |x: usize, y: usize| { + if x < w as usize && y < h as usize { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + }; + if noise { + for y in 0..h as usize { + for x in 0..w as usize { + if next() & 1 == 0 { + black(x, y); + } + } + } + return data; + } + // Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted + // stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner + // sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4. + for line in (4..h as usize).step_by(24) { + let mut x = 8; + while x + 16 < w as usize { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + // Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's + // edges are. + let mut ragged = || match next() % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged()); + for xx in left..right.saturating_sub(1) { + black(xx, y); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with +/// `entries` added to its dictionary. +fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option, entries: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + let s = match predictor { + None => Stream::flate(d, data), + Some(predictor) => { + let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(predictor)); + parms.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap()) + } + }; + doc.add(Object::Stream(s)) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel. +/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and +/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one wider +/// than Group 4 encodes and a tiny one (bigger as stored) are left alone, as is everything with +/// `mono: Retain`. +#[test] +fn black_and_white_images_become_ccitt_group_4() { + 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())]); + let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let noise = + mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let rgb = mono_image( + &mut doc, + 400, + 160, + &bilevel(1200, 160, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))], + ); + let wide = mono_image( + &mut doc, + 40_000, + 2, + &bilevel(40_000, 2, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))], + ); + // 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer + // dictionary Group 4 needs. + let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap(); + assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len()); + page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny)], + "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 \ + q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let untouched = [noise, rgb, wide, tiny].map(|r| raw(&doc, r)); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: MonoCompression::Retain, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i))); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, 4); + assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after); + for i in [gray, mask, indexed, predicted] { + let s = stream(&doc, i); + assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64))); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays"); + assert_eq!([noise, rgb, wide, tiny].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3e6d735523..4f43912938 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -597,6 +597,8 @@ Clean Up تنظيف Color Images الصور الملونة Grayscale Images الصور الرمادية + Monochrome Images الصور أحادية اللون + CCITT Group 4 CCITT المجموعة 4 Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى for images above للصور التي تتجاوز Compression الضغط diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ec0339f18a..9464683b36 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -259,6 +259,8 @@ Clean Up Почистване Color Images Цветни изображения Grayscale Images Изображения в сива скала + Monochrome Images Монохромни изображения + CCITT Group 4 CCITT група 4 Bicubic downsampling to Бикубично понижаване на резолюцията до for images above за изображения над Compression Компресия diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0cacae3c2c..c7d351ac13 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -560,6 +560,8 @@ Clean Up Neteja Color Images Imatges en color Grayscale Images Imatges en escala de grisos + Monochrome Images Imatges monocromes + CCITT Group 4 CCITT grup 4 Bicubic downsampling to Reducció bicúbica de resolució a for images above per a imatges de més de Compression Compressió diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 71c0c62869..c5fa4b6967 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -567,6 +567,8 @@ Clean Up پاککردنەوە Color Images وێنە ڕەنگاوڕەنگەکان Grayscale Images وێنە خۆڵەمێشییەکان + Monochrome Images وێنە یەکڕەنگەکان + CCITT Group 4 CCITT گرووپی 4 Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ for images above بۆ ئەو وێنانەی سەرووی Compression پەستاندن diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ed6d051b57..342f762378 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -681,6 +681,8 @@ key Space Mezerník Clean Up Vyčištění Color Images Barevné obrázky Grayscale Images Obrázky ve stupních šedi + Monochrome Images Monochromatické obrázky + CCITT Group 4 CCITT skupina 4 Bicubic downsampling to Bikubické převzorkování na for images above u obrázků nad Compression Komprese diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..71163b6b96 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -570,6 +570,8 @@ Clean Up Aufräumen Color Images Farbbilder Grayscale Images Graustufenbilder + Monochrome Images Monochrome Bilder + CCITT Group 4 CCITT Gruppe 4 Bicubic downsampling to Bikubische Neuberechnung auf for images above für Bilder über Compression Komprimierung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e3eac77caa..13f762d6ca 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1906,6 +1906,8 @@ Clean Up Εκκαθάριση Color Images Έγχρωμες εικόνες Grayscale Images Εικόνες σε κλίμακα του γκρι + Monochrome Images Μονόχρωμες εικόνες + CCITT Group 4 CCITT Ομάδα 4 Bicubic downsampling to Δικυβική μείωση ανάλυσης σε for images above για εικόνες πάνω από ZIP ZIP diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0d4e505a70..0457264377 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -593,6 +593,8 @@ Clean Up Limpieza Color Images Imágenes en color Grayscale Images Imágenes en escala de grises + Monochrome Images Imágenes monocromas + CCITT Group 4 CCITT grupo 4 Bicubic downsampling to Reducción de resolución bicúbica a for images above para imágenes por encima de Compression Compresión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a542ee9b91..5bd26bff11 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -560,6 +560,8 @@ Clean Up Siivoa Color Images Värikuvat Grayscale Images Harmaasävykuvat + Monochrome Images Mustavalkokuvat + CCITT Group 4 CCITT ryhmä 4 Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen for images above kuville, joiden tarkkuus yli Compression Pakkaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61c8b5171d..b5434915af 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -570,6 +570,8 @@ Clean Up Nettoyer Color Images Images couleur Grayscale Images Images en niveaux de gris + Monochrome Images Images monochromes + CCITT Group 4 CCITT groupe 4 Bicubic downsampling to Sous-échantillonnage bicubique à for images above pour les images au-dessus de Compression Compression diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e9d627aca9..30aa915af7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -544,6 +544,8 @@ Clean Up सफ़ाई Color Images रंगीन छवियाँ Grayscale Images ग्रेस्केल छवियाँ + Monochrome Images मोनोक्रोम छवियाँ + CCITT Group 4 CCITT समूह 4 Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक for images above इससे बड़ी छवियों के लिए Compression संपीड़न diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6b37b9169b..b7e494ab0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -568,6 +568,8 @@ Clean Up Tisztítás Color Images Színes képek Grayscale Images Szürkeárnyalatos képek + Monochrome Images Monokróm képek + CCITT Group 4 CCITT 4. csoport 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 292b336e10..c87527fee3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2055,6 +2055,8 @@ comment menu Edit Edit Formulas and similar non-text elements need alternate text. Add alternate text to them. Rumus dan elemen non-teks serupa memerlukan teks alternatif. Tambahkan teks alternatif ke elemen tersebut. Gray Abu-abu Grayscale Images Gambar Skala Abu-abu + Monochrome Images Gambar Monokrom + CCITT Group 4 CCITT Grup 4 Green Hijau Green on black Hijau di atas hitam H / Space (hold) H / Space (tahan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 20729bae6a..62b012f091 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -594,6 +594,8 @@ Clean Up Pulizia Color Images Immagini a colori Grayscale Images Immagini in scala di grigi + Monochrome Images Immagini monocromatiche + CCITT Group 4 CCITT gruppo 4 Bicubic downsampling to Sottocampionamento bicubico a for images above per immagini oltre Compression Compressione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ed4d43b6c..beb3002eb6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -597,6 +597,8 @@ Clean Up クリーンアップ Color Images カラー画像 Grayscale Images グレースケール画像 + Monochrome Images 白黒画像 + CCITT Group 4 CCITT グループ 4 Bicubic downsampling to バイキュービック法でダウンサンプル: for images above 対象画像の解像度: Compression 圧縮 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9022c3591c..08a390dade 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -557,6 +557,8 @@ Clean Up Тазалау Color Images Түсті кескіндер Grayscale Images Сұр реңкті кескіндер + Monochrome Images Монохромды кескіндер + CCITT Group 4 CCITT 4-топ Bicubic downsampling to Бикубтық төмендету for images above мұнан асатын кескіндер үшін Compression Сығымдау diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 09b9cad9fa..1b6dcc99ee 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -544,6 +544,8 @@ Clean Up 정리 Color Images 컬러 이미지 Grayscale Images 회색조 이미지 + Monochrome Images 흑백 이미지 + CCITT Group 4 CCITT 그룹 4 Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링 for images above 다음 값을 초과하는 이미지에 적용 Compression 압축 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 964467d823..168fccb36e 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -565,6 +565,8 @@ Clean Up ອະນາໄມ Color Images ຮູບພາບສີ Grayscale Images ຮູບພາບລະດັບສີເທົາ + Monochrome Images ຮູບພາບຂາວດຳ + CCITT Group 4 CCITT ກຸ່ມ 4 Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ: for images above ສຳລັບຮູບພາບທີ່ເກີນ: Compression ການບີບອັດ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 43749f4ec8..33ffe03418 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2123,6 +2123,8 @@ comment menu Edit Redaguoti Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulėms ir panašiems netekstiniams elementams reikia alternatyviojo teksto. Pridėkite jį. Gray Pilka Grayscale Images Pilkų atspalvių paveikslėliai + Monochrome Images Vienspalviai paveikslėliai + CCITT Group 4 CCITT 4 grupė Green Žalia Green on black Žalia juodame fone H / Space (hold) H / tarpo klavišas (laikyti) diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 6f1739974a..f3f37a1afa 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2060,6 +2060,8 @@ Formulas and similar non-text elements need alternate text. Add alternate text to them. Formler og lignende elementer som ikke er tekst, trenger alternativ tekst. Legg til alternativ tekst på dem. Gray Grå Grayscale Images Gråtonebilder + Monochrome Images Svart-hvitt-bilder + CCITT Group 4 CCITT gruppe 4 Green Grønn Green on black Grønn på svart H / Space (hold) H / Mellomrom (hold) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 963d717584..0dd3604189 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -592,6 +592,8 @@ Clean Up Porządkowanie Color Images Obrazy kolorowe Grayscale Images Obrazy w skali szarości + Monochrome Images Obrazy monochromatyczne + CCITT Group 4 CCITT grupa 4 Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do for images above dla obrazów powyżej Compression Kompresja diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c34ae83bc5..6ab4ec9d85 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -216,6 +216,8 @@ Clean Up Limpar Color Images Imagens coloridas Grayscale Images Imagens em escala de cinza + Monochrome Images Imagens monocromáticas + CCITT Group 4 CCITT grupo 4 Bicubic downsampling to Redução bicúbica de amostragem para for images above para imagens acima de Compression Compactação diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 78d2298b93..0b0050a4f1 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2066,6 +2066,8 @@ comment menu Edit Editare Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulele și alte elemente non-text similare au nevoie de text alternativ. Adăugați-le text alternativ. Gray Gri Grayscale Images Imagini în tonuri de gri + Monochrome Images Imagini monocrome + CCITT Group 4 CCITT grupa 4 Green Verde Green on black Verde pe negru H / Space (hold) H / Spațiu (ținut apăsat) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 274232d586..f9a17709ba 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -590,6 +590,8 @@ Clean Up Очистить Color Images Цветные изображения Grayscale Images Изображения в оттенках серого + Monochrome Images Монохромные изображения + CCITT Group 4 CCITT группа 4 Bicubic downsampling to Бикубическое уменьшение до for images above для изображений выше Compression Сжатие diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 420e6a5996..83790b055e 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -891,6 +891,8 @@ Grab bag Razno Gray Siva Grayscale Images Sivinske slike + Monochrome Images Enobarvne slike + CCITT Group 4 CCITT skupina 4 Green Zelena H / Space (hold) H / preslednica (drži) HTML web page Spletna stran HTML diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ba7c9dc5b3..cc899a668a 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -880,6 +880,8 @@ Grab bag Të ndryshme Gray Gri Grayscale Images Imazhe në nuanca gri + Monochrome Images Imazhe bardhezi + CCITT Group 4 CCITT grupi 4 Green Jeshile H / Space (hold) H / Hapësira (mbaje shtypur) HTML web page Faqe interneti HTML diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7f339989ee..43a2782674 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -574,6 +574,8 @@ comment menu Edit సవరించు Clean Up చక్కదిద్దడం Color Images రంగు చిత్రాలు Grayscale Images గ్రేస్కేల్ చిత్రాలు + Monochrome Images మోనోక్రోమ్ చిత్రాలు + CCITT Group 4 CCITT గ్రూప్ 4 Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం: for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు: Compression కుదింపు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 2c9b892ed7..4eeeb6f951 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -554,6 +554,8 @@ Clean Up ล้างข้อมูล Color Images รูปภาพสี Grayscale Images รูปภาพระดับสีเทา + Monochrome Images รูปภาพขาวดำ + CCITT Group 4 CCITT กลุ่ม 4 Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น for images above สำหรับรูปภาพที่สูงกว่า Compression การบีบอัด diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2ecaea179..d5124513cf 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -555,6 +555,8 @@ Clean Up Temizle Color Images Renkli Resimler Grayscale Images Gri Tonlamalı Resimler + Monochrome Images Tek Renkli Resimler + CCITT Group 4 CCITT Grup 4 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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c76868839..8051e42f82 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -595,6 +595,8 @@ Clean Up Очищення Color Images Кольорові зображення Grayscale Images Зображення у відтінках сірого + Monochrome Images Монохромні зображення + CCITT Group 4 CCITT група 4 Bicubic downsampling to Бікубічне зниження роздільної здатності до for images above для зображень із роздільною здатністю понад Compression Стиснення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 973ba870ea..8703b4c8ee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2111,6 +2111,8 @@ comment menu Edit Sửa Formulas and similar non-text elements need alternate text. Add alternate text to them. Công thức và các phần tử không phải văn bản tương tự cần có văn bản thay thế. Hãy thêm văn bản thay thế cho chúng. Gray Xám Grayscale Images Ảnh thang xám + Monochrome Images Ảnh đơn sắc + CCITT Group 4 CCITT nhóm 4 Green Xanh lá Green on black Xanh lá trên nền đen H / Space (hold) H / Space (giữ) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d77c8f6425..da2722c7fa 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -265,6 +265,8 @@ Clean Up 清理 Color Images 彩色图像 Grayscale Images 灰度图像 + Monochrome Images 单色图像 + CCITT Group 4 CCITT 第 4 组 Bicubic downsampling to 双三次降采样至 for images above (高于此分辨率的图像) Compression 压缩 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce3b939f1d..8f42d9da3b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -221,6 +221,8 @@ Clean Up 清理 Color Images 彩色圖片 Grayscale Images 灰階圖片 + Monochrome Images 單色圖片 + CCITT Group 4 CCITT 第 4 組 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 3bb80ac439..d3a5cfd4a1 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align, Layout, RichText}; -use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings}; +use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -143,6 +143,23 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { ui.add_space(8.0); } +/// Monochrome Images: how 1-bit images and image masks are stored. +fn mono_row(ui: &mut egui::Ui, m: &mut MonoCompression) { + ui.label(egui::RichText::new(tl!("Monochrome Images")).font(theme::semibold(13.0))); + ui.horizontal(|ui| { + ui.label(tl!("Compression")); + let label = match m { + MonoCompression::CcittG4 => tl!("CCITT Group 4"), + 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::Retain, tl!("Retain existing")); + }); + }); + ui.add_space(8.0); +} + fn discard_box(ui: &mut egui::Ui, list: &mut Vec, h: Hidden, label: &str) { let mut on = list.contains(&h); if ui.add(widgets::checkbox(&mut on, tl!(label))).changed() { @@ -205,6 +222,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.merge_fragments, tl!("Merge image fragments"))); image_row(ui, "color", "Color Images", &mut s.color); image_row(ui, "gray", "Grayscale Images", &mut s.gray); + mono_row(ui, &mut s.mono); ui.label( egui::RichText::new(tl!("Each image is measured where pages draw it; an image is replaced only if the result is smaller.")) .small() diff --git a/crates/ui-egui/tests/it/create.rs b/crates/ui-egui/tests/it/create.rs index a1f368922d..22ac8503e7 100644 --- a/crates/ui-egui/tests/it/create.rs +++ b/crates/ui-egui/tests/it/create.rs @@ -187,6 +187,8 @@ fn the_pdf_optimizer_dialog_saves_an_optimized_copy() { h.run_steps(2); h.get_by_label("PDF Optimizer"); h.get_by_label("Color Images"); + h.get_by_label("Monochrome Images"); + assert_eq!(h.state().optimize_draft.settings.mono, pdfcraft_engine::optimize::MonoCompression::CcittG4); h.get_by_label("Discard Objects").click(); h.run_steps(2); h.get_by_label("Discard document tags").click(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..9a2a42dacc 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-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) @@ -255,6 +256,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..daffc88188 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7325,7 +7325,7 @@ status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images to CCITT Group 4 when that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -7381,11 +7381,11 @@ title = "Monochrome compression (JBIG2, CCITT G4)" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +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." [[feature]] id = "optimize.only-if-smaller"