diff --git a/crates/optimize/README.md b/crates/optimize/README.md index af9b5385e0..994ccbd9d0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -59,12 +59,16 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going downsampling it to JPEG. - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a - tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image - mask (a 0, or a 1 with an inverting decode array). Only images whose dark sample is known so - are averaged: `DeviceGray` (grey and RGB packed to one bit among them) and image masks, with no + tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image + mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two + palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and + RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or - `/DefaultRGB` is or may be in force. Not a separation, a DeviceN, calibrated or ICC grey, or an - indexed image, whose palette isn't read. Not one with a soft mask, whose own size would have to + `/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form + resources read, as an annotation's appearance, may be). Not one with a colour key, which can + hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% one white), nor one with a square of four new pixels that each would, as a small screened diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 319b87cd40..13133ed25a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,6 +1,7 @@ //! Image resampling and recompression. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; @@ -300,6 +301,19 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) } +/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]), +/// which default colour spaces in force where it is drawn may change. +fn indexed(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed"))) +} + +/// The first entry of colour space array `a`, its family, read through a reference. +fn family(doc: &Document, a: &[Object]) -> Option> { + a.first().map(|f| doc.resolve(f)) +} + /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary /// too, but not those in objects referred to. fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { @@ -770,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)), @@ -846,7 +860,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 => {} @@ -927,8 +941,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() { @@ -942,12 +960,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` @@ -1019,12 +1087,13 @@ 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`, -/// if its dark sample is known ([`dark_is_one`]: grey or an image mask, 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). +/// 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 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 @@ -1060,8 +1129,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 @@ -1123,11 +1192,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() @@ -1389,8 +1460,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(); @@ -1414,6 +1486,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), @@ -1422,6 +1495,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 6e8988fd1b..a4a0a810e0 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -13,7 +13,7 @@ //! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, //! 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 when grey or image masks, halftones and dithers aside (see the README), and +//! 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), diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8eac4534a5..f3e63a1b37 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3446,10 +3446,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]; @@ -3461,6 +3463,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))); @@ -3492,14 +3495,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/parity/acrobat-features.toml b/parity/acrobat-features.toml index f36a538d32..9a361664b0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7404,8 +7404,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey images and image masks (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations, DeviceN, calibrated or ICC grey, or indexed images (their dark sample isn't read), images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base or with a table over 4 KiB, images drawn or listed where /DefaultGray or /DefaultRGB is or may be in force (an indexed image also used beyond the page and form resources read, as an annotation's appearance), images with a colour key, which can hide the dark sample, or a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images"