Merge the fixes to the PR below into this one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 00:20:54 +01:00
co-authored by Claude Opus 5.5
12 changed files with 361 additions and 20 deletions
+3 -1
View File
@@ -91,5 +91,7 @@ recognisable in a diff and byte-stable when re-serialized.
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
in the reserved `arlington` crate.
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB);
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB,
their filters undone once a document for the images that share them, up to 64 MiB kept, while
their segments are decoded for every image);
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
+3
View File
@@ -49,6 +49,9 @@ pub struct Document {
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
stream_limit: usize,
overlay: BTreeMap<u32, Slot>,
next_num: u32,
+45 -12
View File
@@ -5,6 +5,10 @@ use super::{
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
};
/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see
/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`].
const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS;
impl Document {
pub fn bytes(&self) -> &Arc<Vec<u8>> {
&self.data
@@ -301,13 +305,13 @@ impl Document {
};
// `/Width` and `/Height` are read as given directly, as every caller reads them first: one
// given by reference has the image refused there, before it gets here.
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0);
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok());
if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() {
let Some(columns) = s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()) else {
return Err(CosError::Filter("a JBIG2 image's /Width isn't a size".into()));
let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else {
return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into()));
};
let globals = self.jbig2_globals(s)?;
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref(), columns, height, max).map_err(filter);
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
@@ -317,24 +321,53 @@ impl Document {
return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into()));
}
let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1);
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height);
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0));
let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?;
pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter)
}
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one. Globals that can't be read
/// are an error, rather than the image decoded without them.
fn jbig2_globals(&self, s: &Stream) -> Result<Option<Vec<u8>>, CosError> {
let globals = self.last_filter_parms(s).get(b"JBIG2Globals").map(|g| self.resolve(g));
let Some(Object::Stream(g)) = globals.as_deref() else { return Ok(None) };
self.with_direct_parms(g).decoded_within(MAX_JBIG2_GLOBALS).map(Some).map_err(|e| {
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
/// without them, as the renderer draws it, so an image that needed them mostly fails to decode
/// and can decode to other pixels. A stream that is there and can't be read is an error, rather
/// than the image decoded without it.
///
/// The images of a scan share one globals stream, so a stream of the file, not edited since
/// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they
/// are another object, which can change while this one doesn't), has its filters undone once a
/// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That
/// saves the filters only: the segments it holds are decoded again for every image.
fn jbig2_globals(&self, s: &Stream) -> Result<Option<Arc<Vec<u8>>>, CosError> {
let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) };
let held = match entry {
Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num),
_ => None,
};
if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) {
return Ok(Some(hit));
}
let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) };
let readable = self.with_direct_parms(g);
let held = held.filter(|_| matches!(readable, Cow::Borrowed(_)));
let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| {
let detail = match e {
CosError::Filter(detail) => detail,
e => e.to_string(),
};
CosError::Filter(format!("the JBIG2 globals can't be read: {detail}"))
})
})?;
// What is kept counts by its length, so that's all it may hold on to.
data.shrink_to_fit();
let data = Arc::new(data);
// A poisoned lock only means nothing is kept.
if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) {
let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add);
if total <= JBIG2_GLOBALS_HELD {
kept.insert(num, data.clone());
}
}
Ok(Some(data))
}
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
+2
View File
@@ -19,6 +19,7 @@ impl Document {
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: crate::object::MAX_DECODED,
overlay: BTreeMap::new(),
next_num: 1,
@@ -102,6 +103,7 @@ impl Document {
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
overlay: BTreeMap::new(),
next_num: 1,
+153
View File
@@ -173,6 +173,159 @@ fn fax_images_decode_with_their_parameters() {
assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
}
/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept
/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the
/// file's), and objects added in memory aren't kept.
#[test]
fn shared_jbig2_globals_are_read_once() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let mut doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let image = |doc: &mut Document| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))
};
let (a, b) = (image(&mut doc), image(&mut doc));
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept");
// The same objects read from a file, the catalog holding on to the images (a full save may
// number them anew).
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap();
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let mut doc = Document::open(Arc::new(file)).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap());
let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone();
let globals = parms.reference(b"JBIG2Globals").unwrap();
let clone = doc.clone();
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept");
let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image");
let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode");
doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())));
assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh");
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's");
}
/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image
/// with neither is an error, not an image of no rows.
#[test]
fn a_jbig2_image_needs_its_size_given_directly() {
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap();
let mut doc = Document::new_empty();
let size = doc.add(Object::Int(2));
let image = |width: Object, height: Object| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), width);
d.set(b"Height".to_vec(), height);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
};
assert!(image(Object::Int(8), Object::Int(2)).is_ok());
assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference");
assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference");
}
/// A document that kept globals of 64 MiB keeps no more, and the images past that still read:
/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time.
#[test]
fn kept_jbig2_globals_are_bounded_in_total() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let length = 22 * 1024 * 1024;
let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0];
skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes());
skipped.extend_from_slice(&[0, 0, 0, 1]);
skipped.resize(skipped.len() + length, 0);
let mut doc = Document::new_empty();
let mut images = Vec::new();
for _ in 0..3 {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))));
}
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap();
let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
for image in &images {
let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") };
assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads");
}
let kept = doc.jbig2_globals.lock().unwrap();
assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't");
assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts");
}
/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so
/// editing it changes what they decode to while the stream itself, the key kept by, stays the same.
#[test]
fn jbig2_globals_with_parameters_by_reference_are_read_afresh() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 };
let mut doc = Document::new_empty();
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(15));
let parms = doc.add(Object::Dict(parms));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
d.set(b"DecodeParms".to_vec(), Object::Ref(parms));
let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &params, &skipped).unwrap();
let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted)));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut image_parms = Dict::new();
image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms));
let image = doc.add(Object::Stream(Stream::from_raw(d, coded)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let stream = |doc: &Document| match &*doc.get(image) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept");
let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap();
let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") };
let parms = g.dict.reference(b"DecodeParms").unwrap();
doc.set(parms, Object::Dict(Dict::new()));
assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise");
}
/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of
/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference
/// too): globals that can't be read are an error, and ones at another filter's place aren't its
+2
View File
@@ -58,3 +58,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
pdfcraft-testkit = { workspace = true }
# JBIG2 fixtures (`encode_jbig2_generic`).
pdfcraft-filters = { workspace = true }
+99
View File
@@ -8079,6 +8079,105 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
assert!(d.page_image_copy_file(0, 1).is_err());
}
/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the
/// image's own stream only an extension segment decoders skip, so it has a picture only through
/// them. Its default pixel is black.
#[test]
fn copy_image_reads_the_globals_of_a_jbig2_picture() {
let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
globals.extend_from_slice(&16u32.to_be_bytes());
globals.extend_from_slice(&2u32.to_be_bytes());
globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping
let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0];
let stream = |dict: &str, data: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
o.extend_from_slice(data);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
stream(
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>",
&image,
),
stream("", &globals),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
let d = s.get(id).unwrap();
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (16, 2));
assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals");
}
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`.
#[test]
fn copy_image_copies_a_jbig2_picture_behind_another_filter() {
// 16 × 2: the left half of the top row black, the rest white.
let bits = [0x00, 0xFF, 0xFF, 0xFF];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
let hex: Vec<u8> = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
let stream = |dict: &str, data: &[u8]| {
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
o.extend_from_slice(data);
o.extend_from_slice(b"\nendstream");
o
};
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
stream(
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]",
&hex,
),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0));
assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png");
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
assert_eq!((w, h), (16, 2));
let grey: Vec<u8> = rgba.chunks(4).map(|px| px[0]).collect();
let expected: Vec<u8> = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect();
assert_eq!(grey, expected, "the picture, black as 0");
}
/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656).
fn fixture_without_page_tree() -> Vec<u8> {
let bytes = fixture(2);
+2 -1
View File
@@ -65,7 +65,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
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), 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 or a DeviceN, whose tint transform isn't read, calibrated or ICC
in force (an indexed image referred to from beyond the page and form resources read, as an
annotation's appearance, may be). 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%
+14 -4
View File
@@ -300,6 +300,14 @@ 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!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")))
}
/// 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)) {
@@ -1145,11 +1153,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<u32> = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect();
defaults.extend(used_elsewhere(doc, &rgb, &read));
let checked: HashSet<u32> =
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<ObjRef> = ppi
.keys()
+35
View File
@@ -3573,6 +3573,41 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
}
}
/// 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}");
}
}
/// 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() {