mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
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:
1 parent
1b7164cab1
commit
5c4cadce59
51 files changed
+720
-30
No files matched your search
Generated
+2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-ccitt.workspace = true
|
||||
hayro-jbig2.workspace = true
|
||||
proptest.workspace = true
|
||||
weezl.workspace = true
|
||||
@@ -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.
|
||||
@@ -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(®ion);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -546,6 +546,7 @@
|
||||
Grayscale Images ग्रेस्केल छवियाँ
|
||||
Monochrome Images मोनोक्रोम छवियाँ
|
||||
CCITT Group 4 CCITT समूह 4
|
||||
JBIG2 (lossless) JBIG2 (हानिरहित)
|
||||
Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक
|
||||
for images above इससे बड़ी छवियों के लिए
|
||||
Compression संपीड़न
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -559,6 +559,7 @@
|
||||
Grayscale Images Сұр реңкті кескіндер
|
||||
Monochrome Images Монохромды кескіндер
|
||||
CCITT Group 4 CCITT 4-топ
|
||||
JBIG2 (lossless) JBIG2 (шығынсыз)
|
||||
Bicubic downsampling to Бикубтық төмендету
|
||||
for images above мұнан асатын кескіндер үшін
|
||||
Compression Сығымдау
|
||||
|
||||
|
@@ -546,6 +546,7 @@
|
||||
Grayscale Images 회색조 이미지
|
||||
Monochrome Images 흑백 이미지
|
||||
CCITT Group 4 CCITT 그룹 4
|
||||
JBIG2 (lossless) JBIG2(무손실)
|
||||
Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링
|
||||
for images above 다음 값을 초과하는 이미지에 적용
|
||||
Compression 압축
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -556,6 +556,7 @@
|
||||
Grayscale Images รูปภาพระดับสีเทา
|
||||
Monochrome Images รูปภาพขาวดำ
|
||||
CCITT Group 4 CCITT กลุ่ม 4
|
||||
JBIG2 (lossless) JBIG2 (ไม่สูญเสียข้อมูล)
|
||||
Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น
|
||||
for images above สำหรับรูปภาพที่สูงกว่า
|
||||
Compression การบีบอัด
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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,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. |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user