M2.6: test that a JPEG refused at its lookup table is left out

CodeRabbit's follow-up on #5825: the indexed JPEG test ran only with an
unlimited allowance, so nothing checked that a refused lookup-table read
leaves a JPEG out (`a_lookup_table_is_read_within_the_exports_allowance`
checks a Flate image). The test now measures what the soft mask costs
alone (no colour space, so nothing is read after it) and allows a step
less than the mask and the table: the export is refused with the
reason, and only the table is refused, nothing after it. A step short
of the mask, the mask alone is refused, so the mask is read first and
the case above is refused at the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-03 06:57:06 +01:00
1 parent 0c9cd267f5
commit a2b2bc5e6d
1 file changed
+28 -8
+28 -8
View File
@@ -2346,7 +2346,8 @@ fn a_lookup_table_is_read_within_the_exports_allowance() {
/// A JPEG's colour space is read once for an export, its lookup table paid for once: whether it is
/// CMYK (a negative as is) and what its samples are both come from that reading. An indexed JPEG
/// isn't valid, and is written as it is, but its soft mask makes it go on past the first.
/// isn't valid, and is written as it is, but its soft mask makes it go on past the first. With the
/// mask, a table the allowance can't pay for leaves the image out, with the reason.
#[test]
fn a_jpegs_lookup_table_is_paid_for_once() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
@@ -2354,9 +2355,12 @@ fn a_jpegs_lookup_table_is_paid_for_once() {
let lookup = Stream::flate(Dict::new(), &table);
let mut jpeg = Vec::new();
image::GrayImage::from_pixel(2, 1, image::Luma([1])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let spent = |as_stream: bool| {
// `given`: None leaves the colour space out, Some(true) gives the table as a stream.
let exported = |given: Option<bool>, work: u64| {
let mut doc = Document::new_empty();
let table = if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) };
let table = given.map(|as_stream| {
if as_stream { Object::Ref(doc.add(Object::Stream(lookup.clone()))) } else { Object::String(PdfString::literal(table.to_vec())) }
});
let mut mask = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
@@ -2371,17 +2375,33 @@ fn a_jpegs_lookup_table_is_paid_for_once() {
{
dict.set(k.as_bytes().to_vec(), v);
}
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
if let Some(table) = table {
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), table]));
}
dict.set(b"Filter".to_vec(), Object::name("DCTDecode"));
dict.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(dict, jpeg.clone())));
let mut budget = ImageBudget::with_work(u64::MAX);
let mut budget = ImageBudget::with_work(work);
let file = crate::extract::image_file_within(&doc, image, &mut budget);
assert_eq!(file, Ok(("jpg", jpeg.clone())), "written as it is");
u64::MAX - budget.work_left()
(file, work - budget.work_left(), budget.refused())
};
let (direct, direct_cost, _) = exported(Some(false), u64::MAX);
let (streamed, streamed_cost, _) = exported(Some(true), u64::MAX);
assert_eq!(direct, Ok(("jpg", jpeg.clone())), "written as it is");
assert_eq!(streamed, direct);
let read = (lookup.raw.len() + table.len()) as u64 + pdfcraft_filters::FLATE_READ_WORK * lookup.raw.len() as u64;
assert_eq!(spent(true), spent(false) + read, "one reading of the table");
assert_eq!(streamed_cost, direct_cost + read, "one reading of the table");
// Without a colour space nothing is read after the soft mask, so this is what the mask costs.
let (none, mask, _) = exported(None, u64::MAX);
assert_eq!(none.unwrap_err(), "the image has no colour space");
// Read first, the table would be refused a step short of the mask too, and the mask after it.
assert!(read >= mask, "the table costs at least what the mask does");
assert_eq!(exported(Some(true), mask - 1).2, 1, "the mask is read first, and refused alone");
let (refused, _, count) = exported(Some(true), mask + read - 1);
let refused = refused.unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(count, 1, "refused at the table, and nothing after it asks to be paid for");
}
/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that