mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 23:32:35 +00:00
Merge pull request #6935 from Piohrun/feat/optimize-mono-past-80-megapixels
M11: Optimize stores one-bit images and masks past 80 million pixels as monochrome images
This commit is contained in:
@@ -34,9 +34,13 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
|
||||
stencil `/Mask` has its own resolution, so the picture changes under it and it keeps its size and
|
||||
its bits (stored again as a picture's mask, below).
|
||||
An image is replaced only if the result is smaller. Images over 80 million pixels are left
|
||||
alone by the image steps, here and below, whatever their settings (memory); Clean Up may still
|
||||
Flate an unfiltered or LZW one. Left alone by these colour and grey
|
||||
An image is replaced only if the result is smaller. Images over 80 million pixels are left alone
|
||||
by the image steps, here and below, whatever their settings (memory), except that the monochrome
|
||||
step below takes an image already of one bit a pixel, a mask included, while its rows hold no more
|
||||
bytes than 80 million pixels of eight bits (each row starts on a new byte: a byte for each 8
|
||||
samples and one for any left over): up to 640 million pixels when it is wide, and a strip one
|
||||
sample wide no taller than one of eight bits. Clean Up may still Flate an unfiltered or LZW one.
|
||||
Left alone by these colour and grey
|
||||
settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000,
|
||||
JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with
|
||||
its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one
|
||||
@@ -119,8 +123,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
more bits stays as it is, to be resampled with its picture, which wants it at eight. The report
|
||||
counts masks as images.
|
||||
- Not yet: inline images, a picture's mask resampled, or packed to one bit from more, Group 4
|
||||
for rows wider than 32,767 samples, and JBIG2 past 65,535 samples on a side, where the decoder
|
||||
stops (its 2^28 in all is past the 80 million pixels above).
|
||||
for rows wider than 32,767 samples, and JBIG2 past 65,535 samples on a side or 2^28 in all,
|
||||
where the decoder stops: with JBIG2 chosen, a one-bit image larger than that stays unless
|
||||
it is averaged.
|
||||
|
||||
- Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when
|
||||
smaller, a dictionary shared by up to 16 images when that is smaller still. No lossy symbol substitution is used. Export, redaction and this step read JBIG2
|
||||
|
||||
@@ -13,6 +13,15 @@ use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeE
|
||||
/// Images with more pixels than this are left alone (memory).
|
||||
pub(crate) const MAX_PIXELS: u64 = 80_000_000;
|
||||
|
||||
/// The bytes an image of one bit a pixel holds as stored: each row starts on a new byte, a byte for
|
||||
/// each 8 samples and one for any left over. The monochrome step takes such an image while these
|
||||
/// are no more than [`MAX_PIXELS`], as many as that many pixels of eight bits hold: a wide one up
|
||||
/// to 640 million pixels (an A0 page at 600 ppi is about 560 million), where at eight bits a pixel
|
||||
/// it would be left alone, and a strip one sample wide no taller than one of eight bits.
|
||||
fn one_bit_bytes(w: u64, h: u64) -> u64 {
|
||||
w.div_ceil(8).saturating_mul(h)
|
||||
}
|
||||
|
||||
/// The resources of a page, inherited from the page tree if the page has none.
|
||||
pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
|
||||
held_page_resources(doc, page).map(|(resources, _)| resources)
|
||||
@@ -829,6 +838,21 @@ fn one_component(doc: &Document, cs: &Object) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the filters before a fax image's decoder may produce, its code: at most about a byte a
|
||||
/// pixel and each row's end, as redaction allows, but no more than `MAX_PIXELS` bytes for its
|
||||
/// pixels (and four a row), so a large page's code can't cost a byte a pixel.
|
||||
fn fax_code_allowance(w: usize, h: usize) -> Option<usize> {
|
||||
w.checked_mul(h)?.min(usize::try_from(MAX_PIXELS).ok()?).checked_add(h.checked_mul(4)?)?.checked_add(4096)
|
||||
}
|
||||
|
||||
/// Whether `d` is an image of one bit a pixel as stored: an image mask, or one bit in one colour
|
||||
/// component.
|
||||
fn one_bit_as_stored(doc: &Document, d: &Dict) -> bool {
|
||||
let bits = d.int(b"BitsPerComponent");
|
||||
let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true)));
|
||||
(mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs)))
|
||||
}
|
||||
|
||||
/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither.
|
||||
type Pixel = fn(&[u8]) -> Option<bool>;
|
||||
|
||||
@@ -854,19 +878,23 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut
|
||||
let d = &s.dict;
|
||||
// Through u32 first, as everywhere here, so the product can't overflow.
|
||||
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
// An image already of one bit a pixel is taken while its rows hold no more than `MAX_PIXELS`
|
||||
// bytes (`one_bit_bytes`), one packed from more bits (2, 4 or 8) up to `MAX_PIXELS` pixels, as
|
||||
// before: its samples are decoded as stored first, eight-bit RGB at three bytes a pixel.
|
||||
let as_stored = one_bit_as_stored(doc, d);
|
||||
let held = if as_stored { one_bit_bytes(u64::from(w), u64::from(h)) } else { u64::from(w) * u64::from(h) };
|
||||
if w == 0 || h == 0 || held > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
|
||||
let stride = wu.div_ceil(8);
|
||||
let bits = d.int(b"BitsPerComponent");
|
||||
let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true)));
|
||||
if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) {
|
||||
// No further than its rows can need: a fax image's code (the filters before its decoder
|
||||
// produce it) takes at most about a byte a pixel and each row's end, as redaction allows.
|
||||
if as_stored {
|
||||
// No further than its rows can need.
|
||||
let len = stride.checked_mul(hu)?;
|
||||
let max = match s.filters().last() {
|
||||
Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?,
|
||||
Some((pdfcraft_filters::Filter::CcittFax, _)) => fax_code_allowance(wu, hu)?,
|
||||
// A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit.
|
||||
_ => allowance(len, h)?,
|
||||
};
|
||||
@@ -937,6 +965,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut
|
||||
_ => return None,
|
||||
};
|
||||
let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8);
|
||||
count_decoded(u64::from(w) * u64::from(h));
|
||||
// Through its parameters given by reference too: without a PNG predictor's tag bytes taken
|
||||
// off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted.
|
||||
let samples = budget.decode_filters(doc, s, allowance(row_len.checked_mul(hu)?, h)?).ok()?;
|
||||
@@ -1605,12 +1634,21 @@ const PICTURE_MEMORY: u64 = 1 << 30;
|
||||
/// What a picture holds while it is worked out, for each pixel: decoded, and resampled or encoded.
|
||||
const PICTURE_BYTES_PER_PIXEL: u64 = 8;
|
||||
|
||||
/// A picture's pixels, if it is small enough to be decoded at all.
|
||||
/// A picture's pixels, if it is small enough to be decoded at all. One of one bit a pixel as
|
||||
/// stored is taken while the bytes its rows hold ([`one_bit_bytes`]) are no more than
|
||||
/// `MAX_PIXELS`, and counts as those and one more a row: decoding allows each row's end a few
|
||||
/// bytes besides its samples ([`allowance`], [`fax_code_allowance`]), which for an image a few
|
||||
/// samples wide is as much again as its samples.
|
||||
fn picture_pixels(doc: &Document, r: ObjRef) -> Option<u64> {
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { return None };
|
||||
let (w, h) = (u64::try_from(s.dict.int(b"Width")?).ok()?, u64::try_from(s.dict.int(b"Height")?).ok()?);
|
||||
w.checked_mul(h).filter(|pixels| *pixels <= MAX_PIXELS)
|
||||
if one_bit_as_stored(doc, &s.dict) {
|
||||
let bytes = one_bit_bytes(w, h);
|
||||
return (bytes <= MAX_PIXELS).then(|| bytes.saturating_add(h));
|
||||
}
|
||||
let pixels = w.checked_mul(h)?;
|
||||
(pixels <= MAX_PIXELS).then_some(pixels)
|
||||
}
|
||||
|
||||
/// How many pictures of `largest` pixels to work on at once: `workers`, or as many as
|
||||
@@ -1861,6 +1899,83 @@ mod dimension_tests {
|
||||
assert_eq!(threads_for(8, 0), 8);
|
||||
}
|
||||
|
||||
/// A one-bit image counts as the bytes its rows hold and one more a row when planning how many
|
||||
/// pictures run at once, while those it holds are up to `MAX_PIXELS`: about an eighth of its
|
||||
/// pixels when wide, two bytes a row when a few samples wide. One of eight bits counts whole,
|
||||
/// up to `MAX_PIXELS`.
|
||||
#[test]
|
||||
fn a_one_bit_picture_counts_as_the_bytes_of_its_rows() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut add = |w: i64, h: i64, bits: i64| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(w));
|
||||
dict.set(b"Height".to_vec(), Object::Int(h));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(bits));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
doc.add(Object::Stream(Stream::from_raw(dict, Vec::new())))
|
||||
};
|
||||
let (one_bit, past_one_bit, eight_bit) = (add(20_000, 32_000, 1), add(20_000, 32_001, 1), add(10_000, 8_001, 8));
|
||||
let (strip, past_strip, narrow) = (add(1, 80_000_000, 1), add(1, 80_000_001, 1), add(8, 10_000_000, 1));
|
||||
assert_eq!(picture_pixels(&doc, one_bit), Some(80_000_000 + 32_000));
|
||||
assert_eq!(picture_pixels(&doc, past_one_bit), None);
|
||||
assert_eq!(picture_pixels(&doc, strip), Some(160_000_000), "a byte a row, and one more");
|
||||
assert_eq!(picture_pixels(&doc, past_strip), None);
|
||||
assert_eq!(picture_pixels(&doc, narrow), Some(20_000_000));
|
||||
assert_eq!(threads_for(8, 20_000_000), 6);
|
||||
assert_eq!(threads_for(8, 160_000_000), 1);
|
||||
assert_eq!(picture_pixels(&doc, eight_bit), None);
|
||||
assert_eq!(threads_for(8, 80_000_000), 1);
|
||||
}
|
||||
|
||||
/// A fax image's code may take about a byte a pixel and its rows' ends, but no more than
|
||||
/// `MAX_PIXELS` bytes for its pixels however large the page, and four a row.
|
||||
#[test]
|
||||
fn a_fax_images_code_is_allowed_80_million_bytes_and_four_a_row() {
|
||||
assert_eq!(fax_code_allowance(1_000, 1_000), Some(1_000_000 + 4_000 + 4_096));
|
||||
assert_eq!(fax_code_allowance(20_000, 32_000), Some(80_000_000 + 128_000 + 4_096));
|
||||
assert_eq!(fax_code_allowance(1, 80_000_000), Some(80_000_000 + 320_000_000 + 4_096));
|
||||
}
|
||||
|
||||
/// The monochrome step decodes an image already of one bit a pixel while its rows hold no more
|
||||
/// than `MAX_PIXELS` bytes, and one it would pack from eight bits up to `MAX_PIXELS` pixels.
|
||||
/// Past those, nothing is decoded.
|
||||
#[test]
|
||||
fn one_bit_images_are_decoded_up_to_their_own_limit() {
|
||||
let image = |w: i64, h: i64, bits: i64| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(w));
|
||||
dict.set(b"Height".to_vec(), Object::Int(h));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(bits));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
Stream::from_raw(dict, Vec::new())
|
||||
};
|
||||
let decoded = |s: &Stream| {
|
||||
DECODED_PIXELS.with(|c| c.set(0));
|
||||
one_bit(&Document::new_empty(), s, true, false, &mut ImageBudget::with_work(u64::MAX));
|
||||
DECODED_PIXELS.with(|c| c.get())
|
||||
};
|
||||
assert_eq!(decoded(&image(20_000, 32_000, 1)), 640_000_000, "one bit, at the limit");
|
||||
assert_eq!(decoded(&image(20_000, 32_001, 1)), 0, "one bit, past it");
|
||||
assert_eq!(decoded(&image(1, 80_000_000, 1)), 80_000_000, "one bit a sample wide, a byte a row, at the limit");
|
||||
assert_eq!(decoded(&image(1, 80_000_001, 1)), 0, "one bit a sample wide, past it");
|
||||
assert_eq!(decoded(&image(10_000, 8_000, 8)), 80_000_000, "eight bits, at theirs");
|
||||
assert_eq!(decoded(&image(10_000, 8_001, 8)), 0, "eight bits, past theirs");
|
||||
}
|
||||
|
||||
/// A zero-wide image isn't decoded: its rows hold no bytes, but its filters would be allowed
|
||||
/// three a row.
|
||||
#[test]
|
||||
fn a_zero_wide_image_is_not_decoded() {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(0));
|
||||
dict.set(b"Height".to_vec(), Object::Int(1_000_000));
|
||||
dict.set(b"ImageMask".to_vec(), Object::Bool(true));
|
||||
let s = Stream::flate(dict, &vec![0u8; 1 << 20]);
|
||||
let mut budget = ImageBudget::with_work(1 << 40);
|
||||
assert!(one_bit(&Document::new_empty(), &s, true, false, &mut budget).is_none());
|
||||
assert_eq!(budget.work_left(), 1 << 40);
|
||||
}
|
||||
|
||||
/// Only what redaction can store again without the dictionary shares one: an image mask, or
|
||||
/// DeviceGray named directly, of at most 2^26 pixels.
|
||||
#[test]
|
||||
|
||||
@@ -423,8 +423,10 @@ fn black_and_white_images_become_ccitt_group_4() {
|
||||
/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one
|
||||
/// component and a DeviceN of one colourant among them, but not one of three components or two
|
||||
/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or
|
||||
/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more.
|
||||
/// The byte totals count exactly the images stored again.
|
||||
/// by default, but not one that says 8; and images past 80 million pixels too, where an image of
|
||||
/// eight bits would be left alone (`one_bit_images_are_decoded_up_to_their_own_limit` checks the
|
||||
/// bytes of rows a one-bit image may hold without decoding so much). The byte totals count exactly
|
||||
/// the images stored again.
|
||||
#[test]
|
||||
fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -457,7 +459,7 @@ fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() {
|
||||
let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())];
|
||||
let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1));
|
||||
let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8));
|
||||
// 10,000 × 8,000 is the limit itself.
|
||||
// 10,000 × 8,000 is the limit of an image of eight bits; one of one bit goes past it.
|
||||
let grey = one_bit(Object::name("DeviceGray"));
|
||||
let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey);
|
||||
let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey);
|
||||
@@ -474,8 +476,8 @@ fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() {
|
||||
})
|
||||
.collect();
|
||||
page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::<Vec<_>>(), &content);
|
||||
let converted = [icc_1, spot, mask_1, at_limit];
|
||||
let left = [icc_3, two_spots, mask_8, past_limit];
|
||||
let converted = [icc_1, spot, mask_1, at_limit, past_limit];
|
||||
let left = [icc_3, two_spots, mask_8];
|
||||
let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::<Vec<_>>();
|
||||
let (old, untouched) = (before(&doc, &converted), before(&doc, &left));
|
||||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
@@ -4925,15 +4927,22 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An image past what the JBIG2 decoder takes on a side stays as it is: JBIG2 can state the size,
|
||||
/// but PdfCraft's decoder refuses such a stream, so stored as JBIG2 it drew nothing here. At 65,535
|
||||
/// on a side, the same bars do become JBIG2, so the fixture does reach that choice, and the coding
|
||||
/// stored (symbols, here) reads back through that decoder.
|
||||
/// An image past what the JBIG2 decoder takes on a side, or past 2^28 pixels in all, stays as it
|
||||
/// is: JBIG2 can state the size, but PdfCraft's decoder refuses such a stream, so stored as JBIG2 it
|
||||
/// drew nothing here. At 65,535 on a side, and at 2^28 in all (past 80 million, which the
|
||||
/// monochrome step takes of a one-bit image), the same bars do become JBIG2, so the fixture does
|
||||
/// reach that choice, and the coding stored reads back through that decoder. Averaged, an image past
|
||||
/// 2^28 becomes small enough and is stored as JBIG2 too.
|
||||
#[test]
|
||||
fn an_image_too_large_for_the_jbig2_decoder_stays_as_it_is() {
|
||||
for (w, h, stored) in
|
||||
[(65_535u32, 16u32, &b"JBIG2Decode"[..]), (16, 65_535, b"JBIG2Decode"), (65_536, 16, b"FlateDecode"), (16, 65_536, b"FlateDecode")]
|
||||
{
|
||||
for (w, h, stored) in [
|
||||
(65_535u32, 16u32, &b"JBIG2Decode"[..]),
|
||||
(16, 65_535, b"JBIG2Decode"),
|
||||
(65_536, 16, b"FlateDecode"),
|
||||
(16, 65_536, b"FlateDecode"),
|
||||
(16_384, 16_384, b"JBIG2Decode"),
|
||||
(16_384, 16_385, b"FlateDecode"),
|
||||
] {
|
||||
let mut doc = Document::new_empty();
|
||||
let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||||
page(&mut doc, &[("Im", image)], &format!("q {w} 0 0 {h} 0 0 cm /Im Do Q"));
|
||||
@@ -4958,6 +4967,15 @@ fn an_image_too_large_for_the_jbig2_decoder_stays_as_it_is() {
|
||||
assert!(decoded_image(&doc, &s).unwrap() == bits, "{w} × {h} reads back");
|
||||
}
|
||||
}
|
||||
let (w, h) = (16_384u32, 16_385u32);
|
||||
let mut doc = Document::new_empty();
|
||||
let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||||
let (pw, ph) = (f64::from(w) * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0);
|
||||
page(&mut doc, &[("Im", image)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q"));
|
||||
let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
|
||||
let r = optimize(&mut doc, &settings).unwrap();
|
||||
assert_eq!(r.images_resampled, 1, "{r:?}");
|
||||
assert_eq!(stream(&doc, image).dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]), "averaged, it is stored as JBIG2");
|
||||
}
|
||||
|
||||
/// The colour and grey steps leave an image over 80 million pixels as it is, whatever their settings
|
||||
@@ -5405,6 +5423,59 @@ fn a_picture_with_a_stencil_mask_is_downsampled_under_its_mask() {
|
||||
/// the page looks the same to the pixel. A mask two pictures share is stored once, and a stencil
|
||||
/// the page also draws by name is stored as that image. An eight-bit soft mask of only black and
|
||||
/// white stays as it is, to be resampled with its picture. With `mono: Retain` nothing changes.
|
||||
/// One-bit images past 80 million pixels are stored again, as images of eight bits that large are
|
||||
/// not: their rows hold a byte for each 8 samples. A picture's stencil mask and one-bit soft mask
|
||||
/// too.
|
||||
#[test]
|
||||
fn one_bit_images_past_80_million_pixels_are_stored_as_group_4() {
|
||||
let mut doc = Document::new_empty();
|
||||
let (w, h) = (9_000, 8_900);
|
||||
let text = bilevel(w, h, false);
|
||||
let one_bit = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||||
let stencil = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true))]);
|
||||
let soft = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||||
let masked = |doc: &mut Document, key: &str, mask: ObjRef| {
|
||||
let picture = image(doc, 200, 80, 3, false, None);
|
||||
let mut s = stream(doc, picture);
|
||||
s.dict.set(key.as_bytes().to_vec(), Object::Ref(mask));
|
||||
doc.set(picture, Object::Stream(s));
|
||||
picture
|
||||
};
|
||||
let (picture, shaded) = (masked(&mut doc, "Mask", stencil), masked(&mut doc, "SMask", soft));
|
||||
page(
|
||||
&mut doc,
|
||||
&[("I", one_bit), ("P", picture), ("S", shaded)],
|
||||
"q 300 0 0 300 20 400 cm /I Do Q q 300 0 0 120 20 100 cm /P Do Q q 300 0 0 120 20 250 cm /S Do Q",
|
||||
);
|
||||
let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE };
|
||||
let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE };
|
||||
let before = [one_bit, stencil, soft].map(|r| stream(&doc, r));
|
||||
let r = optimize(&mut doc, &Settings { color: keep, gray: keep, mono, ..Settings::default() }).unwrap();
|
||||
assert_eq!(r.images_recompressed, 3, "{r:?}");
|
||||
for (r, old) in [one_bit, stencil, soft].into_iter().zip(&before) {
|
||||
let new = stream(&doc, r);
|
||||
assert_eq!(new.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{r:?}");
|
||||
assert!(new.raw.len() < old.raw.len(), "{r:?}: {} against {}", new.raw.len(), old.raw.len());
|
||||
assert_eq!(decoded_image(&doc, &new).unwrap(), text, "{r:?} keeps its bits");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the default settings (Reduce File Size), a one-bit image past 80 million pixels drawn at
|
||||
/// 600 ppi is averaged to 300 ppi, as a smaller one is: vertical stripes four samples wide.
|
||||
#[test]
|
||||
fn a_one_bit_image_past_80_million_pixels_is_averaged_with_the_default_settings() {
|
||||
let (w, h) = (9_000u32, 8_900u32);
|
||||
let mut doc = Document::new_empty();
|
||||
let stripes = vec![0x0F; (w as usize).div_ceil(8) * h as usize];
|
||||
let one_bit = mono_image(&mut doc, w, h, &stripes, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]);
|
||||
let (pw, ph) = (f64::from(w) * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0);
|
||||
page(&mut doc, &[("I", one_bit)], &format!("q {pw} 0 0 {ph} 0 0 cm /I Do Q"));
|
||||
let r = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(r.images_resampled, 1, "{r:?}");
|
||||
let s = stream(&doc, one_bit);
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(4_500), Some(4_450)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pictures_one_bit_masks_are_stored_again() {
|
||||
let mut doc = Document::new_empty();
|
||||
|
||||
@@ -7563,8 +7563,8 @@ milestone = "M11"
|
||||
status = "partial"
|
||||
commands = ["optimize.advanced", "optimize.reduce"]
|
||||
tools = ["doc_optimize", "doc_reduce"]
|
||||
evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "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/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "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", "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/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::an_image_too_large_for_the_jbig2_decoder_stays_as_it_is", "crates/optimize/tests/optimize.rs::what_is_too_large_for_jbig2_is_stored_another_way", "crates/optimize/tests/optimize.rs::group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit", "crates/filters/src/jbig2/symbols.rs::pages_in_one_font_share_one_dictionary", "crates/filters/src/jbig2/symbols.rs::what_a_page_has_alone_stays_its_own", "crates/filters/src/jbig2/symbols.rs::past_the_shared_limit_the_most_widely_held_marks_are_shared", "crates/optimize/tests/optimize.rs::jbig2_pages_share_one_symbol_dictionary", "crates/optimize/tests/optimize.rs::a_dictionary_that_saves_less_than_it_takes_isnt_shared", "crates/optimize/tests/optimize.rs::sixteen_images_at_most_share_a_dictionary", "crates/engine/src/tests.rs::redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again", "crates/optimize/src/images.rs::only_what_redaction_can_store_again_shares_a_dictionary", "crates/optimize/tests/optimize.rs::a_pictures_one_bit_masks_are_stored_again", "crates/optimize/tests/optimize.rs::a_pictures_mask_stored_as_jbig2_shares_no_dictionary", "crates/automation/tests/it/automation.rs::doc_optimize_stores_a_pictures_one_bit_soft_mask_as_ccitt_group_4"]
|
||||
notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. 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. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export, up to 16 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it); lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. A picture's one-bit /SMask, and its stencil /Mask unless pages also draw it by name (it is then one of the images), are stored again the same way after the pictures, each once, at their size and bits, sharing no symbol dictionary, and counted as images. Not yet: images over 80 million pixels (left alone by the image steps, whatever their settings; Clean Up may still Flate an unfiltered or LZW one), inline images, a picture's mask resampled, or a soft mask of more bits packed to one bit (it stays, to be resampled with its picture), Group 4 for rows wider than 32,767 samples, JBIG2 past 65,535 samples on a side, where the decoder stops: such an image isn't stored as JBIG2 (the image step leaves a 1-bit one as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate)."
|
||||
evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::one_bit_images_past_80_million_pixels_are_stored_as_group_4", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "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/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "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", "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/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::an_image_too_large_for_the_jbig2_decoder_stays_as_it_is", "crates/optimize/tests/optimize.rs::what_is_too_large_for_jbig2_is_stored_another_way", "crates/optimize/tests/optimize.rs::group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit", "crates/filters/src/jbig2/symbols.rs::pages_in_one_font_share_one_dictionary", "crates/filters/src/jbig2/symbols.rs::what_a_page_has_alone_stays_its_own", "crates/filters/src/jbig2/symbols.rs::past_the_shared_limit_the_most_widely_held_marks_are_shared", "crates/optimize/tests/optimize.rs::jbig2_pages_share_one_symbol_dictionary", "crates/optimize/tests/optimize.rs::a_dictionary_that_saves_less_than_it_takes_isnt_shared", "crates/optimize/tests/optimize.rs::sixteen_images_at_most_share_a_dictionary", "crates/engine/src/tests.rs::redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again", "crates/optimize/src/images.rs::only_what_redaction_can_store_again_shares_a_dictionary", "crates/optimize/tests/optimize.rs::a_pictures_one_bit_masks_are_stored_again", "crates/optimize/tests/optimize.rs::a_pictures_mask_stored_as_jbig2_shares_no_dictionary", "crates/automation/tests/it/automation.rs::doc_optimize_stores_a_pictures_one_bit_soft_mask_as_ccitt_group_4"]
|
||||
notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. 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. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export, up to 16 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it); lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. A picture's one-bit /SMask, and its stencil /Mask unless pages also draw it by name (it is then one of the images), are stored again the same way after the pictures, each once, at their size and bits, sharing no symbol dictionary, and counted as images. Not yet: images over 80 million pixels (left alone by the image steps, whatever their settings; Clean Up may still Flate an unfiltered or LZW one), except images and masks already of one bit a pixel whose rows hold no more bytes than 80 million pixels of eight bits (a byte for each 8 samples of a row and one for any left over, so up to 640 million pixels when wide, and a strip one sample wide no taller than at eight bits), which the monochrome step averages and stores again as it does smaller ones (with JBIG2 chosen, those past 2^28 pixels stay unless averaged), inline images, a picture's mask resampled, or a soft mask of more bits packed to one bit (it stays, to be resampled with its picture), Group 4 for rows wider than 32,767 samples, JBIG2 past 65,535 samples on a side or 2^28 in all, where the decoder stops: such an image isn't stored as JBIG2 (the image step leaves a 1-bit one as it is unless it is averaged, and may store one packed from more bits as 1-bit Flate)."
|
||||
|
||||
[[feature]]
|
||||
id = "optimize.only-if-smaller"
|
||||
|
||||
Reference in New Issue
Block a user