diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 0085f07538..9f31b1742d 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -68,15 +68,15 @@ impl CustomImage for Raw { } } -/// `icc` when it is a profile of the colour space `space` names: `GRAY` for grey samples, `RGB ` -/// for RGB ones. `None` for other profiles and spaces. +/// `icc` when it is a well-formed profile ([`valid_icc`]) of the colour space `space` names: `GRAY` +/// for grey samples, `RGB ` for RGB ones. `None` for other profiles and spaces. fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> { let want = match space { ImageColorspace::Luma => *b"GRAY", ImageColorspace::Rgb => *b"RGB ", ImageColorspace::Cmyk | ImageColorspace::DeviceN => return None, }; - icc.filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)).map(Arc::new) + icc.filter(|icc| valid_icc(icc, want)).map(Arc::new) } /// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, @@ -100,7 +100,7 @@ fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Op if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { return None; } - let rgb_icc = pixels.is_none() && icc.as_deref().and_then(vectorcraft_doc::cmyk::icc_color_space) == Some(*b"RGB "); + let rgb_icc = pixels.is_none() && icc.as_deref().is_some_and(|icc| valid_icc(icc, *b"RGB ")); let (color, space) = if neutral(rgba) && !rgb_icc { (px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma) } else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) { @@ -175,19 +175,20 @@ fn lossless( /// it holds is still written. pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; -/// The most bytes a CMYK profile carried by an image is written with (16 MiB). +/// The most bytes an ICC profile carried by an image is written with (16 MiB). const MAX_ICC: usize = 16 << 20; /// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a -/// CMYK image can be tagged with: at most [`MAX_ICC`] bytes, the size its header declares, the -/// `acsp` signature, an input, display, output or colour space profile of CMYK colours, and a tag -/// table whose tags lie within it. `None` for other images and other profiles. +/// CMYK image can be tagged with ([`valid_icc`]). `None` for other images and other profiles. pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { - blob.cmyk_icc_profile().filter(|icc| valid_cmyk_icc(icc)) + blob.cmyk_icc_profile().filter(|icc| valid_icc(icc, *b"CMYK")) } -/// Is `icc` a CMYK profile as [`cmyk_icc`] describes? -fn valid_cmyk_icc(icc: &[u8]) -> bool { +/// Is `icc` a profile an image of colour space `space` (`GRAY`, `RGB ` or `CMYK`) can be tagged +/// with: at most [`MAX_ICC`] bytes, the size its header declares, the `acsp` signature, an input, +/// display, output or colour space profile of `space` colours, and a tag table whose tags lie +/// within it? +fn valid_icc(icc: &[u8], space: [u8; 4]) -> bool { let be = |at: usize| icc.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); let fits = |at: u32, len: u32| u64::from(at) + u64::from(len) <= icc.len() as u64; if icc.len() > MAX_ICC || be(0).and_then(|n| usize::try_from(n).ok()) != Some(icc.len()) { @@ -195,7 +196,7 @@ fn valid_cmyk_icc(icc: &[u8]) -> bool { } let class = icc.get(12..16); if icc.get(36..40) != Some(b"acsp".as_slice()) - || icc.get(16..20) != Some(b"CMYK".as_slice()) + || icc.get(16..20) != Some(space.as_slice()) || ![b"scnr", b"mntr", b"prtr", b"spac"].iter().any(|c| class == Some(c.as_slice())) { return false; @@ -412,8 +413,7 @@ pub(crate) fn recode( // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = - (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want)); + let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index e8c750f024..10bcb54dc1 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -270,8 +270,8 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert!(r.image.is_none(), "kept as it is"); } -/// A version 2.1 display ICC profile header for colour space `space` (`RGB ` or `GRAY`), `len` -/// bytes in all, every byte after the header different from its neighbors. +/// A version 2.1 display ICC profile for colour space `space` (`RGB ` or `GRAY`), `len` bytes in +/// all, with an empty tag table, every byte after the tag count different from its neighbors. fn icc_profile(space: &[u8; 4], len: usize) -> Vec { let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); @@ -279,6 +279,7 @@ fn icc_profile(space: &[u8; 4], len: usize) -> Vec { icc[12..16].copy_from_slice(b"mntr"); icc[16..20].copy_from_slice(space); icc[36..40].copy_from_slice(b"acsp"); + icc[128..132].copy_from_slice(&0u32.to_be_bytes()); icc } @@ -450,6 +451,24 @@ fn tagged_tiffs_keep_their_icc_profile() { // A profile of another colour space is left out. let spaces = export(&tiff(&colour(), &gray), json!({})); assert!(spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), "RGB in a gray profile: {spaces:?}"); + // A malformed profile is left out, the image kept as it is, resampled or compressed with JPEG: + // one whose header declares another size, one without the `acsp` signature, and one whose tag + // table runs past its end. + let mut malformed = [rgb.clone(), rgb.clone(), rgb.clone()]; + malformed[0][..4].copy_from_slice(&601u32.to_be_bytes()); + malformed[1][36..40].copy_from_slice(b"xxxx"); + malformed[2][128..132].copy_from_slice(&100u32.to_be_bytes()); + let compressions = + [json!({}), json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"color": {"compression": "jpeg"}})]; + for (i, icc) in malformed.iter().enumerate() { + for compression in &compressions { + let spaces = export(&tiff(&colour(), icc), json!({ "compression": compression })); + assert!( + spaces.len() == 1 && spaces[0].space == "DeviceRGB" && spaces[0].icc.is_none(), + "malformed profile {i}, {compression}: {spaces:?}" + ); + } + } // Converted pixels are in the destination's colours, not the image's profile. for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}}));