+ 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer
> {
+ use image::imageops::{FilterType, resize};
+ let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
+ let largest = (iw * ih).max(u64::from(w) * u64::from(h));
+ if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
+ resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
+ } else {
+ resize(img, w, h, FilterType::CatmullRom)
+ }
+}
+
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> {
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
@@ -754,7 +784,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool {
let cs = doc.resolve(cs);
match &*cs {
Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"),
- Object::Array(a) => match a.first().and_then(Object::as_name) {
+ Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) {
Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true,
Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))),
Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)),
@@ -771,7 +801,7 @@ type Pixel = fn(&[u8]) -> Option;
/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit
/// image. These count:
/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with
-/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further
+/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further
/// than its rows can need);
/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values):
/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array
@@ -802,8 +832,10 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
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)?,
+ // A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit.
_ => allowance(len, h)?,
};
+ count_decoded(u64::from(w) * u64::from(h));
let mut data = doc.decoded_image_within(s, max).ok()?;
if data.len() < len {
return None;
@@ -827,7 +859,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
packed.set(b"BitsPerComponent".to_vec(), Object::Int(1));
// Components a pixel, bits a component, and a pixel's samples as 1 or 0.
let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) {
- Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => {
+ Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => {
let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?;
match decode.as_deref() {
None => {}
@@ -908,8 +940,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit
Some(out)
}
-/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom
-/// colour spaces stay at their original resolution rather than guessing which entry is dark.
+/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array
+/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so
+/// is the painted part of an image mask. An indexed image's samples pick palette entries (its
+/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and
+/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and
+/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike.
fn dark_is_one(doc: &Document, d: &Dict) -> Option {
let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite());
let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() {
@@ -923,12 +959,62 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option {
if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
return Some(d1 < d0);
}
+ // A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the
+ // sample that shows.
+ if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) {
+ return None;
+ }
match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() {
Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0),
+ Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => {
+ let hival = a.get(2).and_then(|v| match &*doc.resolve(v) {
+ Object::Int(h) if (0..=255).contains(h) => Some(*h),
+ _ => None,
+ })?;
+ // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap.
+ let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize;
+ let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?);
+ (l0 != l1).then_some(l1 < l0)
+ }
_ => None,
}
}
+/// The most bytes a palette is read to (see [`lightness`]).
+const MAX_PALETTE: usize = 4096;
+
+/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`)
+/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only
+/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or
+/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base
+/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold.
+fn lightness(doc: &Document, a: &[Object], i: usize) -> Option {
+ // A device space is its name, or that name alone in an array.
+ let base = doc.resolve(a.get(1)?);
+ let base = match &*base {
+ Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?),
+ _ => base,
+ };
+ let n = match base.as_name()? {
+ b"DeviceGray" => 1,
+ b"DeviceRGB" => 3,
+ _ => return None,
+ };
+ // At most 256 entries of three components: more is no palette. Images share a table, and this
+ // is read for each of them, so a large one is refused before it's copied or decoded.
+ let table = match &*doc.resolve(a.get(3)?) {
+ Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(),
+ Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?,
+ _ => return None,
+ };
+ let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect();
+ Some(match c.as_slice() {
+ [v] => v * 1000,
+ [r, g, b] => 299 * r + 587 * g + 114 * b,
+ _ => return None,
+ })
+}
+
/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger:
/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie
/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None`
@@ -1000,15 +1086,20 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i
/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled.
///
/// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`,
-/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a
-/// dither: most of its new pixels would cover both black and white, and taking the sample that
-/// covers more would turn its tones solid (a 50% screen black, a 25% one white).
-/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only
-/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again
-/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored
-/// again like any other. With `Retain`, a resampled image keeps its kind of compression
-/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the
-/// grey or colour settings.
+/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose
+/// palette says which entry is darker, not under `defaults`), unless it has a soft mask, whose
+/// own size would then have to follow, or is a halftone or a dither: most of its new pixels would
+/// cover both black and white, and taking the sample that covers more would turn its tones solid
+/// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover
+/// both, as a small screened picture on a white page has (and ragged scanned text, which is then
+/// left at its resolution too).
+/// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that
+/// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image
+/// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded
+/// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside
+/// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or
+/// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or
+/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`.
///
/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for
/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default
@@ -1020,10 +1111,14 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
let fax = doc.fax_params(s);
// Its kind of compression, which Retain keeps, behind any other filters.
let ccitt = fax.is_some();
- // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind
- // ASCII85Decode, say, can be smaller stored again as Group 4 alone.
- let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1;
- if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample {
+ let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _)));
+ // Group 4 alone is only worth decoding to resample it, and so is JBIG2 alone when Group 4 is what
+ // it would be stored as: that output is rarely smaller, and decoding an ordinary scan costs a
+ // quarter of a second a page. Group 3, Group 4 behind ASCII85Decode, say, and JBIG2 stored as
+ // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone.
+ let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4;
+ let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0)));
+ if only_to_resample && !downsample {
return None;
}
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?;
@@ -1038,8 +1133,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
} else {
(w, h)
};
- let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) };
- let averaged = if (nw, nh) != (w, h) && polarity.is_some() {
+ let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) };
+ let averaged = if polarity.is_some() {
// The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair.
let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?;
// A halftone or dither: most new pixels cover both samples. Text covers both only along
@@ -1055,7 +1150,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
Some(out) => (out, nw, nh),
None => (data, w, h),
};
- if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled {
+ if only_to_resample && !resampled {
return None;
}
let mut dict = packed.unwrap_or_else(|| d.clone());
@@ -1078,7 +1173,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
} else {
None
};
- let jbig = if settings.compression == MonoCompression::Jbig2 {
+ let jbig = if settings.compression == MonoCompression::Jbig2 || (retain && jbig2) {
pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| {
let mut dict = dict.clone();
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
@@ -1087,7 +1182,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
} else {
None
};
- let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data));
+ let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data));
// Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters.
let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?;
(best.0 < crate::stored_len(s)?).then_some((best.1, resampled))
@@ -1101,11 +1196,13 @@ pub(crate) fn run(
progress: &mut dyn FnMut(Stage) -> bool,
) -> Result<(), OptimizeError> {
let (ppi, mut defaults, read) = walk_pages(doc, pages);
- // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used
- // where the walk didn't look may be drawn so.
+ // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an
+ // indexed image's palette says which of its samples a resampled one keeps, which they may
+ // change: one used where the walk didn't look may be drawn so.
let packs = settings.mono.compression != MonoCompression::Retain;
- let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect();
- defaults.extend(used_elsewhere(doc, &rgb, &read));
+ let checked: HashSet =
+ ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect();
+ defaults.extend(used_elsewhere(doc, &checked, &read));
// Soft masks are processed with their image, not on their own.
let masks: HashSet = ppi
.keys()
@@ -1228,6 +1325,36 @@ mod dimension_tests {
assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4]));
}
+ /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped,
+ /// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind
+ /// another filter it can still be stored again, as Group 4 can.
+ #[test]
+ fn a_jbig2_image_alone_is_not_decoded_for_group_4_unless_it_is_resampled() {
+ let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
+ let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
+ let image = |filter: Object, data: Vec| {
+ let mut dict = Dict::new();
+ dict.set(b"Width".to_vec(), Object::Int(16));
+ dict.set(b"Height".to_vec(), Object::Int(2));
+ dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
+ dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
+ dict.set(b"Filter".to_vec(), filter);
+ Stream::from_raw(dict, data)
+ };
+ let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE };
+ let decoded = |s: &Stream, settings: &MonoSettings| {
+ DECODED_PIXELS.with(|c| c.set(0));
+ mono(&Document::new_empty(), s, 300.0, false, settings);
+ DECODED_PIXELS.with(|c| c.get())
+ };
+ let alone = image(Object::name("JBIG2Decode"), coded.clone());
+ assert_eq!(decoded(&alone, &settings), 0, "alone, for Group 4, below the threshold");
+ let behind = image(Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]), hex);
+ assert!(decoded(&behind, &settings) > 0, "behind another filter it can be stored again");
+ let jbig2 = MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE };
+ assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller");
+ }
+
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
#[test]
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
@@ -1244,6 +1371,66 @@ mod dimension_tests {
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
+
+ /// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
+ /// the first three shapes asked for 160 GB and more on the way, and that aborted the process.
+ /// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way
+ /// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions
+ /// resample as they always did.
+ #[test]
+ fn resampling_across_proportions_takes_the_memory_of_neither_product() {
+ let shapes = [
+ (100_000u32, 1u32, 1u32, 100_000u32, 1usize),
+ (1_000_000, 1, 4, 100_000, 1),
+ (100_000, 1, 1, 100_000, 3),
+ (1, 100_000, 100_000, 1, 1),
+ (1, 100_000, 100_000, 1, 3),
+ ];
+ for (w, h, nw, nh, n) in shapes {
+ let px = vec![0x60; (w * h) as usize * n];
+ let out = resample(&px, n, w, h, nw, nh).unwrap();
+ assert_eq!(out.len(), (nw * nh) as usize * n);
+ assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
+ }
+ let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
+ let px: Vec = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
+ let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
+ let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
+ assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
+ }
+
+ /// The long way round is for proportions far apart: up to a quarter over the larger picture the
+ /// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
+ /// out the same either way.
+ #[test]
+ fn proportions_up_to_the_limit_resample_as_they_did() {
+ let noise = |w: u32, h: u32| -> Vec {
+ let mut seed = 0x2545_F491_4F6C_DD1Du64;
+ (0..w * h)
+ .map(|_| {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed as u8
+ })
+ .collect()
+ };
+ let direct = |px: &[u8], w, h, nw, nh| {
+ let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
+ image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
+ };
+ // Each exactly a quarter over the larger: the source's size, then the target's.
+ for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
+ let px = noise(w, h);
+ assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
+ }
+ // A step past it goes the long way, which is how to tell.
+ let px = noise(25, 15);
+ assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
+ // A picture of the size asked for comes through as it is.
+ let px = noise(20, 10);
+ assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px);
+ }
}
#[cfg(test)]
@@ -1307,8 +1494,9 @@ mod downsample_tests {
assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000]));
}
- /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round
- /// with an inverting decode array; 0 for an indexed image, whatever its decode array.
+ /// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode
+ /// array; none known for a separation, nor for an indexed image without a palette (see the next
+ /// test), nor with a decode array that isn't two numbers.
#[test]
fn the_dark_sample_follows_the_colour_space_and_decode_array() {
let doc = Document::new_empty();
@@ -1332,6 +1520,7 @@ mod downsample_tests {
(vec![mask, inverted.clone()], Some(true)),
(vec![separation.clone()], None),
(vec![separation, inverted.clone()], None),
+ // A palette that isn't there says nothing.
(vec![indexed.clone()], None),
(vec![indexed, inverted], None),
(vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None),
@@ -1340,6 +1529,54 @@ mod downsample_tests {
}
}
+ /// An indexed image's dark sample is the one picking the darker palette entry, after its decode
+ /// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string
+ /// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a
+ /// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255
+ /// say nothing.
+ #[test]
+ fn an_indexed_images_palette_says_which_sample_is_dark() {
+ let mut doc = Document::new_empty();
+ let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec()));
+ let referenced = doc.add(string(&[255, 0]));
+ let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255])));
+ let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]);
+ let indexed = |base: Object, hival: i64, table: Object| {
+ let mut d = Dict::new();
+ d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table]));
+ d
+ };
+ let inverted = |mut d: Dict| {
+ d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()]));
+ d
+ };
+ for (d, dark_one, what) in [
+ (indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"),
+ (indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"),
+ (indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"),
+ (inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"),
+ (indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"),
+ (indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"),
+ (indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"),
+ // Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685).
+ (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"),
+ (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"),
+ (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"),
+ // Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it.
+ (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"),
+ (indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"),
+ (indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"),
+ (indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"),
+ (indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"),
+ (indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"),
+ (indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"),
+ (indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"),
+ (indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"),
+ ] {
+ assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}");
+ }
+ }
+
#[test]
fn what_it_cannot_resample_is_none() {
assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider");
diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs
index 76e30da7ac..4c8845c82d 100644
--- a/crates/optimize/src/lib.rs
+++ b/crates/optimize/src/lib.rs
@@ -10,10 +10,11 @@
//! A new image replaces the old one only if it is smaller. The colour and grey settings leave
//! alone images they 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). Black-and-white images (1-bit, image masks and CCITT fax,
+//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks, CCITT fax and JBIG2,
//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images
//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a
-//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
+//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and
+//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
//! Flate once resampled or packed, when that is smaller.
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
//! print settings.
diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs
index 683cbd0684..5bfa74aa8b 100644
--- a/crates/optimize/tests/optimize.rs
+++ b/crates/optimize/tests/optimize.rs
@@ -2045,6 +2045,127 @@ fn rendered_text_becomes_jbig2_symbols() {
assert!(render(&doc) == before, "the page looks the same");
}
+/// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it
+/// is averaged as its Flate copy is, to the same samples, and stored as each compression asks:
+/// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another
+/// filter, and with `/JBIG2Globals`, which the new image doesn't refer to. Below 450 ppi it is
+/// stored again only when that is smaller: a generic region of text stays under Group 4, which
+/// is larger, and under Retain, and becomes symbols with JBIG2 asked for; behind ASCIIHex it is
+/// stored again alone, as Group 4 where the hex is spaced out. A JBIG2 page taller than the image
+/// is left alone.
+#[test]
+fn jbig2_scans_are_averaged_and_stored_again() {
+ // Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new
+ // pixel covering both samples, so nothing keeps the picture at its resolution.
+ let (w, h) = (1200u32, 480u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut squares = vec![0xFFu8; stride * h as usize];
+ for (top, left) in (0..h as usize / 32).flat_map(|r| (0..w as usize / 32).map(move |c| (r * 32 + 8, c * 32 + 8))) {
+ for y in top..top + 16 {
+ for x in left..left + 16 {
+ squares[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ }
+ let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
+ let hex = |coded: &[u8]| -> Vec { coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() };
+ // An image of `coded` behind `filters`, with globals a decoder skips when `globals`, drawn at
+ // `width` × `height` pt.
+ let jbig2_page = |coded: &[u8], filters: &[&str], globals: bool, (iw, ih): (u32, u32), (width, height): (f64, f64)| {
+ let mut doc = Document::new_empty();
+ let mut d = Dict::new();
+ d.set(b"Subtype".to_vec(), Object::name("Image"));
+ d.set(b"Width".to_vec(), Object::Int(iw.into()));
+ d.set(b"Height".to_vec(), Object::Int(ih.into()));
+ for (k, v) in &grey {
+ d.set(k.as_bytes().to_vec(), v.clone());
+ }
+ d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect()));
+ if globals {
+ let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1])));
+ let mut parms = Dict::new();
+ parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
+ let entries = filters.iter().map(|f| if *f == "JBIG2Decode" { Object::Dict(parms.clone()) } else { Object::Null }).collect();
+ d.set(b"DecodeParms".to_vec(), Object::Array(entries));
+ }
+ let data = if filters.len() > 1 { hex(coded) } else { coded.to_vec() };
+ let im = doc.add(Object::Stream(Stream::from_raw(d, data)));
+ page(&mut doc, &[("Im", im)], &format!("q {width} 0 0 {height} 20 600 cm /Im Do Q"));
+ (doc, im)
+ };
+ let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap();
+ // 144 × 57.6 pt is 600 ppi.
+ let at_600 = (144.0, 57.6);
+ for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] {
+ let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() };
+ let mut doc = Document::new_empty();
+ let im = mono_image(&mut doc, w, h, &squares, None, &grey);
+ page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
+ assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}");
+ let averaged = doc.decoded_image(&stream(&doc, im)).unwrap();
+ for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] {
+ let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600);
+ let r = optimize(&mut doc, &settings).unwrap();
+ let s = stream(&doc, im);
+ let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}");
+ assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}");
+ assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}");
+ assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}");
+ let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec);
+ // The smallest of what each compression may store; under Retain, JBIG2 again.
+ let allowed: &[&[u8]] = match compression {
+ MonoCompression::CcittG4 => &[b"CCITTFaxDecode", b"FlateDecode"],
+ MonoCompression::Jbig2 => &[b"JBIG2Decode", b"FlateDecode"],
+ MonoCompression::Retain => &[b"JBIG2Decode"],
+ };
+ assert!(filter.as_deref().is_some_and(|f| allowed.contains(&f)), "{what}: {:?}", filter.map(String::from_utf8));
+ }
+ }
+
+ // At 300 ppi, below 450: rendered text, whose letters repeat, coded as one generic region.
+ let (tw, th) = (600u32, 480u32);
+ let text = rendered_text(tw, th);
+ let generic = pdfcraft_filters::encode_jbig2_generic(&text, tw, th).unwrap();
+ let at_300 = (144.0, 115.2);
+ for compression in [MonoCompression::CcittG4, MonoCompression::Retain] {
+ let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() };
+ let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300);
+ let before = stream(&doc, im);
+ optimize(&mut doc, &settings).unwrap();
+ assert_eq!(stream(&doc, im), before, "JBIG2 below 450 ppi, {compression:?}");
+ }
+ let jbig2_asked = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
+ let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300);
+ let r = optimize(&mut doc, &jbig2_asked).unwrap();
+ let s = stream(&doc, im);
+ assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}");
+ assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len());
+ assert_eq!(doc.decoded_image(&s).unwrap(), text);
+ // Behind ASCIIHex it is stored again alone, being smaller so.
+ let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
+ optimize(&mut doc, &jbig2_asked).unwrap();
+ let s = stream(&doc, im);
+ assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..])));
+ assert_eq!(doc.decoded_image(&s).unwrap(), text);
+ // So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex
+ // allows, so that the stream is many times the code.
+ let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
+ let mut padded = stream(&doc, im);
+ padded.raw = generic.iter().flat_map(|b| format!("{b:02x}{}", " ".repeat(30)).into_bytes()).chain(*b">").collect::>().into();
+ doc.set(im, Object::Stream(padded));
+ optimize(&mut doc, &Settings::default()).unwrap();
+ let s = stream(&doc, im);
+ assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone");
+ assert_eq!(doc.decoded_image(&s).unwrap(), text);
+
+ // A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't
+ // read, and stays as it is.
+ let (mut doc, im) = jbig2_page(&coded, &["JBIG2Decode"], false, (w, h - 8), at_600);
+ let before = stream(&doc, im);
+ optimize(&mut doc, &Settings::default()).unwrap();
+ assert_eq!(stream(&doc, im), before, "a taller page");
+}
+
/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the
/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols.
#[test]
@@ -3394,10 +3515,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() {
assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops);
}
-/// Unknown palette polarity is never guessed. Both palette orders and Decode directions
-/// retain the original thin rule and exact samples through a full save and render.
+/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does
+/// in grey: with the palette either way round, and the decode array either way, the averaged image
+/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same
+/// through a full save and render.
#[test]
-fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
+fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
let (w, h) = (1200u32, 480u32);
let stride = (w as usize).div_ceil(8);
let mut gray_data = vec![0xFF; stride * h as usize];
@@ -3409,6 +3532,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
let gray_render = render(&gray);
optimize(&mut gray, &Settings::default()).unwrap();
+ let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap());
let gray_stream = stream(&gray, gray_image);
let gray_samples = gray.decoded_image(&gray_stream).unwrap();
assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240)));
@@ -3440,14 +3564,153 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray");
optimize(&mut doc, &Settings::default()).unwrap();
let s = stream(&doc, image);
- assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into())));
- assert_eq!(doc.decoded_image(&s).unwrap(), data);
+ assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}");
+ // The rows are 600 samples, whole bytes: flipping each byte flips each sample.
+ let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect();
+ assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark");
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
- assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}");
+ assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}");
}
}
}
+/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where
+/// other default colour spaces may be in force and change which palette entry is darker) keeps its
+/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is
+/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an
+/// indexed image's palette depends on what's in force where it is drawn.
+#[test]
+fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() {
+ let (w, h) = (1200u32, 480u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut data = vec![0xFF; stride * h as usize];
+ for y in 0..h as usize {
+ data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
+ }
+ let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255]));
+ let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]);
+ let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)];
+ for (cs, in_annotation, width) in cases {
+ let mut doc = Document::new_empty();
+ let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]);
+ // 144 × 57.6 pt is 600 ppi.
+ let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
+ if in_annotation {
+ let resources = Object::Dict({
+ let mut r = Dict::new();
+ r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)])));
+ r
+ });
+ let appearance = drawing(&mut doc, Some(resources), &form_entries());
+ annotate(&mut doc, p, appearance);
+ }
+ optimize(&mut doc, &Settings::default()).unwrap();
+ assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}");
+ }
+}
+
+/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than
+/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has
+/// no palette, so its samples aren't averaged.
+#[test]
+fn an_indexed_palette_is_read_by_the_luma_of_its_colours() {
+ let (w, h) = (1200u32, 480u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut data = vec![0xFF; stride * h as usize];
+ for y in 0..h as usize {
+ data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
+ }
+ let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0]));
+ let optimized = |space: &str| {
+ let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]);
+ let mut doc = Document::new_empty();
+ let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]);
+ page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
+ optimize(&mut doc, &Settings::default()).unwrap();
+ let s = stream(&doc, image);
+ (s.dict.int(b"Width"), doc.decoded_image(&s).unwrap())
+ };
+ let (width, samples) = optimized("Indexed");
+ assert_eq!(width, Some(600));
+ assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample");
+ assert_eq!(optimized("NotIndexed").0, Some(1200));
+}
+
+/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base
+/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival`
+/// aren't one, and neither is a table too large to be a palette, which images share and which
+/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of
+/// the other would be lost, in grey too; a stencil `/Mask` doesn't.
+#[test]
+fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() {
+ let (w, h) = (1200u32, 480u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut data = vec![0xFF; stride * h as usize];
+ for y in 0..h as usize {
+ data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
+ }
+ let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255]));
+ let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]);
+ let rgb = || Object::name("DeviceRGB");
+ let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table());
+ let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| {
+ let mut doc = Document::new_empty();
+ let mut entries = vec![("BitsPerComponent", Object::Int(1))];
+ entries.extend(make(&mut doc));
+ let image = mono_image(&mut doc, w, h, &data, None, &entries);
+ page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
+ optimize(&mut doc, &Settings::default()).unwrap();
+ stream(&doc, image).dict.int(b"Width")
+ };
+ let averaged = Some(600);
+ let kept = Some(1200);
+ assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct");
+ assert_eq!(
+ width(&|doc: &mut Document| {
+ let family = doc.add(Object::name("Indexed"));
+ vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))]
+ }),
+ averaged,
+ "the family by reference"
+ );
+ let base = Object::Array(vec![rgb()]);
+ assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array");
+ assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation");
+ assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival");
+ let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat()));
+ assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette");
+ // 16 KiB of raw data is all a table may take, whatever it decodes to.
+ let mut d = Dict::new();
+ d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode"));
+ let table_of = |raw: usize| {
+ let d = d.clone();
+ let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect();
+ move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone())))
+ };
+ for (raw, expected) in [(16384, averaged), (16385, kept)] {
+ let add = table_of(raw);
+ assert_eq!(
+ width(&|doc: &mut Document| {
+ let table = add(doc);
+ vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))]
+ }),
+ expected,
+ "a stream of {raw} bytes"
+ );
+ }
+ let key = || Object::Array(vec![0.into(), 0.into()]);
+ assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed");
+ assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey");
+ assert_eq!(
+ width(&|doc: &mut Document| {
+ let key = doc.add(key());
+ vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))]
+ }),
+ kept,
+ "a colour key by reference"
+ );
+}
+
/// White margins must not hide a small screened photograph from the tone guard.
#[test]
fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() {
diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml
index 8d1515d8a4..fe21d13b32 100644
--- a/crates/redact/Cargo.toml
+++ b/crates/redact/Cargo.toml
@@ -17,8 +17,9 @@ pdfcraft-forms = { workspace = true }
pdfcraft-edit = { workspace = true }
pdfcraft-annot = { workspace = true }
pdfcraft-xfa = { workspace = true }
+# CCITT Group 4 and JBIG2 encoding for cleared one-bit images.
+pdfcraft-filters = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
-pdfcraft-filters = { workspace = true }
pdfcraft-testkit = { workspace = true }
diff --git a/crates/redact/README.md b/crates/redact/README.md
index 8383f0119f..9c024d1b55 100644
--- a/crates/redact/README.md
+++ b/crates/redact/README.md
@@ -18,10 +18,19 @@ clear_marks(&mut doc, None)?; // remove marks without app
more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is
inside;
- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared
- (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last
- filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference,
- numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed
- whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
+ (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as
+ the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its
+ `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy
+ is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2
+ when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to
+ no globals. Globals hold the shapes of every image that shares them (a scan's pages, often),
+ those of marks under the area included, so every other image that uses the globals of a removed
+ or replaced image is decoded and stored again in place without them, as a copy is (Flate,
+ Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be
+ decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are
+ removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer
+ draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more
+ than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other
rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask`
or `/Mask` data, and images whose data decodes to more than three times their declared raster
@@ -103,5 +112,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r
`CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes.
Custom code sets aren't supported yet.
-Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information
-sanitising (M8.4), re-encoding of DCT images (they are removed instead).
+Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed
+instead).
diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs
index f662849c12..a96f4715e6 100644
--- a/crates/redact/src/image.rs
+++ b/crates/redact/src/image.rs
@@ -1,13 +1,14 @@
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
-//! written as a new Flate image; the original object is untouched (other pages may use it).
-//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes,
-//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode
-//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image
-//! instead; that is fail-closed.
+//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it
+//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it).
+//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on
+//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the
+//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the
+//! caller removes the whole image instead; that is fail-closed.
use pdfcraft_content::{Matrix, overlaps};
-use pdfcraft_cos::{Document, Object, Stream};
+use pdfcraft_cos::{Dict, Document, Object, Stream};
/// Most pixels an image may have to be cleared in place (larger ones are removed).
const MAX_PIXELS: u64 = 64 << 20;
@@ -53,18 +54,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL"
)
};
- // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the
- // filters before it, so a scan stored as Group 4 is cleared like any other image.
+ // A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`,
+ // after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other
+ // image.
let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF"));
+ let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice());
let readable = |f: &Object| f.as_name().is_some_and(supported);
- let (ok, is_fax) = match s.dict.get(b"Filter") {
- None => (true, false),
- Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)),
+ let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") {
+ None => (true, false, false),
+ Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)),
Some(Object::Array(a)) => match a.split_last() {
- Some((last, before)) if fax(last) => (before.iter().all(readable), true),
- _ => (a.iter().all(readable), false),
+ Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)),
+ _ => (a.iter().all(readable), false, false),
},
- _ => (false, false),
+ _ => (false, false, false),
};
if !ok {
return None;
@@ -83,7 +86,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
let (w, h) = (w as usize, h as usize);
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) };
- if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
+ // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
+ if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) {
return None;
}
// Every size comes from the file: multiply checked, so an overflowing product refuses the image
@@ -105,7 +109,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?
};
// Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*`
- // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels).
+ // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels),
+ // and refuses a JBIG2 page of another size than the image: the renderer draws the whole page
+ // into the image, so the rows cleared wouldn't be the ones a mark covers on screen.
let mut data = doc.decoded_image_within(s, allowance).ok()?;
if data.len() < bytes {
return None;
@@ -160,5 +166,74 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
dict.remove(b"Length");
dict.remove(b"DecodeParms");
dict.remove(b"Alternates");
- Some(Some(Stream::flate(dict, &data)))
+ Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2)))
+}
+
+/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit
+/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT
+/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already
+/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's
+/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's
+/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out.
+fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream {
+ let flate = Stream::flate(dict.clone(), data);
+ let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate };
+ let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| {
+ let mut dict = dict.clone();
+ let mut parms = Dict::new();
+ parms.set(b"K".to_vec(), Object::Int(-1));
+ parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns)));
+ parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows)));
+ dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
+ dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
+ Stream::from_raw(dict, coded)
+ });
+ let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| {
+ let mut dict = dict.clone();
+ dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
+ Stream::from_raw(dict, coded)
+ });
+ [g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best })
+}
+
+/// The bytes stream `s` takes in a file: its data and its dictionary as written.
+fn stored_len(s: &Stream) -> usize {
+ let mut dict = Vec::new();
+ pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict);
+ dict.len().saturating_add(s.raw.len())
+}
+
+/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn,
+/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest
+/// (`smallest`), its other entries kept. `None` when it can't
+/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than
+/// the image, or not a one-bit image of one component). Redaction does this to every image that
+/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn
+/// under the area too.
+pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option {
+ let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);
+ if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {
+ return None;
+ }
+ let (w, h) = (w as usize, h as usize);
+ let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
+ // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
+ if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) {
+ return None;
+ }
+ let bytes = w.div_ceil(8).checked_mul(h)?;
+ // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one
+ // of the raster and a byte a row.
+ let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?;
+ let mut data = doc.decoded_image_within(s, allowance).ok()?;
+ if data.len() < bytes {
+ return None;
+ }
+ data.truncate(bytes);
+ let mut dict = s.dict.clone();
+ dict.remove(b"Length");
+ // The globals are named in the parameters; the copy is coded alone, as `clear`'s are.
+ dict.remove(b"DecodeParms");
+ dict.remove(b"Alternates");
+ Some(smallest(dict, &data, w, h, true, true))
}
diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs
index de9dafe7db..fad55c431f 100644
--- a/crates/redact/src/lib.rs
+++ b/crates/redact/src/lib.rs
@@ -70,6 +70,12 @@ pub enum RedactError {
/// could not be shown complete.
#[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")]
TooDeep,
+ /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again
+ /// without them, so the shapes they hold, those of marks under the area included, would stay.
+ #[error(
+ "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted"
+ )]
+ SharedJbig2Globals(usize),
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
#[error(transparent)]
@@ -132,6 +138,9 @@ pub struct Report {
pub glyphs: usize,
pub images_removed: usize,
pub images_cleared: usize,
+ /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the
+ /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file.
+ pub images_restored: usize,
pub paths_removed: usize,
pub paths_clipped: usize,
pub forms_rewritten: usize,
@@ -509,6 +518,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result Result {
+ let mut globals = HashSet::new();
+ let mut seen = HashSet::new();
+ let mut stack: Vec = doomed.to_vec();
+ while let Some(r) = stack.pop() {
+ if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK {
+ continue;
+ }
+ let object = doc.get(r);
+ let Object::Stream(s) = &*object else { continue };
+ if let Some(g) = doc.jbig2_globals_ref(s) {
+ globals.insert(g);
+ }
+ // A form's images, and its forms' (an image's /SMask is a stream of its own, found below).
+ let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r));
+ let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x));
+ if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) {
+ stack.extend(x.iter().filter_map(|(_, v)| v.as_ref()));
+ }
+ if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) {
+ stack.push(m);
+ }
+ }
+ if globals.is_empty() {
+ return Ok(0);
+ }
+ let sharing: Vec = doc
+ .scan_objects()
+ .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g))))
+ .map(|(r, _)| r)
+ .collect();
+ let doomed: HashSet = doomed.iter().copied().collect();
+ let (mut restored, mut stuck) = (0, 0);
+ for r in sharing {
+ let object = doc.get(r);
+ let Object::Stream(s) = &*object else { continue };
+ match image::without_globals(doc, s) {
+ Some(new) => {
+ doc.set(r, Object::Stream(new));
+ restored += usize::from(!doomed.contains(&r));
+ }
+ None if doomed.contains(&r) => {}
+ None => stuck += 1,
+ }
+ }
+ if stuck > 0 {
+ return Err(RedactError::SharedJbig2Globals(stuck));
+ }
+ Ok(restored)
+}
+
/// What applying the marks collects across pages.
struct Applying {
report: Report,
diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs
index 125f00fc5c..74b8407056 100644
--- a/crates/redact/src/tests.rs
+++ b/crates/redact/src/tests.rs
@@ -519,13 +519,19 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
])
}
-/// The samples of the image a page's (possibly inherited) resources name `name`.
+/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through
+/// its image codec too (`Document::decoded_image`).
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> {
+ named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok())
+}
+
+/// The image a page's (possibly inherited) resources name `name`.
+fn named_stream(doc: &Document, page: usize, name: &[u8]) -> Option {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
match &*doc.get(xo.get(name)?.as_ref()?) {
- Object::Stream(s) => s.decoded().ok(),
+ Object::Stream(s) => Some(s.clone()),
_ => None,
}
}
@@ -736,6 +742,506 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() {
assert_eq!((report.images_cleared, report.images_removed), (1, 0));
}
+/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included,
+/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels
+/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen
+/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding.
+/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a
+/// search that one cleared byte would blind isn't all that holds this.
+#[test]
+fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() {
+ const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E];
+ const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F];
+ const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"];
+ let (w, h) = (512usize, 64usize);
+ let stride = w / 8;
+ let mut bits = vec![0xFFu8; stride * h];
+ bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED);
+ bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT);
+ for top in (16..h - 8).step_by(10) {
+ for x in (4..w - 8).step_by(8) {
+ for (dy, row) in LETTER.iter().enumerate() {
+ for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
+ bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
+ }
+ }
+ }
+ }
+ let holds = |doc: &Document, pixels: &[u8]| {
+ doc.scan_objects().any(|(_, object)| match &*object {
+ Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)),
+ _ => false,
+ })
+ };
+ // Columns 0 to 99 of rows 0 to 7.
+ let mut cleared = bits.clone();
+ for row in cleared.chunks_mut(stride).take(8) {
+ row[..12].fill(0);
+ row[12] &= 0x0F;
+ }
+ let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
+ let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
+ assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests");
+ for coded in [symbols, generic] {
+ let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode");
+ // Half a point a pixel, its top row at y 132.
+ let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]);
+ assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found");
+ mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], "");
+ assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1);
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file");
+ assert!(holds(&saved, &KEPT), "the pixels beside them stay");
+ assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed");
+ }
+}
+
+/// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it
+/// needn't read, holding `secret`; its 11-byte header gives the length of the data after it.
+fn jbig2_globals(secret: &[u8]) -> Vec {
+ let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0];
+ g.extend_from_slice(&u32::try_from(4 + secret.len()).unwrap().to_be_bytes());
+ g.extend_from_slice(&[0, 0, 0, 1]);
+ g.extend_from_slice(secret);
+ g
+}
+
+/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy,
+/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by
+/// reference, behind another filter with its globals at its own place, or as a mask, its decode
+/// array inverting it or not.
+#[test]
+fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() {
+ // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of
+ // the top row is covered.
+ let white = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
+ let painted = pdfcraft_filters::encode_jbig2_generic(&[0x00; 4], 16, 2).unwrap();
+ let hex: Vec = white.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect();
+ let globals = stream("", jbig2_globals(b"shared"));
+ let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1";
+ for (dict, data, extra, cleared) in [
+ (format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode"), white.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]),
+ (
+ format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms 8 0 R"),
+ white.clone(),
+ vec![b"<< /JBIG2Globals 9 0 R >>".to_vec(), globals.clone()],
+ [0x00, 0xFF, 0xFF, 0xFF],
+ ),
+ (
+ format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"),
+ hex,
+ vec![globals],
+ [0x00, 0xFF, 0xFF, 0xFF],
+ ),
+ // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing.
+ (format!("{image} /ImageMask true /Filter /JBIG2Decode"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]),
+ // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0.
+ (format!("{image} /ImageMask true /Decode [1 0] /Filter /JBIG2Decode"), white, vec![], [0x00, 0xFF, 0xFF, 0xFF]),
+ ] {
+ let mut objs = vec![stream(&dict, data)];
+ objs.extend(extra);
+ let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs);
+ mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
+ let report = apply(&mut doc, None).unwrap();
+ assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}");
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}");
+ }
+}
+
+/// A JBIG2 image is removed whole when its covered pixels can't be cleared for certain: a JBIG2
+/// page taller than the image (the renderer draws the whole page into the image, so the rows cleared
+/// wouldn't be the ones shown)
+/// or shorter, or of another width; globals that can't be read; data cut short; an image of more
+/// than one bit or component, which JBIG2 doesn't code, even when its rows take as many bytes; and
+/// a page far past the image's raster, which isn't decoded.
+#[test]
+fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() {
+ let white = |rows: u32| pdfcraft_filters::encode_jbig2_generic(&vec![0xFF; 2 * rows as usize], 16, rows).unwrap();
+ // The page information (the first segment's data, after its 11-byte header) claiming 20,000
+ // rows of 16.
+ let mut tall = white(2);
+ tall[15..19].copy_from_slice(&20_000u32.to_be_bytes());
+ let image = |size: &str, more: &str| {
+ format!("/Type /XObject /Subtype /Image {size} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {more}")
+ };
+ let size = "/Width 16 /Height 2";
+ for (dict, data, extra) in [
+ (image(size, ""), white(3), vec![]),
+ (image("/Width 16 /Height 3", ""), white(2), vec![]),
+ (image("/Width 15 /Height 2", ""), white(2), vec![]),
+ (image(size, "/DecodeParms << /JBIG2Globals 8 0 R >>"), white(2), vec![stream("", b"not JBIG2")]),
+ (image(size, ""), white(2)[..20].to_vec(), vec![]),
+ (image(size, "").replace("/BitsPerComponent 1", "/BitsPerComponent 8"), white(2), vec![]),
+ (image(size, "").replace("/DeviceGray", "/DeviceRGB"), white(2), vec![]),
+ // Two pixels of RGB at a bit take a byte a row, as JBIG2's rows do.
+ (
+ image("/Width 2 /Height 2", "").replace("/DeviceGray", "/DeviceRGB"),
+ pdfcraft_filters::encode_jbig2_generic(&[0xFF; 2], 2, 2).unwrap(),
+ vec![],
+ ),
+ (image(size, ""), tall, vec![]),
+ ] {
+ let mut objs = vec![stream(&dict, data)];
+ objs.extend(extra);
+ let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs);
+ mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
+ let report = apply(&mut doc, None).unwrap();
+ assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}");
+ }
+}
+
+/// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is
+/// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals,
+/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller
+/// than Flate, and refers to no globals.
+#[test]
+fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() {
+ const LETTERS: [[&str; 7]; 3] = [
+ ["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
+ [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
+ ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
+ ];
+ let (w, h) = (400usize, 40usize);
+ let stride = w.div_ceil(8);
+ let mut bits = vec![0xFFu8; stride * h];
+ for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
+ for (k, x) in (3..w - 6).step_by(7).enumerate() {
+ for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
+ for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
+ bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
+ }
+ }
+ }
+ }
+ let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
+ let original = coded.clone();
+ assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded");
+ let dict = format!(
+ "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>"
+ );
+ // Half a point a pixel, its top row at y 120.
+ let mut doc = one_page(
+ b"q 200 0 0 20 0 100 cm /Im Do Q",
+ "/XObject << /Im 7 0 R >>",
+ vec![stream(&dict, coded), stream("", jbig2_globals(b"LEAK_GLOBALS"))],
+ );
+ // Columns 0 to 99 of rows 0 to 12.
+ mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
+ let report = apply(&mut doc, None).unwrap();
+ assert_eq!((report.images_cleared, report.images_removed), (1, 0));
+ let mut cleared = bits.clone();
+ for y in 0..=12 {
+ for x in 0..=99 {
+ cleared[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared));
+ assert_no_secret(&doc, b"LEAK_GLOBALS");
+ let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap();
+ assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]));
+ assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals");
+ let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap());
+ assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len());
+ for (r, object) in saved.scan_objects() {
+ if let Object::Stream(s) = &*object {
+ assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}");
+ }
+ }
+}
+
+/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges
+/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above.
+fn ragged_scan(w: usize, h: usize) -> Vec {
+ let stride = w / 8;
+ let mut seed = 0x9E37_79B9_7F4A_7C15u64;
+ let mut next = move || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ let mut bits = vec![0xFFu8; stride * h];
+ for top in (3..h - 14).step_by(20) {
+ let mut x = 4;
+ while x + 70 < w {
+ let len = 20 + (next() % 40) as usize;
+ for y in top..top + 14 {
+ let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize);
+ for xx in left..right {
+ bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8));
+ }
+ }
+ x += len + 6 + (next() % 6) as usize;
+ }
+ }
+ bits
+}
+
+/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when
+/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it
+/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples.
+#[test]
+fn a_cleared_black_and_white_scan_is_stored_as_group_4() {
+ let (w, h) = (800usize, 200usize);
+ let stride = w / 8;
+ let bits = ragged_scan(w, h);
+ let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap();
+ assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here");
+ let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect();
+ for (dict, data, filter) in [
+ (
+ format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"),
+ pdfcraft_filters::encode_flate(&bits),
+ &b"CCITTFaxDecode"[..],
+ ),
+ (
+ format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"),
+ pdfcraft_filters::encode_flate(&grey),
+ &b"FlateDecode"[..],
+ ),
+ ] {
+ let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]);
+ // The left quarter of the top half.
+ mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], "");
+ assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}");
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap();
+ assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}");
+ // Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199.
+ let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() };
+ for y in 0..100 {
+ for x in 0..200 {
+ if filter == b"FlateDecode" {
+ expected[y * w + x] = 0;
+ } else {
+ expected[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ }
+ assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}");
+ }
+}
+
+/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one,
+/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows
+/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear.
+#[test]
+fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() {
+ let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1;
+ for (w, h) in [(3usize, 2usize), (wide, 2)] {
+ let stride = w.div_ceil(8);
+ assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}");
+ let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode");
+ let mut doc = one_page(
+ b"q 200 0 0 20 0 100 cm /Im Do Q",
+ "/XObject << /Im 7 0 R >>",
+ vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))],
+ );
+ // The top row's left half.
+ mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], "");
+ assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}");
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}");
+ let samples = named_image(&saved, 0, b"PCRedacted1").unwrap();
+ let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0);
+ assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept");
+ assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept");
+ }
+}
+
+/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white
+/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the
+/// first rows' worth of bytes, would look far smaller while losing the picture.
+#[test]
+fn a_cleared_image_of_more_than_one_bit_stays_flate() {
+ let (w, h) = (256usize, 256usize);
+ let mut data = vec![0xFF; w * h];
+ let mut state = 0x1234_5678u32;
+ for b in data.iter_mut().skip(w * h / 8) {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ *b = state.to_le_bytes()[0];
+ }
+ let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode");
+ let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]);
+ mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], "");
+ assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1);
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
+ assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept");
+}
+
+/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save,
+/// clears it again rather than removing it whole, keeps what the first cleared, and stores it
+/// smaller than Flate again.
+#[test]
+fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() {
+ let (w, h) = (800usize, 200usize);
+ let stride = w / 8;
+ let bits = ragged_scan(w, h);
+ let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray");
+ for (filter, data, wanted) in [
+ ("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]),
+ ("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]),
+ ] {
+ let dict = format!("{image} /Filter {filter}");
+ let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]);
+ mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], "");
+ assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}");
+ let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}");
+ mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], "");
+ let report = apply(&mut saved, None).unwrap();
+ assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed");
+ let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap();
+ // Rows 0 to 99 of the left quarter, then 100 to 199 of the right.
+ let mut expected = bits.clone();
+ for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] {
+ for y in rows {
+ for x in columns.clone() {
+ expected[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ }
+ let names: Vec> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect();
+ let stored = names.iter().find_map(|n| named_image(&again, 0, n));
+ assert_eq!(stored, Some(expected), "{filter}");
+ }
+}
+
+/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the
+/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to,
+/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2).
+fn jbig2_segments(mut coded: &[u8]) -> Vec> {
+ let mut segments = Vec::new();
+ while !coded.is_empty() {
+ let refers = usize::from(coded[5] >> 5);
+ let header = 4 + 1 + 1 + refers + 1 + 4;
+ let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize;
+ segments.push(coded[..header + length].to_vec());
+ coded = &coded[header + length..];
+ }
+ segments
+}
+
+/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as
+/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a
+/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page
+/// is redacted the second page's image is stored again without the globals, and neither the
+/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel
+/// outside the area.
+#[test]
+fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() {
+ const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"];
+ const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"];
+ let (w, h) = (400usize, 40usize);
+ let stride = w.div_ceil(8);
+ let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| {
+ for (dy, row) in shape.iter().enumerate() {
+ for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
+ bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8));
+ }
+ }
+ };
+ // The letter over and over lower down, and the signature once, top left, under the mark.
+ let mut first = vec![0xFFu8; stride * h];
+ for top in [14usize, 24, 32] {
+ for x in (3..w - 6).step_by(7) {
+ draw(&mut first, &LETTER, top, x);
+ }
+ }
+ draw(&mut first, &SIGNATURE, 2, 10);
+ // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate.
+ let second = ragged_scan(w, h);
+ // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the
+ // second page's segments are numbered past it.
+ let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap());
+ assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary");
+ let mut dictionary = segments.remove(1);
+ dictionary[6] = 0;
+ let page_one: Vec = segments.concat();
+ let page_two: Vec = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap())
+ .into_iter()
+ .enumerate()
+ .flat_map(|(k, mut s)| {
+ s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes());
+ s
+ })
+ .collect();
+ let image = format!(
+ "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>"
+ );
+ // Half a point a pixel, the image's top row at y 120.
+ let page = |content: u32, im: u32| {
+ format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>")
+ .into_bytes()
+ };
+ let build = |second_image: &str| {
+ pdf(vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
+ page(5, 7),
+ page(6, 8),
+ stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()),
+ stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()),
+ stream(&image, page_one.clone()),
+ stream(second_image, page_two.clone()),
+ stream("", dictionary.clone()),
+ ])
+ };
+ let mut doc = build(&image);
+ // A page's picture as it is drawn, JBIG2 read with its globals.
+ let picture = |doc: &Document, page: usize, name: &[u8]| {
+ let p = pdfcraft_model::pages(doc).swap_remove(page);
+ let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
+ let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
+ match &*doc.get(xo.get(name)?.as_ref()?) {
+ Object::Stream(s) => doc.decoded_image(s).ok(),
+ _ => None,
+ }
+ };
+ assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals");
+ assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone()));
+ // Columns 0 to 99 of rows 0 to 12.
+ mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
+ let report = apply(&mut doc, None).unwrap();
+ assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1));
+ let mut cleared = first.clone();
+ for y in 0..=12 {
+ for x in 0..=99 {
+ cleared[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ let saved = reopen(&doc);
+ assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared));
+ assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture");
+ // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate.
+ let restored = named_stream(&saved, 1, b"Im").unwrap();
+ let flate = pdfcraft_filters::encode_flate(&second);
+ assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
+ assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len());
+ // The dictionary's data (after its 11-byte header) codes the signature's bitmap.
+ let coded = &dictionary[11..];
+ for (r, object) in saved.scan_objects() {
+ let mut bytes = Vec::new();
+ pdfcraft_cos::serialize(&object, &mut bytes);
+ assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals");
+ let data = match &*object {
+ Object::Stream(s) => s.decoded().unwrap_or_default(),
+ _ => Vec::new(),
+ };
+ assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}");
+ }
+ // The second page's image can't be read for certain (its JBIG2 page is shorter than the
+ // image), so it can't be stored without the globals: the redaction is refused.
+ let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1)));
+ mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
+ assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1))));
+}
+
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
/// before (inherited from the /Pages node, or shared with another page) still named the removed
/// image and form, so a full-rewrite save kept them.
@@ -2377,7 +2883,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) {
pdfcraft_cos::serialize(&object, &mut bytes);
assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}");
if let Object::Stream(s) = &*object {
- let data = s.decoded().unwrap();
+ // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document.
+ let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap();
assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}");
}
}
diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs
index df6896b980..67b9c86906 100644
--- a/crates/render/src/raster/tests.rs
+++ b/crates/render/src/raster/tests.rs
@@ -802,6 +802,472 @@ fn indexed_palettes_over_lab_keep_their_colours() {
}
assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}");
}
+
+/// A Pattern space's underlying space is any other, named or by reference as well as a direct array
+/// (ISO 32000-2 §8.7.3.3). It was skipped when it was a name or a reference, so an uncoloured tiling
+/// pattern took its components as RGB: a CMYK cyan drew red, and a grey of 0.5 dark red. Each is
+/// compared with the same colour painted without the pattern.
+#[test]
+fn an_uncoloured_pattern_uses_its_underlying_space_in_every_form() {
+ use super::*;
+ let render = |space: &str, content: &str| -> [u8; 4] {
+ let pattern = "0 0 10 10 re f";
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> /Pattern << /P 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream\n{pattern}\nendstream endobj\n\
+ 6 0 obj /DeviceCMYK endobj\n\
+ 7 0 obj /DeviceGray endobj\n\
+ 8 0 obj [/Indexed /DeviceRGB 1 <00FF00FF0000>] endobj\n\
+ 9 0 obj [/Pattern /DeviceCMYK] endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ pattern.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{space}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let fill = "0 0 200 200 re f";
+ let cyan = render("/DeviceCMYK", &format!("/DeviceCMYK cs 1 0 0 0 sc {fill}"));
+ let grey = render("/DeviceGray", &format!("/DeviceGray cs 0.5 sc {fill}"));
+ let green = render("8 0 R", &format!("/C cs 0 sc {fill}"));
+ assert_ne!(cyan[0], 255, "cyan has no red");
+ for (space, colour, want) in [
+ ("[/Pattern /DeviceCMYK]", "1 0 0 0", cyan),
+ ("[/Pattern 6 0 R]", "1 0 0 0", cyan),
+ ("9 0 R", "1 0 0 0", cyan),
+ ("[/Pattern /DeviceGray]", ".5", grey),
+ ("[/Pattern 7 0 R]", ".5", grey),
+ ("[/Pattern 8 0 R]", "0", green),
+ ("[/Pattern [/Indexed /DeviceRGB 1 <00FF00FF0000>]]", "0", green),
+ ] {
+ assert_eq!(render(space, &format!("/C cs {colour} /P scn {fill}")), want, "{space}");
+ }
+}
+
+/// A colour space's parts can refer back to the space, now that a Pattern's underlying space is
+/// followed as an Indexed space's base always was. A file made so ends as a space that can't be
+/// read, and the page draws what it can.
+#[test]
+fn a_colour_space_that_refers_to_itself_ends() {
+ use super::*;
+ let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f";
+ for (space, objects) in [
+ ("5 0 R", "5 0 obj [/Pattern 5 0 R] endobj"),
+ ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Pattern 5 0 R] endobj"),
+ ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Indexed 5 0 R 1 <0011>] endobj"),
+ ("5 0 R", "5 0 obj [/Indexed 5 0 R 1 <0011>] endobj"),
+ ] {
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{objects}: {:?}", page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{objects}: what comes before it drew");
+ }
+}
+
+/// A chain of colour spaces repeats no object, so what ends a cycle lets it through. Each way one
+/// space names another ends, at a depth that leaves room on the stack, and the page draws what it
+/// can.
+#[test]
+fn a_chain_of_colour_spaces_ends() {
+ use super::*;
+ const LINKS: u32 = 20_000;
+ let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f";
+ let link: [(&str, &dyn Fn(u32) -> String); 5] = [
+ ("[/Pattern 10 0 R]", &|next| format!("[/Pattern {next} 0 R]")),
+ ("10 0 R", &|next| format!("[/Indexed {next} 0 R 1 <0011>]")),
+ ("10 0 R", &|next| format!("[/Separation /A {next} 0 R 5 0 R]")),
+ ("10 0 R", &|next| format!("[/DeviceN [/A] {next} 0 R 5 0 R]")),
+ ("[/ICCBased 10 0 R]", &|next| format!("<< /N 1 /Alternate [/ICCBased {next} 0 R] /Length 1 >> stream\nx\nendstream")),
+ ];
+ for (space, link) in link {
+ let objects: String = (0..LINKS).map(|i| format!("{} 0 obj {} endobj\n", 10 + i, link(11 + i))).collect();
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ {objects}{} 0 obj /DeviceGray endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ 10 + LINKS
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{}: {:?}", link(11), page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", link(11));
+ }
+}
+
+/// A file nests colour spaces five levels at most, and the limit is well past that: 31 spaces over
+/// a device space are read and draw their colour, and with one more the space isn't read.
+#[test]
+fn colour_spaces_are_read_to_the_limit_of_their_nesting() {
+ use super::*;
+ let content = "/C cs 1 scn 0 0 200 200 re f";
+ let green = |levels: u32| {
+ let objects: String = (0..levels - 1).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect();
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C 10 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\
+ {objects}{} 0 obj [/Separation /A /DeviceRGB 6 0 R] endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ 10 + levels - 1
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{levels}: {:?}", page.error);
+ page.rgba.get(..4) == Some(&[0, 255, 0, 255][..])
+ };
+ assert!(green(1), "a space on its own");
+ assert!(green(5), "as deep as a legal file goes");
+ assert!(green(31), "the deepest that is read");
+ assert!(!green(32), "one past it");
+}
+
+/// A page with the colour space resources `spaces` and the other objects `objects`, drawn with
+/// `content`. Objects 5 and 6 are tint transforms: the tint itself, and paper to green.
+fn page_with_colour_spaces(spaces: &str, objects: &str, content: &str) -> super::RenderedPage {
+ use super::*;
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << {spaces} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ })
+}
+
+/// What an ICC profile reads as is kept. If what it falls back to were kept with it, a page
+/// selecting the last of a chain first would build each space on the one before, and no read
+/// would be deep: 20,000 levels past the limit, for whatever walks the space to paint with it.
+#[test]
+fn a_chain_built_on_spaces_already_read_ends() {
+ const LINKS: u32 = 20_000;
+ let selected: String = (0..LINKS).rev().map(|i| format!("/C{i} cs\n")).collect();
+ let content = format!("1 0 0 rg 0 0 100 100 re f\n{selected}0 sc 100 100 100 100 re f");
+ let spaces: String = (0..LINKS).map(|i| format!("/C{i} [/ICCBased {} 0 R] ", 10 + i)).collect();
+ let around: [&dyn Fn(&str) -> String; 4] = [
+ &|next| format!("[/Pattern {next}]"),
+ &|next| format!("[/Indexed {next} 1 <0011>]"),
+ &|next| format!("[/Separation /A {next} 5 0 R]"),
+ &|next| format!("[/DeviceN [/A] {next} 5 0 R]"),
+ ];
+ for around in around {
+ let mut objects: String = (0..LINKS)
+ .map(|i| {
+ let alternate = around(&format!("[/ICCBased {} 0 R]", 11 + i));
+ format!("{} 0 obj << /N 1 /Alternate {alternate} /Length 1 >> stream\nx\nendstream endobj\n", 10 + i)
+ })
+ .collect();
+ objects.push_str(&format!("{} 0 obj << /N 1 /Length 1 >> stream\nx\nendstream endobj\n", 10 + LINKS));
+ let page = page_with_colour_spaces(&spaces, &objects, &content);
+ assert!(page.error.is_none(), "{}: {:?}", around("next"), page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", around("next"));
+ }
+}
+
+/// A profile met at the limit can't read its alternate there, which says nothing of a read nearer
+/// the top: the space kept from the first read isn't what the second gets.
+#[test]
+fn a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top() {
+ let chain: String = (0..31).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect();
+ let objects = format!(
+ "{chain}41 0 obj [/ICCBased 7 0 R] endobj\n\
+ 7 0 obj << /N 1 /Alternate [/Separation /A /DeviceRGB 6 0 R] /Length 1 >> stream\nx\nendstream endobj\n"
+ );
+ let page = page_with_colour_spaces("/Deep 10 0 R /Near 41 0 R", &objects, "/Deep cs /Near cs 1 scn 0 0 200 200 re f");
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!(page.rgba.get(..4), Some(&[0, 255, 0, 255][..]));
+}
+
+/// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for
+/// the whole pair, found no pair there, and ended the array, so the image didn't draw at all.
+#[test]
+fn a_decode_array_with_references_in_it_is_read() {
+ use super::*;
+ let render = |width_of: &str, decode: &str, space: &str, data: &str| -> [u8; 4] {
+ let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q";
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width {width_of} /Height 1 /BitsPerComponent 8 /ColorSpace {space} /Decode {decode} /Filter /ASCIIHexDecode /Length {} >> stream\n{data}\nendstream endobj\n\
+ 6 0 obj 1 endobj\n\
+ 7 0 obj 0 endobj\n\
+ 8 0 obj 8 0 R endobj\n\
+ 9 0 obj 10 0 R endobj\n\
+ 10 0 obj 9 0 R endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ data.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{decode}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let grey = render("1", "[1 0]", "/DeviceGray", "40>");
+ assert_eq!(grey, [191, 191, 191, 255], "the direct form inverts the sample");
+ for decode in ["[6 0 R 0]", "[1 7 0 R]", "[6 0 R 7 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceGray", "40>"), grey, "{decode}");
+ }
+ let rgb = render("1", "[1 0 1 0 1 0]", "/DeviceRGB", "204060>");
+ for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}");
+ }
+ // A reference that only leads back to itself holds no number, and reads as one that names
+ // nothing does.
+ let missing = render("1", "[99 0 R 0]", "/DeviceGray", "40>");
+ for decode in ["[8 0 R 0]", "[1 8 0 R]", "[9 0 R 0]", "[1 9 0 R]", "[8 0 R 9 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceGray", "40>"), missing, "{decode}");
+ }
+}
+
+/// An object that holds only a reference stands for what that names, stored on its own or in an
+/// object stream. Read as a number it was the reference's own object number: here 8 names 7, 7
+/// names 6 and 6 holds 1, and that read as 7, which drew white. A chain past the limit, and so a
+/// cycle, reads as a missing object does.
+#[test]
+fn a_reference_to_a_reference_is_what_it_names() {
+ use super::*;
+ let file = |decode: &str, objects: &str| -> Vec {
+ let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q";
+ format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\n40>\nendstream endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes()
+ };
+ let draw = |bytes: Vec| -> [u8; 4] {
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let grey = draw(file("[1 0]", ""));
+ assert_eq!(grey, [191, 191, 191, 255]);
+
+ let chain = "6 0 obj 1 endobj 7 0 obj 6 0 R endobj 8 0 obj 7 0 R endobj";
+ assert_eq!(draw(file("[8 0 R 0]", chain)), grey, "stored on their own");
+ let doc = pdfcraft_cos::Document::open(Arc::new(file("[8 0 R 0]", chain))).unwrap();
+ let packed = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
+ assert!(packed.windows(7).any(|w| w == b"/ObjStm"), "the save packs them");
+ assert_eq!(draw(packed), grey, "stored in an object stream");
+ assert_eq!(draw(file("9 0 R", "9 0 obj 10 0 R endobj 10 0 obj [1 0] endobj")), grey, "not only numbers");
+ let commented = "6 0 obj 1 endobj 7 0 obj 6 %c\n0 R endobj 8 0 obj 7 0 %c\nR endobj";
+ assert_eq!(draw(file("[8 %c\n0 R 0]", commented)), grey, "a comment counts as white space");
+
+ let links = |n: usize| -> String {
+ let links: String = (0..n).map(|i| format!("{} 0 obj {} 0 R endobj\n", 10 + i, 11 + i)).collect();
+ format!("{links}{} 0 obj 1 endobj", 10 + n)
+ };
+ let missing = draw(file("[99 0 R 0]", ""));
+ assert_ne!(missing, grey);
+ assert_eq!(draw(file("[10 0 R 0]", &links(32))), grey, "32 references are followed");
+ assert_eq!(draw(file("[10 0 R 0]", &links(33))), missing, "33 aren't");
+ assert_eq!(draw(file("[6 0 R 0]", "6 0 obj 6 0 R endobj")), missing, "nor is a cycle");
+}
+
+/// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they
+/// lost a reference at the start of a pair the same way, and the array with it. Each form is compared
+/// with the direct one, which differs from the identity so the array is shown to count.
+#[test]
+fn a_functions_arrays_can_start_with_a_reference() {
+ use super::*;
+ let render = |function: String| -> [u8; 4] {
+ let content = "/C cs 0.25 sc 0 0 200 200 re f";
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C [/Separation /X /DeviceRGB 9 0 R] >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj 1 endobj\n6 0 obj 0 endobj\n\
+ 7 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 0 0] /N 1 >> endobj\n\
+ 8 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 1 0] /C1 [0 0 1] /N 1 >> endobj\n\
+ 9 0 obj {function} endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{function}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let sampled = |encode: &str| {
+ format!(
+ "<< /FunctionType 0 /Size [2] /BitsPerSample 8 /Domain [0 1] /Range [0 1 0 1 0 1] /Encode {encode} /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF8040>\nendstream"
+ )
+ };
+ let stitched = |encode: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [7 0 R 8 0 R] /Bounds [.5] /Encode {encode} >>");
+ assert_ne!(render(sampled("[1 0]")), render(sampled("[0 1]")), "/Encode counts");
+ assert_eq!(render(sampled("[5 0 R 0]")), render(sampled("[1 0]")), "a sampled function");
+ assert_ne!(render(stitched("[1 0 0 1]")), render(stitched("[0 1 0 1]")), "/Encode counts");
+ assert_eq!(render(stitched("[5 0 R 0 0 1]")), render(stitched("[1 0 0 1]")), "a stitching function");
+}
+
+/// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can
+/// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it
+/// as a stream used up the reference, so the space couldn't be read and drew in grey.
+#[test]
+fn an_indexed_lookup_table_is_read_in_every_legal_form() {
+ use super::*;
+ let render = |space: &str, content: &str, extra: &str| -> [u8; 4] {
+ let bytes = format!(
+ "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /I {space} >> /XObject << /Im 9 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj <000000FF0000> endobj\n\
+ 6 0 obj << /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF0000>\nendstream endobj\n\
+ 7 0 obj /DeviceRGB endobj\n\
+ 8 0 obj 1 endobj\n\
+ {extra}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{space}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let paint = "/I cs 1 sc 0 0 200 200 re f";
+ // One byte of index 1, as an image in the same space: the image path reads it too.
+ let image = "9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 5 0 R] /Filter /ASCIIHexDecode /Length 3 >> stream\n01>\nendstream endobj";
+ for space in [
+ "[/Indexed /DeviceRGB 1 <000000FF0000>]",
+ "[/Indexed /DeviceRGB 1 5 0 R]",
+ "[/Indexed /DeviceRGB 1 6 0 R]",
+ "[/Indexed 7 0 R 8 0 R 5 0 R]",
+ "[/I /DeviceRGB 1 5 0 R]",
+ ] {
+ assert_eq!(render(space, paint, ""), [255, 0, 0, 255], "{space}");
+ }
+ let indirect = "10 0 obj [/Indexed /DeviceRGB 1 5 0 R] endobj";
+ assert_eq!(render("10 0 R", paint, indirect), [255, 0, 0, 255], "the whole space by reference");
+ assert_eq!(render("/DeviceRGB", "q 200 0 0 200 0 0 cm /Im Do Q", image), [255, 0, 0, 255], "an image's table by reference");
+ // What's neither table form leaves the space unreadable, as a missing table does.
+ for space in ["[/Indexed /DeviceRGB 1 8 0 R]", "[/Indexed /DeviceRGB 1 99 0 R]", "[/Indexed /DeviceRGB 1]"] {
+ let _ = render(space, paint, "");
+ }
+ // So does a table that only leads back to itself, and the read of it ends.
+ let cycle = "11 0 obj 11 0 R endobj 12 0 obj 13 0 R endobj 13 0 obj 12 0 R endobj";
+ for space in ["[/Indexed /DeviceRGB 1 11 0 R]", "[/Indexed /DeviceRGB 1 12 0 R]"] {
+ let _ = render(space, paint, cycle);
+ }
+}
#[test]
fn watchdog_skips_a_stuck_page_and_keeps_rendering() {
use super::*;
@@ -3527,6 +3993,872 @@ fn jbig2_regions_of_billions_of_pixels_are_refused() {
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
+/// One embedded JBIG2 segment (ISO 14492 §7.2) of page 1. More than four references take the long
+/// form, whose retention flags are left clear.
+fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[u8]) {
+ out.extend_from_slice(&number.to_be_bytes());
+ out.push(kind);
+ if refs.len() <= 4 {
+ out.push(u8::try_from(refs.len()).unwrap() << 5);
+ } else {
+ out.extend_from_slice(&(0xE000_0000u32 | u32::try_from(refs.len()).unwrap()).to_be_bytes());
+ out.extend(std::iter::repeat_n(0u8, (refs.len() + 1).div_ceil(8)));
+ }
+ out.extend_from_slice(refs);
+ out.push(1);
+ out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
+ out.extend_from_slice(data);
+}
+
+/// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if
+/// it decodes, draws black whatever its other segments hold.
+fn black_jbig2_page() -> Vec {
+ let mut page = Vec::new();
+ page.extend_from_slice(&16u32.to_be_bytes());
+ page.extend_from_slice(&2u32.to_be_bytes());
+ page.extend_from_slice(&[0; 8]); // resolution
+ page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping
+ let mut stream = Vec::new();
+ jbig2_segment(&mut stream, 0, 48, &[], &page);
+ stream
+}
+
+/// A region's information field, at the page's corner.
+fn jbig2_region_info(width: u32, height: u32) -> Vec {
+ let mut region = Vec::new();
+ region.extend_from_slice(&width.to_be_bytes());
+ region.extend_from_slice(&height.to_be_bytes());
+ region.extend_from_slice(&[0; 9]); // x, y, combination operator
+ region
+}
+
+/// Whether a 16 × 2 JBIG2 image decodes: drawn at 5..25 both ways, it draws black, and refused it
+/// leaves the page white. The red square beside it shows the rest of the page drew either way.
+fn jbig2_image_draws(stream: &[u8]) -> bool {
+ jbig2_image_draws_in(stream, "/DeviceGray")
+}
+
+/// [`jbig2_image_draws`], for an image in colour space `space`.
+fn jbig2_image_draws_in(stream: &[u8], space: &str) -> bool {
+ jbig2_image_draws_with(stream, space, "", b"")
+}
+
+/// [`jbig2_image_draws_in`], with entries for the image's dictionary and more objects, from 6 up.
+fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[u8]) -> bool {
+ let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f";
+ let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
+ pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
+ pdf.extend_from_slice(content);
+ pdf.extend_from_slice(b"\nendstream endobj\n");
+ pdf.extend_from_slice(
+ format!("5 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace {space} {entries} /Filter /JBIG2Decode /Length {} >> stream\n", stream.len())
+ .as_bytes(),
+ );
+ pdf.extend_from_slice(stream);
+ pdf.extend_from_slice(b"\nendstream endobj\n");
+ pdf.extend_from_slice(objects);
+ pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
+ let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
+ // Inside the image: top-down rows 15..35.
+ match &page.rgba[((25 * 40 + 15) * 4)..][..4] {
+ [0, 0, 0, 255] => true,
+ [255, 255, 255, 255] => false,
+ other => panic!("neither the image nor the page: {other:?}"),
+ }
+}
+
+/// Bits written from the top of each byte, as JBIG2's Huffman-coded data is.
+#[derive(Default)]
+struct Bits {
+ bytes: Vec,
+ used: u32,
+}
+
+impl Bits {
+ fn put(&mut self, value: u32, count: u32) {
+ for i in (0..count).rev() {
+ if self.used.is_multiple_of(8) {
+ self.bytes.push(0);
+ }
+ if (value >> i) & 1 != 0 {
+ *self.bytes.last_mut().unwrap() |= 0x80 >> (self.used % 8);
+ }
+ self.used += 1;
+ }
+ }
+
+ fn align(&mut self) {
+ self.used = self.used.div_ceil(8) * 8;
+ }
+
+ /// `value` in table B.1 (ISO 14492 Annex B).
+ fn table_b1(&mut self, value: u32) {
+ match value {
+ 0..=15 => {
+ self.put(0, 1);
+ self.put(value, 4);
+ }
+ 16..=271 => {
+ self.put(0b10, 2);
+ self.put(value - 16, 8);
+ }
+ 272..=65807 => {
+ self.put(0b110, 3);
+ self.put(value - 272, 16);
+ }
+ _ => {
+ self.put(0b111, 3);
+ self.put(value - 65808, 32);
+ }
+ }
+ }
+}
+
+/// A Huffman-coded symbol dictionary of `symbols` symbols of width 0 and height 1, exported or not:
+/// each holds no pixels, and costs a bitmap's header all the same.
+fn zero_width_jbig2_dictionary(symbols: u32, exported: bool) -> Vec {
+ let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1
+ dictionary.extend_from_slice(&(if exported { symbols } else { 0 }).to_be_bytes());
+ dictionary.extend_from_slice(&symbols.to_be_bytes()); // new
+ let mut bits = Bits::default();
+ bits.put(0, 1); // height class delta 1 (B.4)
+ for _ in 0..symbols {
+ bits.put(0, 1); // width delta 0 (B.2)
+ }
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.put(0, 5); // BMSIZE 0, uncompressed (B.1)
+ bits.align();
+ if exported {
+ bits.table_b1(0); // the first export run isn't exported
+ }
+ bits.table_b1(symbols);
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+}
+
+/// A Huffman-coded symbol dictionary of `symbols` symbols of width 1 and no rows, none exported.
+/// Only table B.5 has a negative height class delta, so it gets to no rows from one.
+fn no_row_jbig2_dictionary(symbols: u32) -> Vec {
+ let mut bits = Bits::default();
+ bits.put(0, 1); // height class delta 1 (B.5), a class of no symbols
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.put(0, 5); // BMSIZE 0, uncompressed (B.1)
+ bits.align();
+ bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254
+ bits.put(254, 8);
+ bits.put(0b10, 2); // width delta 1 (B.2)
+ for _ in 1..symbols {
+ bits.put(0, 1); // width delta 0 (B.2)
+ }
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.put(0, 5); // BMSIZE 0, uncompressed (B.1)
+ bits.align();
+ bits.table_b1(symbols); // the first export run, not exported, is all of them
+ let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, B.2, B.1, B.1
+ dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported
+ dictionary.extend_from_slice(&symbols.to_be_bytes()); // new
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+}
+
+/// A Huffman-coded symbol dictionary that climbs to `height`, stays about there for `classes`
+/// classes of no symbols, each with a byte of MMR bitmap, and drops to one row for its one symbol,
+/// not exported.
+fn empty_class_jbig2_dictionary(classes: u32, height: u32) -> Vec {
+ let mut bits = Bits::default();
+ bits.put(0b111110, 6); // height class delta, the upper line of table B.5
+ bits.put(height - 76, 32);
+ let mut at = height;
+ for class in 0..classes {
+ // Table B.5 has no delta of 0, so it goes down one and up one.
+ if class % 2 == 1 {
+ bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254
+ bits.put(254, 8);
+ at -= 1;
+ } else if class > 0 {
+ bits.put(0, 1); // height class delta 1 (B.5)
+ at += 1;
+ }
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.put(1, 5); // BMSIZE 1 (B.1)
+ bits.align();
+ bits.put(0xFF, 8);
+ }
+ bits.put(0b1111111, 7); // height class delta, the lower line of table B.5
+ bits.put(at - 1 - 256, 32);
+ bits.put(0b10, 2); // width delta 1 (B.2)
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.put(0, 5); // BMSIZE 0, uncompressed (B.1)
+ bits.align();
+ bits.put(0, 8); // its row
+ bits.table_b1(1); // the first export run, not exported, is all of them
+ let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, B.2, B.1, B.1
+ dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported
+ dictionary.extend_from_slice(&1u32.to_be_bytes()); // new
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+}
+
+/// A Huffman-coded symbol dictionary of `symbols` white symbols of width 1 and `height` rows, none
+/// exported, for `height` from 76 up.
+fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec {
+ let mut bits = Bits::default();
+ bits.put(0b11111, 5); // height class delta, the upper line of table B.4
+ bits.put(height - 76, 32);
+ bits.put(0b10, 2); // width delta 1 (B.2)
+ for _ in 1..symbols {
+ bits.put(0, 1); // width delta 0 (B.2)
+ }
+ bits.put(0b111111, 6); // end of the class (B.2)
+ let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row
+ bits.table_b1(rows); // BMSIZE (B.1)
+ bits.align();
+ for _ in 0..rows {
+ bits.put(0xFF, 8);
+ }
+ bits.table_b1(symbols); // the first export run, not exported, is all of them
+ let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1
+ dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported
+ dictionary.extend_from_slice(&symbols.to_be_bytes()); // new
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+}
+
+/// An intermediate generic region, white, every row typical: it decodes in a few bytes however
+/// large it is.
+fn jbig2_white_region(width: u32, height: u32) -> Vec {
+ let mut region = jbig2_region_info(width, height);
+ region.push(0x08); // arithmetic coding, template 0, typical prediction
+ region.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels
+ // Every row typical, the first white: pdfcraft-filters' coding of a white 16,384 square.
+ region.extend_from_slice(&[0xb6, 0xc2, 0x1f, 0xff, 0x7f, 0xff, 0xac]);
+ region
+}
+
+/// A pattern dictionary of `patterns` patterns of width 1 and `height` rows, with nothing coded.
+fn jbig2_pattern_dictionary(height: u8, patterns: u32) -> Vec {
+ let mut dictionary = vec![0, 1, height];
+ dictionary.extend_from_slice(&(patterns - 1).to_be_bytes());
+ dictionary
+}
+
+/// A 1 × 1 halftone region over a grid of `width` × `height` cells, all at the region's corner,
+/// coded by MMR or arithmetically.
+fn jbig2_halftone(width: u32, height: u32, mmr: bool) -> Vec {
+ jbig2_halftone_of(1, width, height, mmr)
+}
+
+/// [`jbig2_halftone`] for a square region of `side` pixels.
+fn jbig2_halftone_of(side: u32, width: u32, height: u32, mmr: bool) -> Vec {
+ let mut halftone = jbig2_region_info(side, side);
+ halftone.push(u8::from(mmr)); // template 0
+ halftone.extend_from_slice(&width.to_be_bytes());
+ halftone.extend_from_slice(&height.to_be_bytes());
+ halftone.extend_from_slice(&[0; 12]); // grid offset and vector
+ halftone
+}
+
+/// Each JBIG2 segment's bitmaps are bounded, but nothing bounded what a page's segments hold for
+/// later ones: a stream of a few dozen bytes an intermediate region (16,384 samples a side, every
+/// row typical) held 33 MB each, so a few kilobytes could exhaust memory. Vendored hayro-jbig2
+/// patch: what regions, dictionaries and tables hold is at most 64 MiB (`MAX_RETAINED_WORDS`), two
+/// such squares, which are exactly the bound.
+#[test]
+fn jbig2_regions_held_past_a_pages_bound_are_refused() {
+ let stream = |regions: u32| {
+ let mut stream = black_jbig2_page();
+ for number in 1..=regions {
+ jbig2_segment(&mut stream, number, 36, &[], &jbig2_white_region(16384, 16384));
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(2)), "two such squares fit");
+ assert!(!jbig2_image_draws(&stream(3)), "a third doesn't");
+}
+
+/// A symbol of width 0 holds no pixels, and still costs a bitmap's header: 5 KB of dictionaries of
+/// them held 781 MB, with the bound counting pixels only.
+#[test]
+fn jbig2_symbols_without_pixels_count_towards_a_pages_bound() {
+ let stream = |dictionaries: u32| {
+ let mut stream = black_jbig2_page();
+ let dictionary = zero_width_jbig2_dictionary(32767, true);
+ for number in 1..=dictionaries {
+ jbig2_segment(&mut stream, number, 0, &[], &dictionary);
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(1)), "one dictionary fits");
+ assert!(!jbig2_image_draws(&stream(44)), "44 × 32,767 headers are past the bound");
+}
+
+/// A symbol dictionary may retain its coding contexts for a later one, 73,728 bytes each time, and
+/// 1.1 MB of dictionaries with no symbols, 103 KB behind Flate, held 2.89 GB.
+#[test]
+fn jbig2_retained_contexts_count_towards_a_pages_bound() {
+ let stream = |dictionaries: u32| {
+ let mut dictionary = vec![0x02, 0x00]; // arithmetic coding, contexts retained
+ dictionary.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels
+ dictionary.extend_from_slice(&[0; 8]); // exported, new
+ let mut stream = black_jbig2_page();
+ for number in 1..=dictionaries {
+ jbig2_segment(&mut stream, number, 0, &[], &dictionary);
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(1)), "one dictionary fits");
+ assert!(!jbig2_image_draws(&stream(1000)), "a thousand don't");
+}
+
+/// Huffman tables are held for later segments too, each taking some 586 bytes however small it
+/// is, so 200,000 of them are past the bound, and they were held to it at 4 times that.
+#[test]
+fn jbig2_huffman_tables_count_towards_a_pages_bound() {
+ let stream = |tables: u32| {
+ // One-bit prefix and range lengths, values 0 to 1: a table line, then the lower range.
+ let mut table = vec![0];
+ table.extend_from_slice(&0i32.to_be_bytes());
+ table.extend_from_slice(&1i32.to_be_bytes());
+ table.push(0b1010_0000);
+ let mut stream = black_jbig2_page();
+ for number in 1..=tables {
+ jbig2_segment(&mut stream, number, 53, &[], &table);
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(1)), "one table fits");
+ assert!(!jbig2_image_draws(&stream(200_000)), "200,000 don't");
+}
+
+/// New symbols count as they are decoded, whatever the dictionary exports: rows take a word at
+/// least, so symbols of one column held 270 MiB within the bound on a dictionary's pixels, and
+/// exporting none of them they were never counted at all.
+#[test]
+fn jbig2_new_symbols_count_towards_a_pages_bound() {
+ let stream = |height: u32| {
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 0, &[], &narrow_jbig2_dictionary(65534, height));
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(100)), "65,534 symbols of 100 rows fit");
+ assert!(!jbig2_image_draws(&stream(300)), "of 300 rows they don't");
+}
+
+/// New symbols are held to what the page has left, headers included, not to the whole bound. These
+/// two regions leave it 512 words, and the dictionary after them exports nothing, so only that
+/// check can refuse it.
+#[test]
+fn jbig2_new_symbols_are_held_to_what_a_page_has_left() {
+ let stream = |dictionary: &[u8]| {
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 36, &[], &jbig2_white_region(16384, 16384));
+ jbig2_segment(&mut stream, 2, 36, &[], &jbig2_white_region(16384, 16383));
+ jbig2_segment(&mut stream, 3, 0, &[], dictionary);
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 100))), "100 rows fit");
+ assert!(!jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 1000))), "1,000 rows don't");
+ assert!(jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(10, false))), "10 headers fit");
+ assert!(!jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(100, false))), "100 headers don't");
+}
+
+/// Pattern dictionaries count towards the bound, with no shifted copies to do it for them: at 44
+/// words a pattern, 6 of these are past it.
+#[test]
+fn jbig2_pattern_dictionaries_count_towards_a_pages_bound() {
+ let stream = |dictionaries: u32| {
+ let mut stream = black_jbig2_page();
+ for number in 1..=dictionaries {
+ jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 65535));
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(5)), "5 fit");
+ assert!(!jbig2_image_draws(&stream(6)), "6 don't");
+}
+
+/// Shifted copies are only a speed-up, so they are never what gets an image refused: each of these
+/// tables is within its own limit, and the fourth won't fit what the page has left, so it is left
+/// out, where counting it refused the image.
+#[test]
+fn jbig2_shifted_patterns_never_refuse_an_image() {
+ let mut stream = black_jbig2_page();
+ for number in 1..=4 {
+ jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 2048));
+ }
+ assert!(jbig2_image_draws(&stream));
+}
+
+/// Ordinary globals still reach the decoder, with a filter on them or without: here they hold the
+/// page information, so the image draws only if they are read.
+#[test]
+fn jbig2_globals_are_read() {
+ let globals = black_jbig2_page();
+ let hex: String = globals.iter().map(|b| format!("{b:02x}")).collect();
+ let mut stream = Vec::new();
+ jbig2_segment(&mut stream, 1, 62, &[], &[]); // an extension, which decoders skip
+ for (filter, data) in [("", globals.clone()), ("/Filter /ASCIIHexDecode", hex.into_bytes())] {
+ let mut objects = format!("6 0 obj << {filter} /Length {} >> stream\n", data.len()).into_bytes();
+ objects.extend_from_slice(&data);
+ objects.extend_from_slice(b"\nendstream endobj\n");
+ assert!(jbig2_image_draws_with(&stream, "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects), "{filter}");
+ }
+}
+
+/// Globals are raw segments, but a `/JBIG2Globals` stream could itself be JBIG2, naming the next
+/// such stream, and each read decoded the next: 4,000 of them, here on a test thread's 2 MiB stack,
+/// overflowed it, which aborts the process. Vendored hayro-syntax patch: such a stream reads as
+/// globals that can't be decoded, so the image decodes without them.
+#[test]
+fn jbig2_globals_that_are_jbig2_themselves_are_not_decoded() {
+ let links = 4000;
+ let mut objects = Vec::new();
+ for number in 0..links {
+ let link = 6 + number;
+ let next = link + 1;
+ let parms = if number + 1 < links { format!("/DecodeParms << /JBIG2Globals {next} 0 R >>") } else { String::new() };
+ objects.extend_from_slice(format!("{link} 0 obj << /Filter /JBIG2Decode {parms} /Length 0 >> stream\n\nendstream endobj\n").as_bytes());
+ }
+ assert!(jbig2_image_draws_with(&black_jbig2_page(), "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects));
+}
+
+/// A Huffman-coded symbol dictionary of one white symbol, `side` from 76 up, exported or not.
+fn square_jbig2_dictionary(side: u32, exported: bool) -> Vec {
+ let mut bits = Bits::default();
+ bits.put(0b11111, 5); // height class delta, the upper line of table B.4
+ bits.put(side - 76, 32);
+ bits.put(0b111110, 6); // width delta, the upper line of table B.2
+ bits.put(side - 75, 32);
+ bits.put(0b111111, 6); // end of the class (B.2)
+ let bytes = side.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row
+ bits.table_b1(bytes); // BMSIZE (B.1)
+ bits.align();
+ for _ in 0..bytes {
+ bits.put(0xFF, 8);
+ }
+ if exported {
+ bits.table_b1(0);
+ }
+ bits.table_b1(1);
+ let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1
+ dictionary.extend_from_slice(&u32::from(exported).to_be_bytes());
+ dictionary.extend_from_slice(&1u32.to_be_bytes()); // new
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+}
+
+/// A text region of `width` × `height` placing symbol 0 of one, Huffman-coded, `instances` of 0 or 1.
+fn jbig2_text_region(width: u32, height: u32, instances: u32) -> Vec {
+ let mut text = jbig2_region_info(width, height);
+ text.extend_from_slice(&[0x00, 0x11, 0, 0]); // Huffman, top left; tables B.6, B.8, B.11
+ text.extend_from_slice(&instances.to_be_bytes());
+ let mut bits = Bits::default();
+ for code in 0..35 {
+ bits.put(u32::from(code == 1), 4); // only run code 1, one bit long
+ }
+ bits.put(0, 1); // symbol 0 is one bit long
+ bits.align();
+ bits.put(0, 1); // the first strip's T (B.11)
+ if instances > 0 {
+ bits.put(0, 1); // delta T 1 (B.11)
+ bits.put(0, 9); // first S 0 (B.6)
+ bits.put(0, 1); // symbol 0
+ bits.put(0b01, 2); // end of the strip (B.8)
+ }
+ text.extend_from_slice(&bits.bytes);
+ text
+}
+
+/// A 1 × 1 text region placing one instance of symbol 0 of one, refined, Huffman-coded.
+fn jbig2_refined_text_region() -> Vec {
+ let mut text = jbig2_region_info(1, 1);
+ text.extend_from_slice(&[0x80, 0x13, 0, 0]); // Huffman, refined by template 1, top left
+ text.extend_from_slice(&1u32.to_be_bytes());
+ let mut bits = Bits::default();
+ for code in 0..35 {
+ bits.put(u32::from(code == 1), 4); // only run code 1, one bit long
+ }
+ bits.put(0, 1); // symbol 0 is one bit long
+ bits.align();
+ bits.put(0, 1); // the first strip's T (B.11)
+ bits.put(0, 1); // delta T 1 (B.11)
+ bits.put(0, 9); // first S 0 (B.6)
+ bits.put(0, 1); // symbol 0
+ bits.put(1, 1); // refined
+ bits.put(0, 4); // no change of width, height or offset (B.14)
+ bits.table_b1(0); // no refinement data (B.1)
+ bits.align();
+ bits.put(0b01, 2); // end of the strip (B.8)
+ text.extend_from_slice(&bits.bytes);
+ text
+}
+
+/// A generic refinement region of 1 × 1, with whatever its data decodes to.
+fn jbig2_refinement_region() -> Vec {
+ let mut region = jbig2_region_info(1, 1);
+ region.push(1); // template 1, no typical prediction
+ region.extend_from_slice(&[0; 4]);
+ region
+}
+
+/// A class of no symbols has nothing to cut from its bitmap, and rows are charged by the symbol, so
+/// decoding one would be rows for nothing: a thousand classes of 65,000 rows take a second, from a
+/// dictionary of 4 KB. It isn't decoded, however many there are, and the symbol after them shows that
+/// its bytes are stepped over.
+#[test]
+fn jbig2_classes_of_no_symbols_decode_no_bitmap() {
+ let mut stream = black_jbig2_page();
+ for number in 0..1_000 {
+ jbig2_segment(&mut stream, number + 1, 0, &[], &empty_class_jbig2_dictionary(1024, 65_000));
+ }
+ let (done, finished) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let _ = done.send(jbig2_image_draws(&stream));
+ });
+ // This takes milliseconds now and took a quarter of an hour, so a margin this wide can't fail on
+ // a busy machine.
+ assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true));
+}
+
+/// Each item a JBIG2 stream decodes costs its work, and the stream may ask for 2^30: four of the
+/// largest pages, some 6 s to 17 s. Each bitmap is bounded on its own, but nothing bounded how many
+/// there were, so 1,140 bytes of generic regions took over 40 s, and a 795-byte PDF's one halftone
+/// 265 s. Vendored hayro-jbig2 patch: `MAX_WORK`. A row costs 4 pixels, even in a bitmap of no
+/// width, and a copy goes by the word and the row, so a tall thin one costs its rows. Here regions
+/// of typical rows, cheap to decode though charged like any, spend all but exactly what the item
+/// costs, so it draws with that left and is refused with one less, for each item at its own cost.
+#[test]
+fn jbig2_decoding_work_is_counted_item_by_item() {
+ struct Item {
+ what: &'static str,
+ /// What comes first, and what it costs.
+ before: Vec<(u8, Vec)>,
+ spent: u64,
+ kind: u8,
+ refs: &'static [u8],
+ data: Vec,
+ cost: u64,
+ }
+ let stream = |item: &Item, left: u64| {
+ let mut stream = black_jbig2_page();
+ let mut number = 0;
+ for (kind, data) in &item.before {
+ number += 1;
+ jbig2_segment(&mut stream, number, *kind, &[], data);
+ }
+ // A square costs 16,384 rows of 16,388, and a single row what it has and 4 more.
+ let work = (1u64 << 30) - item.spent - left;
+ let (row, square) = (16388u64, 16384 * 16388u64);
+ let mut squares = work / square;
+ let mut rest = work % square;
+ if rest > 0 && rest < 5 {
+ squares -= 1;
+ rest += square;
+ }
+ let mut rows = rest / row;
+ let mut last = rest % row;
+ if last > 0 && last < 5 {
+ rows -= 1;
+ last += row;
+ }
+ let regions = std::iter::repeat_n((16384, 16384), usize::try_from(squares).unwrap())
+ .chain((rows > 0).then_some((16384, rows)))
+ .chain((last > 0).then_some((last - 4, 1)));
+ for (width, height) in regions {
+ number += 1;
+ let region = jbig2_white_region(u32::try_from(width).unwrap(), u32::try_from(height).unwrap());
+ jbig2_segment(&mut stream, number, 38, &[], ®ion); // immediate generic region
+ }
+ jbig2_segment(&mut stream, number + 1, item.kind, item.refs, &item.data);
+ stream
+ };
+ let symbol = || vec![(0, zero_width_jbig2_dictionary(1, true))];
+ let patterns = || vec![(16, jbig2_pattern_dictionary(1, 2))];
+ let exporting_none = {
+ let mut dictionary = vec![0x00, 0x01];
+ dictionary.extend_from_slice(&[0; 8]); // exported, new
+ let mut bits = Bits::default();
+ bits.table_b1(1); // the first export run, not exported, is the one it refers to
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+ };
+ let zero = |height: u32| {
+ let mut bits = Bits::default();
+ bits.put(0b11111, 5); // height class delta, the upper line of table B.4
+ bits.put(height - 76, 32);
+ bits.put(0, 1); // width delta 0 (B.2)
+ bits.put(0b111111, 6); // end of the class (B.2)
+ bits.table_b1(0); // BMSIZE 0, stored (B.1)
+ bits.align();
+ bits.table_b1(1); // the first export run, not exported, is the symbol
+ let mut dictionary = vec![0x00, 0x01];
+ dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported
+ dictionary.extend_from_slice(&1u32.to_be_bytes()); // new
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+ };
+ // The cost of a pattern dictionary of two patterns: rows of them side by side, then each taken out.
+ let two_patterns = |width: u64, height: u64| height * (2 * width + 4) + 2 * height * (width.div_ceil(32) + 1);
+ let thin_region = |side: u32| {
+ let mut region = jbig2_white_region(1, 1);
+ region.splice(0..8, [0u32.to_be_bytes(), side.to_be_bytes()].concat());
+ region
+ };
+ let items = vec![
+ Item { what: "immediate generic", before: vec![], spent: 0, kind: 38, refs: &[], data: jbig2_white_region(1, 1), cost: 5 },
+ Item { what: "intermediate generic", before: vec![], spent: 0, kind: 36, refs: &[], data: jbig2_white_region(1, 1), cost: 5 },
+ Item { what: "a region of no width", before: vec![], spent: 0, kind: 38, refs: &[], data: thin_region(1000), cost: 4000 },
+ Item { what: "immediate refinement", before: vec![], spent: 0, kind: 42, refs: &[], data: jbig2_refinement_region(), cost: 5 },
+ Item { what: "intermediate refinement", before: vec![], spent: 0, kind: 40, refs: &[], data: jbig2_refinement_region(), cost: 5 },
+ Item {
+ what: "pattern dictionary",
+ before: vec![],
+ spent: 0,
+ kind: 16,
+ refs: &[],
+ data: jbig2_pattern_dictionary(1, 2),
+ cost: two_patterns(1, 1),
+ },
+ Item {
+ what: "tall patterns",
+ before: vec![],
+ spent: 0,
+ kind: 16,
+ refs: &[],
+ data: jbig2_pattern_dictionary(32, 2),
+ cost: two_patterns(1, 32),
+ },
+ // What an item reads counts, at 38 an integer: two classes, five widths and an export run.
+ Item { what: "symbols of no rows", before: vec![], spent: 0, kind: 0, refs: &[], data: no_row_jbig2_dictionary(5), cost: 304 },
+ // Rows cost in a symbol of no pixels too, on top of a class, three widths and an export run.
+ Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 202 },
+ Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4114 },
+ Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6834 },
+ Item { what: "referred symbols", before: symbol(), spent: 156, kind: 0, refs: &[1], data: exporting_none, cost: 39 },
+ // Its symbol, and its one row and word; Huffman-coded, a table line for the symbol.
+ Item { what: "immediate text", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 },
+ Item { what: "intermediate text", before: symbol(), spent: 156, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 },
+ Item { what: "a text region's pixels", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 },
+ Item {
+ what: "an intermediate text region's pixels",
+ before: symbol(),
+ spent: 156,
+ kind: 4,
+ refs: &[1],
+ data: jbig2_text_region(32, 32, 0),
+ cost: 75,
+ },
+ // The region's 13, a copy of the symbol's row, and the instance's 4 integers.
+ Item { what: "an instance", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 166 },
+ // The region's 13, 80 rows of 4 words copied, and the instance's 4 integers.
+ Item {
+ what: "an instance of a large symbol",
+ before: vec![(0, square_jbig2_dictionary(80, true))],
+ spent: 6872,
+ kind: 6,
+ refs: &[1],
+ data: jbig2_text_region(1, 1, 1),
+ cost: 485,
+ },
+ // And decoded as well as copied, with the 4 integers that gave its size.
+ Item {
+ what: "a refined instance",
+ before: vec![(0, square_jbig2_dictionary(80, true))],
+ spent: 6872,
+ kind: 6,
+ refs: &[1],
+ data: jbig2_refined_text_region(),
+ cost: 7357,
+ },
+ // A bit plane, a cell's copy and the region's.
+ Item {
+ what: "immediate halftone",
+ before: patterns(),
+ spent: two_patterns(1, 1),
+ kind: 22,
+ refs: &[1],
+ data: jbig2_halftone(1, 1, false),
+ cost: 9,
+ },
+ Item {
+ what: "intermediate halftone",
+ before: patterns(),
+ spent: two_patterns(1, 1),
+ kind: 20,
+ refs: &[1],
+ data: jbig2_halftone(1, 1, false),
+ cost: 9,
+ },
+ // A plane of 2 cells, each a copy of 32 rows of 2 words' cost, and the region's.
+ Item {
+ what: "a halftone's cells",
+ before: vec![(16, jbig2_pattern_dictionary(32, 2))],
+ spent: two_patterns(1, 32),
+ kind: 22,
+ refs: &[1],
+ data: jbig2_halftone(2, 1, false),
+ cost: 136,
+ },
+ Item {
+ what: "a grid of no width",
+ before: patterns(),
+ spent: two_patterns(1, 1),
+ kind: 22,
+ refs: &[1],
+ data: jbig2_halftone(0, 1000, false),
+ cost: 4002,
+ },
+ Item {
+ what: "a halftone's pixels",
+ before: patterns(),
+ spent: two_patterns(1, 1),
+ kind: 22,
+ refs: &[1],
+ data: jbig2_halftone_of(32, 1, 1, false),
+ cost: 71,
+ },
+ ];
+ for item in &items {
+ assert!(jbig2_image_draws(&stream(item, item.cost)), "{}: with its cost left it draws", item.what);
+ assert!(!jbig2_image_draws(&stream(item, item.cost - 1)), "{}: with one less it's refused", item.what);
+ }
+}
+
+/// An image's page header can claim any size, and the Indexed path reserves its output from it
+/// before the page bitmap is sized: a 743-byte PDF asked for 137 GB, which aborts the process.
+/// Vendored hayro-jbig2 patch: a page past the bounds is refused when the stream is opened.
+#[test]
+fn jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them() {
+ let indexed = "[/Indexed /DeviceGray 1 <00FF>]";
+ assert!(jbig2_image_draws_in(&black_jbig2_page(), indexed), "a page within the bounds");
+ let mut page = Vec::new();
+ page.extend_from_slice(&(1u32 << 20).to_be_bytes());
+ page.extend_from_slice(&(1u32 << 20).to_be_bytes());
+ page.extend_from_slice(&[0; 11]); // resolution, flags, striping
+ let mut stream = Vec::new();
+ jbig2_segment(&mut stream, 0, 48, &[], &page);
+ assert!(!jbig2_image_draws_in(&stream, indexed), "the page draws without it");
+}
+
+/// One custom Huffman table could grow without bound as it was read, before anything counted it:
+/// a line takes 2 bits of input and 8 bytes here, so 250 MiB behind nested Flate, in a 1,297-byte
+/// PDF, was killed at 8 GiB. Vendored hayro-jbig2 patch: at most 2^16 lines.
+#[test]
+fn jbig2_huffman_tables_of_too_many_lines_are_refused() {
+ let stream = |lines: i32| {
+ // One-bit prefix and range lengths, all zero: a line per value.
+ let mut table = vec![0];
+ table.extend_from_slice(&0i32.to_be_bytes());
+ table.extend_from_slice(&lines.to_be_bytes());
+ table.resize(table.len() + usize::try_from(lines).unwrap().div_ceil(4) + 1, 0);
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 53, &[], &table);
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(1 << 16)), "2^16 lines are allowed");
+ assert!(!jbig2_image_draws(&stream((1 << 16) + 1)), "one more isn't");
+}
+
+/// A stream's segments are all parsed and sorted before the first is decoded: 11 bytes of input took
+/// 64 here, so behind nested Flate a 4,765-byte PDF reached 5 GB, and failed only afterwards.
+/// Vendored hayro-jbig2 patch: at most 2^20 segments.
+#[test]
+fn jbig2_streams_of_too_many_segments_are_refused() {
+ let stream = |segments: u32| {
+ let mut stream = black_jbig2_page();
+ for number in 1..segments {
+ jbig2_segment(&mut stream, number, 62, &[], &[]); // an extension, which decoders skip
+ }
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(1 << 20)), "2^20 segments are allowed");
+ assert!(!jbig2_image_draws(&stream((1 << 20) + 1)), "one more isn't");
+}
+
+/// References take 4 bytes each for 1 of input, a stream's total as much as a segment's.
+/// Vendored hayro-jbig2 patch: at most 2^22 in a stream, which is 64 × 65,535 + 64.
+#[test]
+fn jbig2_streams_of_too_many_references_are_refused() {
+ let stream = |last: usize| {
+ let mut stream = black_jbig2_page();
+ for number in 1..=64 {
+ jbig2_segment(&mut stream, number, 62, &vec![0u8; 65535], &[]);
+ }
+ jbig2_segment(&mut stream, 65, 62, &vec![0u8; last], &[]);
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(64)), "2^22 references are allowed");
+ assert!(!jbig2_image_draws(&stream(65)), "one more isn't");
+}
+
+/// One segment may refer to the same dictionary again and again, and collecting its symbols took a
+/// pointer each: 65,535 references to a dictionary of 32,767 symbols, in a 78 KB stream, asked for
+/// 17 GB. Vendored hayro-jbig2 patch: at most 2^20 symbols per segment, and 32 × 32,767 + 32 is
+/// exactly that. All three segment kinds that take symbols are held to it: the text regions, whether
+/// immediate or intermediate, and a dictionary that refers to others.
+#[test]
+fn jbig2_segments_referring_to_too_many_symbols_are_refused() {
+ let mut text = jbig2_region_info(1, 1);
+ text.extend_from_slice(&[0, 0]); // arithmetic coding, no refinement
+ text.extend_from_slice(&[0; 4]); // no instances
+ // A dictionary of no symbols of its own, exporting none of those it refers to.
+ let dictionary = |symbols: u32| {
+ let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1
+ dictionary.extend_from_slice(&[0; 8]); // exported, new
+ let mut bits = Bits::default();
+ bits.table_b1(symbols); // the first export run, not exported, is all of them
+ dictionary.extend_from_slice(&bits.bytes);
+ dictionary
+ };
+ let stream = |kind: u8, small: usize, data: &[u8]| {
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 0, &[], &zero_width_jbig2_dictionary(32767, true));
+ jbig2_segment(&mut stream, 2, 0, &[], &zero_width_jbig2_dictionary(32, true));
+ let mut refs = vec![1u8; 32];
+ refs.extend(std::iter::repeat_n(2u8, small));
+ jbig2_segment(&mut stream, 3, kind, &refs, data);
+ stream
+ };
+ let limit = 1 << 20;
+ for (what, kind, allowed, past) in [
+ ("immediate text region", 6, text.clone(), text.clone()),
+ ("intermediate text region", 4, text.clone(), text.clone()),
+ ("symbol dictionary", 0, dictionary(limit), dictionary(limit + 32)),
+ ] {
+ assert!(jbig2_image_draws(&stream(kind, 1, &allowed)), "{what}: 2^20 symbols are allowed");
+ assert!(!jbig2_image_draws(&stream(kind, 2, &past)), "{what}: 32 more aren't");
+ }
+}
+
+/// A halftone grid takes a 4-byte value per cell while it decodes, so the bound of 2^28 cells let a
+/// 98-byte segment take 1 GiB. Vendored hayro-jbig2 patch: at most 2^25 cells, however it's coded.
+#[test]
+fn jbig2_halftone_grids_past_their_bound_are_refused() {
+ let stream = |width: u32, height: u32, mmr: bool| {
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(1, 2));
+ jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(width, height, mmr));
+ stream
+ };
+ assert!(jbig2_image_draws(&stream(8192, 4096, false)), "2^25 cells are allowed");
+ assert!(!jbig2_image_draws(&stream(8193, 4096, false)), "one more column isn't");
+ assert!(!jbig2_image_draws(&stream(8193, 4096, true)), "nor when it is MMR-coded");
+}
+
+/// A pattern dictionary's shifted copies only speed up drawing, and 48 bytes could ask for 539 MB
+/// of them, past what a page may hold. Vendored hayro-jbig2 patch: past 16 MiB they are left out,
+/// so such a dictionary is drawn the slow way rather than refused.
+#[test]
+fn jbig2_pattern_dictionaries_too_large_to_shift_still_draw() {
+ let mut stream = black_jbig2_page();
+ jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(32, 65535));
+ jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(1, 1, false));
+ assert!(jbig2_image_draws(&stream));
+}
+
/// From `cargo xtask fuzz`: one changed byte in a symbol dictionary's arithmetic-coded data
/// hung the renderer. Two of its loops can stop advancing: an export run of length 0, and a
/// height class whose first width is already the end-of-class marker. Past the end of its
@@ -3591,6 +4923,86 @@ fn jbig2_symbol_dictionaries_that_never_advance_terminate() {
}
}
+/// A JBIG2 stream can put a region, a reference or a symbol at the very end of what an `i32` holds,
+/// where adding to it overflowed: a panic wherever overflow is checked, a build's own tests included.
+/// A region or a symbol out there is off the page, so nothing draws. Vendored hayro-jbig2 patch: the arithmetic wraps, as
+/// a release build's did, or in `Bitmap::combine` saturates, which comes to the same. Three of the text
+/// streams are arithmetic-coded: one symbol on a strip of -2^31 + 1, with its bottom left as the corner
+/// placed, one on a strip of -2^31, and one refined by an offset of `i32::MIN`, which reads nothing of
+/// the old symbol and still draws. Two are Huffman-coded, each with its first S at an offset of
+/// 0x7FFFFFFF on a table's 32-bit range line, past what an `i32` holds, and are refused further on.
+#[test]
+fn jbig2_placements_at_the_end_of_an_i32_draw_nothing() {
+ const FAR_CORNER: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdc3ce0205df55c36bfffac00000002062001010000001f000000100000000c000000000000000004000000000001969747cbe88bffac";
+ const STRIP: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda25e46956d3e0d3fffac00000002062001010000001f000000100000000c000000000000000004001000000001969747cbe5dbffac";
+ const UPPER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003f7fffffff0000000000000000";
+ const LOWER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003e7fffffff0000000000000000";
+ const OFFSET: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda9172f614a037ddfffac0000000206200101000000260000000800000006000000000000000004801200000001a9695690b3e5f44a2f105e30d3ffac";
+ let segment = |number: u32, kind: u8, data: &[u8]| {
+ let mut out = number.to_be_bytes().to_vec();
+ out.extend_from_slice(&[kind, 0, 1]);
+ out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
+ out.extend_from_slice(data);
+ out
+ };
+ let side = 8u32.to_be_bytes();
+ let page = |width: u32| {
+ let mut page = [width.to_be_bytes(), side].concat();
+ page.extend_from_slice(&[0; 8]); // resolution
+ page.extend_from_slice(&[0, 0, 0]); // flags, striping
+ segment(0, 48, &page)
+ };
+ let region = |width: u32, kind: u8, x: u32, y: u32, header: &[u8]| {
+ let mut region = [width.to_be_bytes(), side, x.to_be_bytes(), y.to_be_bytes()].concat();
+ region.push(0); // combination operator
+ region.extend_from_slice(header);
+ region.extend_from_slice(&[0x5a; 64]);
+ [page(width), segment(1, kind, ®ion)].concat()
+ };
+ let generic = |x, y| region(8, 38, x, y, &[0, 3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // template 0, AT pixels
+ // Immediate refinements of the page: template 0 with its usual AT pixels, with others, with typical
+ // prediction, and template 1.
+ let refined = |width, x, y, form: usize| {
+ region(width, 42, x, y, [&[0, 0xff, 0xff, 0xff, 0xff][..], &[0, 0xff, 0xff, 127, 127], &[2, 0xff, 0xff, 0xff, 0xff], &[1]][form])
+ };
+ let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>();
+ let ink = |jbig2: Vec, width: u32, height: u32| {
+ let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q";
+ let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
+ pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
+ pdf.extend_from_slice(content);
+ pdf.extend_from_slice(b"\nendstream endobj\n");
+ pdf.extend_from_slice(
+ format!("5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len())
+ .as_bytes(),
+ );
+ pdf.extend_from_slice(&jbig2);
+ pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
+ let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
+ };
+ assert!(ink(generic(2, 0), 8, 8) > 0, "a region on the page draws");
+ for form in 0..4 {
+ assert!(ink(refined(8, 0, 0, form), 8, 8) > 0, "a refinement on the page draws: {form}");
+ }
+ let end = 0x7FFF_FFFFu32;
+ for (x, y) in [(end, 0), (0, end), (end - 7, 0), (0, end - 7), (end - 129, 0), (0, end - 129)] {
+ assert_eq!(ink(generic(x, y), 8, 8), 0, "generic at {x:#x}, {y:#x}");
+ for form in 0..4 {
+ assert_eq!(ink(refined(8, x, y, form), 8, 8), 0, "refinement {form} at {x:#x}, {y:#x}");
+ }
+ }
+ // Past a 32-bit word of the row, where the reference is read again.
+ assert_eq!(ink(refined(64, 0, end, 0), 64, 8), 0, "a wide refinement");
+ assert_eq!(ink(hex(FAR_CORNER), 16, 12), 0, "a symbol's far side past the end");
+ assert_eq!(ink(hex(STRIP), 16, 12), 0, "a strip of -2^31");
+ assert!(ink(hex(OFFSET), 16, 12) > 0, "a symbol refined by an offset of i32::MIN");
+ assert_eq!(ink(hex(UPPER_LINE), 16, 12), 0, "an upper range line past an i32");
+ assert_eq!(ink(hex(LOWER_LINE), 16, 12), 0, "a lower range line past an i32");
+}
+
/// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`,
/// so everything after the cut was lost (the text and the title block of the A1 sample). Content
/// that expands by a few times over is read to its end, however large it is.
@@ -4864,6 +6276,77 @@ fn interpolated_minified_image_edges_snap_to_whole_pixels() {
}
}
+/// #5530: an image placed turned or mirrored (by its own matrix or the page's `/Rotate`) snaps to
+/// whole pixels just as an upright one does. Before, only upright images were snapped, so a turned
+/// scan was resampled twice and every edge was a blended, soft pixel. This checks the edges, and
+/// which way a mirrored image faces; `rotated_image_snap` in the integration tests checks which
+/// way a turned page's image faces (`/Rotate` only turns, it never mirrors).
+#[test]
+fn a_turned_or_mirrored_minified_image_snaps_to_whole_pixels() {
+ let black = vec![0u8; 40 * 40];
+ let image = hex(&black);
+ // Each matrix maps the image onto the user box 0.6..10.6 × 0.4..10.4, a different way round.
+ for matrix in [
+ "0 10 -10 0 10.6 0.4", // a quarter turn
+ "-10 0 0 -10 10.6 10.4", // a half turn
+ "0 -10 10 0 0.6 10.4", // three quarters
+ "-10 0 0 10 10.6 0.4", // mirrored left to right
+ ] {
+ let content = format!("q {matrix} cm /Im0 Do Q");
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ image.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{matrix}: {:?}", page.error);
+ assert_eq!((page.width, page.height), (11, 11));
+ let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
+ // Device box 0.6..10.6 × 0.6..10.6 snaps to columns 1..11 and rows 1..11: no soft edge.
+ for y in 0..11 {
+ for x in 0..11 {
+ let inside = (1..11).contains(&x) && (1..11).contains(&y);
+ assert_eq!(gray(x, y), if inside { 0 } else { 255 }, "{matrix}: pixel ({x}, {y})");
+ }
+ }
+ }
+
+ // A uniform image looks the same mirrored, so mirror one whose left half (source columns
+ // 0..20) is black: drawn left to right reversed, the black half is on the right.
+ let halves: Vec = (0..40).flat_map(|_| (0..40).map(|x| if x < 20 { 0 } else { 255 })).collect();
+ let halves = hex(&halves);
+ let content = "q -10 0 0 10 10.6 0.4 cm /Im0 Do Q";
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{halves}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ halves.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
+ // The snapped box is columns 1..11; columns 1..3 and 8..11 are well clear of the change at 6.
+ for y in 2..10 {
+ for x in 1..4 {
+ assert_eq!(gray(x, y), 255, "mirrored: the left of the box shows the image's right (white) half, pixel ({x}, {y})");
+ }
+ for x in 8..11 {
+ assert_eq!(gray(x, y), 0, "mirrored: the right of the box shows the image's left (black) half, pixel ({x}, {y})");
+ }
+ }
+}
+
#[test]
fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
let levels = [0u8, 255, 85, 170, 40, 200, 120, 230];
@@ -7008,6 +8491,99 @@ mod devicen_inks;
// patch 27).
mod isolated_cmyk_groups;
+/// The generation-0 stand-in a rebuilt table files an object under must not make the object a
+/// second object: enumeration yielded it once per key, so a document recovered without a readable
+/// page tree reported the page twice (noted in review of #5580).
+///
+/// And a later header for the same object number replaces the stand-in, so `N 0 R` cannot be left
+/// pointing at a body the exact key no longer names.
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object() {
+ // No table, no `startxref`, and no readable page tree: the pages are recovered by looking for
+ // objects that are pages, which is where a doubled object shows up as a doubled page.
+ let pdf = "%PDF-1.4\n\
+ 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 3 99999 obj << /Type /Page /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length 0 >> stream\n\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n\
+ %%EOF\n";
+ let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default());
+ let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(first.error.is_none(), "the page is recovered: {:?}", first.error);
+ let second = r.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(second.error.is_some(), "and it is one page, not two: page 2 must not exist");
+
+ // Two headers for object 3, the second the one the exact key names: the stand-in follows it.
+ // `(B)` is drawn by the later body, so the page that comes back says which one was read.
+ let twice = "%PDF-1.4\n\
+ 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 5 0 R >> endobj\n\
+ 3 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >> endobj\n\
+ 5 0 obj << /Length 0 >> stream\n\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n\
+ %%EOF\n";
+ let mut r = PageRenderer::new(Arc::new(twice.to_string().into_bytes()), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(page.error.is_none(), "the page is found: {:?}", page.error);
+ assert_eq!((page.width, page.height), (300, 150), "`3 0 R` reads the later body, as `3 1` does");
+}
+
+/// A file whose cross-reference table has to be rebuilt keeps an object whose header names a
+/// generation the reference does not.
+///
+/// `poppler-85140-0.pdf` is 340 bytes with no table at all, and its one page's header reads
+/// `3 18446744073709551616 obj` while the page tree points at `3 0 R`. Before #427 the digits
+/// wrapped, and 2^64 wrapped to exactly 0, so the header read as `3 0 obj` and the file opened by
+/// accident; once an overflowing integer became a real the object was unreachable and the file
+/// stopped opening at all, which `cargo xtask check` reports as a regression (#5580).
+///
+/// A rebuilt table has no revision history to tell two generations of an object apart -- the scan
+/// already files objects found in object streams under generation 0 -- so the object number is
+/// what identifies one. A readable generation that simply is not the one referenced
+/// (`3 99999 obj`) was lost the same way, and is covered here too.
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
+#[cfg_attr(not(target_arch = "wasm32"), test)]
+fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() {
+ for generation in [
+ // The generation the reference names, which always worked.
+ "0",
+ // One that reads as an integer but is not the one referenced.
+ "99999",
+ // And one no generation may be: 2^64, which is what the reported file carries. A
+ // generation number is at most 65,535 (ISO 32000-2 §7.5.4).
+ "18446744073709551616",
+ ] {
+ // No table and no `startxref`, so the objects are recovered by scanning the file.
+ let pdf = format!(
+ "%PDF-1.4\n\
+ 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 {generation} obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\
+ trailer << /Root 1 0 R >>\n\
+ %%EOF\n"
+ );
+ let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(page.error.is_none(), "generation {generation}: the page is found: {:?}", page.error);
+ assert_eq!((page.width, page.height), (200, 100), "generation {generation}: and it is the page's own size");
+ }
+
+ // A stray number before a header (`42 3 0 obj`) is not a header, and must not cost the one
+ // after it: the lenient read leaves the scan where it was when it fails (noted in review).
+ let pdf = "%PDF-1.4\n\
+ 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 42 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\
+ trailer << /Root 1 0 R >>\n\
+ %%EOF\n";
+ let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(page.error.is_none(), "the header after a stray number is found: {:?}", page.error);
+ assert_eq!((page.width, page.height), (200, 100));
+}
+
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
/// but draws no pixels itself.
#[test]
diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs
index 5323279ca1..7e8307d908 100644
--- a/crates/render/tests/it/main.rs
+++ b/crates/render/tests/it/main.rs
@@ -8,6 +8,7 @@ mod luminosity_group_space;
mod named_form_properties;
mod nonseparable_gray;
mod open_type_cff_simple_font;
+mod rotated_image_snap;
mod search;
mod separation_all;
mod stray_q_in_a_form;
diff --git a/crates/render/tests/it/rotated_image_snap.rs b/crates/render/tests/it/rotated_image_snap.rs
new file mode 100644
index 0000000000..fcb5181d00
--- /dev/null
+++ b/crates/render/tests/it/rotated_image_snap.rs
@@ -0,0 +1,163 @@
+//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is
+//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a
+//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned
+//! back, must match the upright one. The image itself sits at fractional coordinates: an upright
+//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was
+//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale
+//! and offset (most of its pixels off, by up to 217 levels).
+//!
+//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil
+//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another
+//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so
+//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are
+//! not snapped upright or turned, and were already the same both ways; they stay in the test as a
+//! guard.
+use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
+use std::sync::Arc;
+
+/// A PDF of the given objects (object 1 the catalog), with a cross-reference table.
+fn pdf(objects: &[Vec]) -> Vec