mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 06:51:50 -06: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:
co-authored by
Claude Opus 5.5
parent
1b7164cab1
commit
5c4cadce59
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user