From 18e5e393d2786f6016de465afd4f00913d672304 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:57:51 -0800 Subject: [PATCH] PDF export: warn when a tagged image can't be decoded for sRGB conversion With Convert to Destination and an sRGB destination, a gray or RGB image with an ICC profile whose pixels can't be decoded (a header past the decoding allowance) was kept with its profile and no warning. It now reports that the image is not converted. When the image is left out of the file as well, only the warning that it is missing is given. --- crates/pdf/src/export/images.rs | 6 ++++-- crates/pdf/src/images.rs | 15 ++++++++++----- crates/pdf/src/tests/compression.rs | 23 +++++++++++++++++++++++ docs/mcp.md | 4 ++-- docs/target-app-parity.md | 2 +- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 1b34110326..d977ed02a9 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -151,10 +151,12 @@ impl Exporter<'_> { } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; - if let Some(w) = r.warning { + let img = r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)); + // An image left out gets the warning below instead. + if let Some(w) = r.warning.filter(|_| img.is_some()) { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) + img }); if img.is_none() { self.warn(UNDECODED); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 99b44d5167..d3410e12a6 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -334,6 +334,8 @@ fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option> { const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it and is not converted to sRGB"; +const UNDECODED: &str = "an image couldn't be decoded and is not converted to sRGB"; + /// `img` converted from ICC profile `p` to sRGB with `intent`: at 16 bits a sample when it has /// more than 8, else at 8, with its alpha; and whether any sample changed. A grey image stays grey, each level as the sRGB grey it converts to. `None` when the /// pixels can't be converted. @@ -468,7 +470,7 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces); /// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB /// instead ([`ToSrgb`]). `interpolate` lets viewers smooth it. An image that can't be decoded is -/// kept as it is. +/// kept as it is, with a warning when its pixels were to be converted to sRGB. pub(crate) fn recode( bytes: &[u8], size: (f64, f64), @@ -482,10 +484,6 @@ pub(crate) fn recode( let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); let Ok(mut decoder) = reader.into_decoder() else { return keep }; - // What the header declares is decoded within the image allowance, not whatever it asks for. - if decoder.set_limits(limits()).is_err() { - return keep; - } let dims = decoder.dimensions(); use ExtendedColorType as T; let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); @@ -495,6 +493,13 @@ pub(crate) fn recode( Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED) }, None => None, }; + // Pixels that can't be decoded are kept as they are, with a warning when they were to be + // converted. + let keep = if source.is_some() { Recoded { image: None, warning: Some(UNDECODED) } } else { keep }; + // What the header declares is decoded within the image allowance, not whatever it asks for. + if decoder.set_limits(limits()).is_err() { + return keep; + } let (mut kind, pixels) = if grey { // Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it. let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono)); diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 856528cb7e..8083f64a06 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -760,6 +760,29 @@ fn convert_to_srgb_converts_tagged_images_from_their_profile() { assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); let spaces = image_spaces(&r.bytes); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() == Some(unread.as_slice()), "unread profile: {spaces:?}"); + // A tagged image whose header declares more pixels than the decoding allowance is kept as it + // is, with a warning when it was to be converted. + let declared = |img: &DynamicImage, icc: &[u8]| { + let mut png = tagged(img, icc, ImageFormat::Png); + png[16..24].copy_from_slice(&[0, 0, 0x75, 0x30, 0, 0, 0x75, 0x30]); + let mut crc = flate2::Crc::new(); + crc.update(&png[12..29]); + png[29..33].copy_from_slice(&crc.sum().to_be_bytes()); + png + }; + let c: CompressionSettings = serde_json::from_value(json!({})).unwrap(); + let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true }); + for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] { + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out); + assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning); + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None); + assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning); + // Too large to embed as it is too: the export leaves it out, and says only that. + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(png, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("missing from the picture"), "{what}, exported: {:?}", r.warnings); + } } /// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of diff --git a/docs/mcp.md b/docs/mcp.md index 0de5dc87ee..7a80d81e4e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5599,8 +5599,8 @@ every colour into the destination profile's model (`destination`; CMYK of anothe the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted too and printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image that carries an ICC profile is converted from that profile to sRGB with the color settings' intent and written without -it, at 16 bits a sample when it has them, is not resampled or recompressed and the file is PDF 1.5 or later (a profile -the color engine can't read is kept, with a warning); without conversion it keeps its profile. The destination is any profile `edit.colorSettings` lists (default: the document's +it, at 16 bits a sample when it has them, is not resampled or recompressed and the file is PDF 1.5 or later (an image +whose profile the color engine can't read, or whose pixels can't be decoded, keeps its profile, with a warning); without conversion it keeps its profile. The destination is any profile `edit.colorSettings` lists (default: the document's for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with `edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve (PDF/A, PDF/X-3 and PDF/X-4 files are diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 36f92c2d72..633f3f71b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1861,7 +1861,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). - - PDF export with Output › Convert to Destination and an sRGB destination converts a gray or RGB image that carries an ICC profile from that profile to sRGB, with the color settings' rendering intent, and writes it without the profile, as the conversion applies to every color ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)): 8-bit and 16-bit TIFFs (16 bits a sample from PDF 1.5 on when not resampled or recompressed), PNGs, JPEGs (kept as JPEG) and resampled images. A JPEG in an sRGB profile keeps its JPEG data, a black-and-white image whose converted levels are gray is written as grayscale, and without conversion every image keeps its profile (`crates/pdf/src/tests/compression.rs`). + - PDF export with Output › Convert to Destination and an sRGB destination converts a gray or RGB image that carries an ICC profile from that profile to sRGB, with the color settings' rendering intent, and writes it without the profile, as the conversion applies to every color ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)): 8-bit and 16-bit TIFFs (16 bits a sample from PDF 1.5 on when not resampled or recompressed), PNGs, JPEGs (kept as JPEG) and resampled images. A JPEG in an sRGB profile keeps its JPEG data, a black-and-white image whose converted levels are gray is written as grayscale, and without conversion every image keeps its profile. An image whose profile the color engine can't read, or whose pixels can't be decoded, is not converted: it keeps its profile, with a warning (`crates/pdf/src/tests/compression.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - An image that SVG import stores again, as PNG or as a CMYK TIFF, keeps the resolution its PNG, JPEG, TIFF, WebP or BMP file declares, as Open and Place keep it: in a PNG `pHYs` chunk () or the TIFF's resolution tags, with the horizontal and vertical values swapped when the image turns a quarter. An image that declares no resolution is stored without one (`a_turned_raster_keeps_its_resolution`, `an_upright_tiff_keeps_its_resolution` and `a_cmyk_tiff_keeps_its_resolution_turned_or_not` in `crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`).