mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
M2.6: a JPEG's colour space is read once for an export
CodeRabbit on #5825: a JPEG that isn't inverted read its colour space to know whether it is CMYK, and then, with a soft mask, a stencil mask or a colour key keeping it from being written as it is, read it again for its samples. An indexed one (not valid, written as it is in the end) paid for its lookup table twice, and could be refused with an allowance that covers the image and one reading. The first reading is kept and used. a_jpegs_lookup_table_is_paid_for_once: an indexed JPEG with a soft mask pays one reading of its table, and is written as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
05300ea5bc
commit
0c9cd267f5
2 files changed
+48
-3
No files matched your search
@@ -183,12 +183,17 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
|
||||
.unwrap_or(false);
|
||||
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
|
||||
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
|
||||
// a negative.
|
||||
let as_is_negative = jpeg && !inverted && matches!(space(doc, s.dict.get(b"ColorSpace"), budget), Ok(Space::Cmyk));
|
||||
// a negative. The space is read once: an indexed one's lookup table is paid for each time.
|
||||
let read = (jpeg && !inverted).then(|| space(doc, s.dict.get(b"ColorSpace"), budget));
|
||||
let as_is_negative = matches!(read, Some(Ok(Space::Cmyk)));
|
||||
if jpeg && alpha.is_none() && key.is_none() && !as_is_negative {
|
||||
return as_is();
|
||||
}
|
||||
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"), budget)? };
|
||||
let mut space = match read {
|
||||
_ if mask => Space::Gray,
|
||||
Some(read) => read?,
|
||||
None => space(doc, s.dict.get(b"ColorSpace"), budget)?,
|
||||
};
|
||||
let (samples, bpc) = if jpeg {
|
||||
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) {
|
||||
return Err(FilterError::Spent.to_string());
|
||||
|
||||
@@ -2344,6 +2344,46 @@ fn a_lookup_table_is_read_within_the_exports_allowance() {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn a_jpegs_lookup_table_is_paid_for_once() {
|
||||
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, PdfString, Stream};
|
||||
let table = [255, 0, 0, 0, 0, 255];
|
||||
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| {
|
||||
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 mut mask = Dict::new();
|
||||
for (k, v) in
|
||||
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
|
||||
{
|
||||
mask.set(k.as_bytes().to_vec(), v);
|
||||
}
|
||||
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
let mask = doc.add(Object::Stream(Stream::flate(mask, &[255, 128])));
|
||||
let mut dict = Dict::new();
|
||||
for (k, v) in
|
||||
[("Subtype", Object::name("Image")), ("Width", Object::Int(2)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
|
||||
{
|
||||
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]));
|
||||
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 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()
|
||||
};
|
||||
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");
|
||||
}
|
||||
|
||||
/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that
|
||||
/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without
|
||||
/// its transparency otherwise.
|
||||
|
||||
Reference in new issue
Block a user