PDF export: a malformed gray or RGB profile is left out of an image

A gray or RGB image is tagged with the ICC profile it carries only when
the profile passes the checks a CMYK image's profile already passes: at
most 16 MiB, the size its header declares, the acsp signature, an input,
display, output or color space profile class, and a tag table that lies
within it. Otherwise the image is written in DeviceGray or DeviceRGB, kept
as it is, resampled or compressed with JPEG.
This commit is contained in:
dotCooCoo
2026-11-14 12:33:08 -08:00
parent aa3e74384b
commit af47bc5c3b
2 changed files with 35 additions and 16 deletions
+14 -14
View File
@@ -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<Vec<u8>>, space: ImageColorspace) -> Option<Arc<Vec<u8>>> {
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<Vec<u8>> {
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 {
+21 -2
View File
@@ -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<u8> {
let mut icc: Vec<u8> = (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<u8> {
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}}));