M2.6: a run pays for what a stream costs, not what it holds

Four ways round the allowance, each measured by a review of hostile input:

A Flate stream read as zlib that fails at once, and then as raw deflate that
decodes everything before failing, gave nothing back and so was charged
nothing, here and in DecodeBudget: 110 s for a 387 KB file, all decoded. What
the retry decoded now counts, though the data is still dropped as it was.

Fax and 1-bit Flate images paid by the byte for work done by the pixel: 19
bytes can be a blank page of 100 million pixels, about a second each. Every
image decoded here now pays for its pixels first, as JBIG2 already did.

A JPEG paid nothing, and what one asks for doesn't grow with its length: 156
bytes declared 80 megapixels, 72 s for an 8 KB file. A JPEG pays for its
samples before it is decoded, in Optimize and in an export that has to decode
one for its mask.

Merging fragments paid for reading strips and not for the picture it built:
393 s for 1,154 bytes. It pays for the picture before allocating it.

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-11-01 00:20:43 +01:00
co-authored by Claude Opus 5.5
parent 696728686f
commit ade8ebbdb4
12 changed files with 251 additions and 34 deletions
+5 -1
View File
@@ -7,6 +7,7 @@
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream};
use pdfcraft_filters::FilterError;
/// One image, ready to write.
#[derive(Clone, Debug, PartialEq)]
@@ -165,7 +166,7 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) };
// Without its mask the image would come out opaque, showing what the mask hides.
if budget.refused() > refused {
return Err("more image decoding than one operation may do".into());
return Err(FilterError::Spent.to_string());
}
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
@@ -184,6 +185,9 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64)) {
return Err(FilterError::Spent.to_string());
}
// Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an
// indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
+46 -1
View File
@@ -2312,6 +2312,51 @@ fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() {
assert_eq!(budget.refused(), 1);
}
/// A JPEG is written as it is, at no cost, unless a mask means decoding it: that pays for its
/// samples, and past the allowance the image goes, with the reason, as it would come out without
/// its transparency otherwise.
#[test]
fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let image_dict = |extra: Option<(&[u8], Object)>| {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(4));
d.set(b"Height".to_vec(), Object::Int(4));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
if let Some((key, value)) = extra {
d.set(key.to_vec(), value);
}
d
};
let mut doc = Document::new_empty();
let mut mask = Dict::new();
mask.set(b"Subtype".to_vec(), Object::name("Image"));
mask.set(b"Width".to_vec(), Object::Int(4));
mask.set(b"Height".to_vec(), Object::Int(4));
mask.set(b"BitsPerComponent".to_vec(), Object::Int(8));
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0x80; 16])));
let masked = doc.add(Object::Stream(Stream::from_raw(image_dict(Some((b"SMask", Object::Ref(mask)))), jpeg.clone())));
let plain = doc.add(Object::Stream(Stream::from_raw(image_dict(None), jpeg)));
let mut budget = ImageBudget::with_work(0);
assert!(crate::extract::image_file_within(&doc, plain, &mut budget).is_ok(), "written as it is");
assert_eq!(budget.refused(), 0);
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(crate::extract::image_file_within(&doc, masked, &mut budget).is_ok());
let cost = u64::MAX - budget.work_left();
assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3, "the mask's bytes and pixels, and the JPEG's samples twice");
let mut budget = ImageBudget::with_work(cost - 1);
let refused = crate::extract::image_file_within(&doc, masked, &mut budget).unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(budget.refused(), 1);
}
/// A mask past the allowance can't be left out as an unreadable one is, or the image comes out
/// opaque, showing what the mask hides: the image goes, with the reason.
#[test]
@@ -2326,7 +2371,7 @@ fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() {
mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut doc = Document::new_empty();
let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())));
// A JPEG, which nothing charges, so only its mask can be what stops it.
// A JPEG, read for nothing until its mask is, so only the mask can be what stops it.
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(1, 1, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let mut image = Dict::new();