M11: lossless JBIG2 for black-and-white images

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z
This commit is contained in:
Grzegorz Martens
2026-10-29 20:47:45 +01:00
co-authored by Claude Opus 5.5
parent 1b7164cab1
commit 5c4cadce59
51 changed files with 720 additions and 30 deletions
+41 -14
View File
@@ -562,15 +562,17 @@ fn one_component(doc: &Document, cs: &Object) -> bool {
}
}
/// A black-and-white image (one bit per pixel, or an image mask) stored again as CCITT Group 4,
/// if `compression` asks for that and the result is smaller as stored. Only images whose samples
/// A black-and-white image (one bit per pixel, or an image mask) stored again as `compression`
/// asks (CCITT Group 4 or JBIG2), if the result is smaller as stored. Only images whose samples
/// PdfCraft decodes (no filter, or general-purpose filters: image codecs don't decode here)
/// qualify. The samples decode to the same bits, so the colour space, decode array and masks stay
/// as they are.
fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stream> {
if compression != MonoCompression::CcittG4 {
return None;
}
let widest = match compression {
MonoCompression::CcittG4 => pdfcraft_filters::MAX_G4_COLUMNS,
MonoCompression::Jbig2 => pdfcraft_filters::MAX_JBIG2_COLUMNS,
MonoCompression::Retain => return None,
};
let d = &s.dict;
let bits = d.int(b"BitsPerComponent");
let qualifies = if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
@@ -582,24 +584,31 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option<Stre
return None;
}
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
if w == 0 || h == 0 || w > pdfcraft_filters::MAX_G4_COLUMNS || u64::from(w) * u64::from(h) > MAX_PIXELS {
if w == 0 || h == 0 || w > widest || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
// Through its parameters given by reference too, as `pixels` reads, and no further than its
// rows can need.
let bytes = usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?)?;
let g4 = pdfcraft_filters::encode_g4(&doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?, w, h).ok()?;
let samples = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?;
let mut new = d.clone();
for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
new.remove(k);
}
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(h)));
new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
new.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let new = Stream::from_raw(new, g4);
let data = if compression == MonoCompression::Jbig2 {
// One embedded JBIG2 stream with its own page; no /JBIG2Globals.
new.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
pdfcraft_filters::encode_jbig2_generic(&samples, w, h).ok()?
} else {
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(h)));
new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
new.set(b"DecodeParms".to_vec(), Object::Dict(parms));
pdfcraft_filters::encode_g4(&samples, w, h).ok()?
};
let new = Stream::from_raw(new, data);
// Smaller as stored, counting the larger dictionary: a tiny image can lose by it.
(crate::stored_len(&new)? < crate::stored_len(s)?).then_some(new)
}
@@ -623,6 +632,7 @@ pub(crate) fn run(
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
refs.sort_by_key(|(r, _)| r.num);
let total = refs.len();
let mut jbig2 = false;
for (done, (r, ppi)) in refs.into_iter().enumerate() {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
@@ -634,6 +644,7 @@ pub(crate) fn run(
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
report.images_recompressed += 1;
jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode");
doc.set(r, Object::Stream(new));
continue;
}
@@ -656,6 +667,22 @@ pub(crate) fn run(
if !progress(Stage::Images { done: total, total }) {
return Err(OptimizeError::Cancelled);
}
if jbig2 {
at_least_pdf_1_4(doc)?;
}
Ok(())
}
/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4
/// or more already, gets `/Version /1.4` in its catalog (ISO 32000-2 §7.7.2). A full save with
/// object streams writes a 1.5 header anyway.
fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> {
let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..];
let Some(root) = doc.root() else { return Ok(()) };
let catalog = doc.get(root).as_dict().and_then(|c| c.name(b"Version").map(<[u8]>::to_vec));
if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?;
}
Ok(())
}
+5 -2
View File
@@ -10,8 +10,8 @@
//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode
//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default,
//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform`
//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4,
//! losslessly, when that is smaller.
//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4
//! or JBIG2, losslessly, when that is smaller.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
//! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted
@@ -124,6 +124,9 @@ pub enum Compression {
pub enum MonoCompression {
/// CCITT Group 4 (lossless), when smaller than the image as it is.
CcittG4,
/// JBIG2, lossless (a generic region), when smaller than the image as it is. JBIG2Decode is
/// PDF 1.4: an older document gets `/Version /1.4` in its catalog.
Jbig2,
/// Keep each image's own compression.
Retain,
}