From 7e66a990bc85dd57ea1f3c52e45946c6a16d1c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:33:17 -0800 Subject: [PATCH 1/9] PDF export: a stored gray or RGB image keeps its ICC profile A gray or RGB TIFF that reached PDF export as a TIFF, for example in a native document or from a script, was decoded to 8-bit RGBA and written as DeviceRGB, which dropped the ICC profile of its tag 34675, its 16-bit samples and its gray color space. It is now written in an ICCBased color space holding that profile, as a tagged PNG is: gray stays gray, 16-bit samples stay 16-bit from PDF 1.5 on, and the alpha becomes a soft mask only when some pixel is not opaque. A gray or RGB image that the Compression settings resample or compress with JPEG keeps its source profile too, a PNG included. Pixels that the Output settings convert are written without it, and so is a profile whose color space differs from the samples'. CMYK images are unchanged. --- crates/pdf/src/export/images.rs | 11 +- crates/pdf/src/images.rs | 114 ++++++++++++++++---- crates/pdf/src/tests/compression.rs | 157 +++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 256 insertions(+), 27 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d0158d8cd2..a44aa52526 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -318,8 +318,9 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { const MAX_EMBED_PIXELS: u64 = 1 << 27; /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a -/// sample is written at 8 bits ([`png_8_bit`]). +/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples +/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or +/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. @@ -336,11 +337,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), _ => None, }; - direct.or_else(|| { - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some(Image::from_rgba8(rgba.into_raw(), w, h)) - }) + direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit)) } #[cfg(test)] diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4139259583..85cb29f503 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -26,7 +26,8 @@ use vectorcraft_doc::cmyk::jpeg_components; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; -/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel. +/// Raw pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel, of 8 bits a +/// sample or of 16 (big-endian), with the ICC profile the colour samples are in, if any. #[derive(Clone, Hash)] struct Raw { color: Arc>, @@ -34,6 +35,8 @@ struct Raw { width: u32, height: u32, space: ImageColorspace, + bits: BitsPerComponent, + icc: Option>>, } impl CustomImage for Raw { @@ -46,7 +49,7 @@ impl CustomImage for Raw { } fn bits_per_component(&self) -> BitsPerComponent { - BitsPerComponent::Eight + self.bits } fn size(&self) -> (u32, u32) { @@ -54,7 +57,7 @@ impl CustomImage for Raw { } fn icc_profile(&self) -> Option<&[u8]> { - None + self.icc.as_deref().map(Vec::as_slice) } fn color_space(&self) -> ImageColorspace { @@ -62,24 +65,85 @@ 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. +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) +} + +/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, +/// which reads tags of any size), another format's as its decoder reads it. `None` without one. +fn source_icc(bytes: &[u8]) -> Option> { + let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; + if reader.format() == Some(ImageFormat::Tiff) { + return vectorcraft_doc::cmyk::tiff_icc_profile(bytes); + } + let mut decoder = reader.into_decoder().ok()?; + decoder.icc_profile().ok().flatten().filter(|icc| !icc.is_empty()) +} + /// A `width` × `height` image of straight (not premultiplied) RGBA pixels, written as RGB (as /// CMYK when `pixels` converts to CMYK), or as grey when every pixel is grey, with an alpha -/// channel only when some pixel isn't opaque. `interpolate` asks viewers to smooth it when -/// enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels. -fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option { +/// channel only when some pixel isn't opaque. With an RGB `icc` profile, pixels that `pixels` +/// doesn't convert stay RGB and carry it; a grey one tags grey pixels. `interpolate` asks viewers +/// to smooth it when enlarged (PDF/A forbids it). `None` when `rgba` doesn't hold that many pixels. +fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>, icc: Option>) -> Option { let px = rgba.as_chunks::<4>().0; if px.len() as u64 != width as u64 * height as u64 || px.is_empty() { return None; } - let (color, space) = if neutral(rgba) { + let rgb_icc = pixels.is_none() && icc.as_deref().and_then(vectorcraft_doc::cmyk::icc_color_space) == Some(*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)) { (cmyk, ImageColorspace::Cmyk) } else { (px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb) }; + let icc = icc_for(icc.filter(|_| pixels.is_none()), space); let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); - Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok() + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space, bits: BitsPerComponent::Eight, icc }, interpolate).ok() +} + +/// Decoded image `img` written losslessly as it is: grey or RGB samples (other kinds as RGB), with +/// an alpha channel only when some pixel isn't opaque, at 16 bits a sample when it has more than 8 +/// and `sixteen_bit` allows it (PDF 1.5 and later), else at 8; tagged with ICC profile `icc` when +/// that is a profile of the samples' colour space (`GRAY` or `RGB `). `None` when it has no pixels. +pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: bool, sixteen_bit: bool) -> Option { + let (width, height) = (img.width(), img.height()); + if width == 0 || height == 0 { + return None; + } + let kind = img.color(); + let space = if kind.has_color() { ImageColorspace::Rgb } else { ImageColorspace::Luma }; + let n = if kind.has_color() { 3 } else { 1 }; + let deep = sixteen_bit && kind.bits_per_pixel() > 8 * u16::from(kind.channel_count()); + let (color, alpha, bits) = if deep { + let s = if kind.has_color() { img.into_rgba16().into_raw() } else { img.into_luma_alpha16().into_raw() }; + let px = s.chunks_exact(n + 1); + let color = px.clone().flat_map(|p| p[..n].iter().flat_map(|v| v.to_be_bytes())).collect(); + let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u16::MAX)).then(|| px.flat_map(|p| p[n].to_be_bytes()).collect()); + (color, alpha, BitsPerComponent::Sixteen) + } else { + let s = if kind.has_color() { img.into_rgba8().into_raw() } else { img.into_luma_alpha8().into_raw() }; + let px = s.chunks_exact(n + 1); + let color = px.clone().flat_map(|p| p[..n].iter().copied()).collect(); + let alpha = (kind.has_alpha() && px.clone().any(|p| p[n] < u8::MAX)).then(|| px.map(|p| p[n]).collect()); + (color, alpha, BitsPerComponent::Eight) + }; + let raw = Raw { color: Arc::new(color), alpha: alpha.map(Arc::new), width, height, space, bits, icc: icc_for(icc, space) }; + Image::from_custom(raw, interpolate).ok() +} + +/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as +/// [`from_decoded`] writes it. `None` when it can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option { + from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -90,7 +154,8 @@ fn neutral(rgba: &[u8]) -> bool { /// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha` /// channel, written losslessly. fn lossless(samples: Vec, alpha: Option>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { - Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok() + let raw = Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space, bits: BitsPerComponent::Eight, icc: None }; + Image::from_custom(raw, interpolate).ok() } /// [`from_rgba`] of pixels converted by `pixels` (when given). @@ -98,7 +163,7 @@ pub(crate) fn from_rgba_in(mut rgba: Vec, width: u32, height: u32, interpola if let Some(p) = pixels { p.rgb_in_place(&mut rgba); } - from_rgba(&rgba, width, height, interpolate, pixels) + from_rgba(&rgba, width, height, interpolate, pixels, None) } /// A `width` × `height` image of CMYK ink amounts `inks` (four bytes a pixel), converted by @@ -270,6 +335,11 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in return Recoded { image: None, warning: rule.instead }; } let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + // 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 = convert.is_none().then(|| source_icc(bytes)).flatten().filter(|icc| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(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 { // Resampling greys the edges: back to black and white. @@ -284,16 +354,18 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in let (width, height) = rgba.dimensions(); let opaque = rgba.pixels().all(|p| p[3] == 255); let jpeg = jpeg_quality(&rule, jpeg).filter(|_| opaque).and_then(|q| { - // Grey pixels stay grey; colour ones go to CMYK when the output converts to CMYK. - let grey = kind != Kind::Color || neutral(&rgba); + // Grey pixels stay grey, unless they keep an RGB profile; colour ones go to CMYK when the + // output converts to CMYK. + let grey = kind != Kind::Color || (neutral(&rgba) && !rgb_icc); let (samples, space) = match convert.filter(|_| !grey).and_then(|p| p.cmyk(&rgba)) { Some(cmyk) => (cmyk, ImageColorspace::Cmyk), None if grey => (rgba.pixels().map(|p| p[0]).collect(), ImageColorspace::Luma), None => (rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb), }; - to_jpeg(&samples, space, width, height, q, interpolate) + let icc = icc_for(icc.clone(), space); + to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice)) }); - let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert)); + let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc)); Recoded { image, warning: rule.instead } } @@ -360,7 +432,7 @@ pub(crate) fn recode_cmyk( None => (inks, ImageColorspace::Cmyk), }; // JPEG needs opaque pixels. - let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); + let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, None)); let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate)); Recoded { image, warning: rule.instead } } @@ -405,9 +477,9 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool) } /// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at -/// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array -/// undoes. -fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool) -> Option { +/// `quality`, carrying grey or RGB samples' ICC profile `icc` when given. CMYK JPEGs hold Adobe's +/// inverted samples, which the PDF writer's Decode array undoes. +fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool, icc: Option<&[u8]>) -> Option { let mut out = Vec::new(); match space { ImageColorspace::Cmyk => { @@ -416,7 +488,11 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } ImageColorspace::Rgb | ImageColorspace::Luma => { let t = if space == ImageColorspace::Rgb { ExtendedColorType::Rgb8 } else { ExtendedColorType::L8 }; - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode(samples, width, height, t).ok()?; + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality); + if let Some(icc) = icc { + image::ImageEncoder::set_icc_profile(&mut encoder, icc.to_vec()).ok()?; + } + encoder.encode(samples, width, height, t).ok()?; } ImageColorspace::DeviceN => return None, } diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index c02f3f5e1c..8adf91879a 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -5,7 +5,7 @@ use std::io::Cursor; -use craft_pdf::syntax::object::{Name, Object}; +use craft_pdf::syntax::object::{Array, Name, Object, Stream}; use image::{DynamicImage, ImageFormat}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; @@ -237,3 +237,158 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None); 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. +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()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc +} + +/// `img` encoded as `format` carrying ICC profile `icc`. +fn tagged(img: &DynamicImage, icc: &[u8], format: ImageFormat) -> Vec { + use image::ImageEncoder as _; + let mut out = Vec::new(); + match format { + ImageFormat::Tiff => { + let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out)); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } + _ => { + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } + } + out +} + +/// An image XObject as it is written. +#[derive(Debug, PartialEq)] +struct Written { + /// Its colour space's name: `ICCBased` for an ICC-based one. + space: String, + /// The profile an ICC-based colour space holds. + icc: Option>, + bits: u8, + filter: String, + smask: bool, +} + +/// Each image XObject of `pdf` other than soft masks. +fn image_spaces(pdf: &[u8]) -> Vec { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + let images: Vec> = file + .objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s), + _ => None, + }) + .collect(); + let masks: Vec> = images.iter().filter_map(|s| s.dict().get::>(b"SMask")).map(|m| m.raw_data().to_vec()).collect(); + let mut out = vec![]; + for s in images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())) { + let d = s.dict(); + let (space, icc) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { + (Some(n), _) => (n.as_str().to_string(), None), + (_, Some(a)) => { + let mut it = a.flex_iter(); + let name = it.next::>().unwrap().as_str().to_string(); + (name, it.next::>().map(|s| s.decoded().unwrap().into_owned())) + } + _ => (String::new(), None), + }; + let filter = d.get::>(b"Filter").map(|n| n.as_str().to_string()).unwrap_or_default(); + let (bits, smask) = (d.get::(b"BitsPerComponent").unwrap_or(0), d.get::>(b"SMask").is_some()); + out.push(Written { space, icc, bits, filter, smask }); + } + out +} + +/// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based +/// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as +/// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels +/// the Output settings convert, and a profile of another colour space, are written without it. +#[test] +fn tagged_tiffs_keep_their_icc_profile() { + let (rgb, gray) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300)); + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([10, 20, 30, 0])); + let tiff = |img: &DynamicImage, icc: &[u8]| doc(tagged(img, icc, ImageFormat::Tiff), "image/tiff"); + let export = |d: &Document, settings: Value| { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + image_spaces(&r.bytes) + }; + let icc_based = |icc: &[u8], bits: u8, filter: &str, smask: bool| { + vec![Written { space: "ICCBased".into(), icc: Some(icc.to_vec()), bits, filter: filter.into(), smask }] + }; + let flate = "FlateDecode"; + let cases = [ + ("RGB", tiff(&colour(), &rgb), json!({}), icc_based(&rgb, 8, flate, false)), + ("gray", tiff(&colour().grayscale(), &gray), json!({}), icc_based(&gray, 8, flate, false)), + ( + "RGB, 16 bits", + tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb), + json!({"compatibility": "1.7"}), + icc_based(&rgb, 16, flate, false), + ), + ( + "RGB, 16 bits, PDF 1.4", + tiff(&DynamicImage::ImageRgb16(colour().to_rgb16()), &rgb), + json!({"compatibility": "1.4"}), + icc_based(&rgb, 8, flate, false), + ), + ("RGBA", tiff(&clear.clone().into(), &rgb), json!({}), icc_based(&rgb, 8, flate, true)), + ( + "RGB, resampled", + tiff(&colour(), &rgb), + json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&rgb, 8, flate, false), + ), + ( + "gray, resampled", + tiff(&colour().grayscale(), &gray), + json!({"compression": {"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&gray, 8, flate, false), + ), + ("RGB, JPEG", tiff(&colour(), &rgb), json!({"compression": {"color": {"compression": "jpeg"}}}), icc_based(&rgb, 8, "DCTDecode", false)), + ( + "gray, JPEG", + tiff(&colour().grayscale(), &gray), + json!({"compression": {"gray": {"compression": "jpeg"}}}), + icc_based(&gray, 8, "DCTDecode", false), + ), + ( + "PNG, resampled", + doc(tagged(&colour(), &rgb, ImageFormat::Png), "image/png"), + json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}), + icc_based(&rgb, 8, flate, false), + ), + ]; + for (what, d, settings, want) in cases { + assert_eq!(export(&d, settings), want, "{what}"); + } + // A gray image in an RGB profile stays RGB to keep it, resampled too. + let neutral = tiff(&DynamicImage::ImageRgb8(colour().grayscale().to_rgb8()), &rgb); + for settings in [json!({}), json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})] { + assert_eq!(export(&neutral, settings.clone()), icc_based(&rgb, 8, flate, false), "neutral RGB, {settings}"); + } + // 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:?}"); + // 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}})); + assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..5e9f3038f5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,6 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - 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, and a profile whose color space differs from the samples', are written without it. CMYK images are unchanged (`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`). - 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`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). From e45c6f518e295bca5d82619413948eb4fbbc0703 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 06:02:55 -0800 Subject: [PATCH 2/9] PDF export: PDF/X-1a writes tagged gray images in DeviceGray PDF/X-1a allows device colors only, but a gray TIFF that carried a gray ICC profile kept it when no color conversion applied, and the export then failed its PDF/X check. A gray or RGB PNG or JPEG with a profile went the same way through the PDF writer. In PDF/X-1a, images are now written without the profile they carry; the other standards and plain exports keep it. --- crates/pdf/src/export/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/export/text.rs | 2 +- crates/pdf/src/images.rs | 28 +++++++++++++++++++--------- crates/pdf/src/tests/compression.rs | 17 ++++++++++++++++- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index a44aa52526..1aaf3c3e7d 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -138,6 +138,7 @@ impl Exporter<'_> { } let doc = self.doc; let out = self.out.clone(); + let profiles = !out.standard.cmyk_only(); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { @@ -146,12 +147,12 @@ impl Exporter<'_> { let r = if cmyk_image { crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) }; if let Some(w) = r.warning { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) }); if img.is_none() { self.warn(UNDECODED); @@ -318,10 +319,12 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { const MAX_EMBED_PIXELS: u64 = 1 << 27; /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats (a TIFF) decoded with the ICC profile they carry and their 16-bit samples -/// ([`crate::images::decoded_image`]). Without `sixteen_bit` (a file before PDF 1.5), a PNG or -/// another image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). -pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option { +/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in +/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an +/// image of any format that carries a gray or RGB profile is decoded too, and written without it. +/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is +/// written at 8 bits ([`png_8_bit`]). +pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); @@ -329,7 +332,14 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { + // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is + // decoded and written without it. + let tagged = || { + let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc)); + matches!(space.as_ref(), Some(b"GRAY" | b"RGB ")) + }; + let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() }; + let direct = match mime { "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), @@ -337,7 +347,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), _ => None, }; - direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit)) + direct.or_else(|| crate::images::decoded_image(&bytes, interpolate, sixteen_bit, profiles)) } #[cfg(test)] @@ -356,7 +366,7 @@ mod tests { let crc = !png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 })); png[29..33].copy_from_slice(&crc.to_be_bytes()); - assert!(super::embed(&ImageBlob::png(png), false, true).is_none()); + assert!(super::embed(&ImageBlob::png(png), false, true, true).is_none()); } #[test] diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index 4b0b3a047a..c9ef8203c7 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -325,7 +325,7 @@ impl Exporter<'_> { let img = match self.images.get(&cache) { Some(img) => img.clone(), None => { - let img = embed(&image.blob, self.interpolate, self.sixteen_bit); + let img = embed(&image.blob, self.interpolate, self.sixteen_bit, !self.out.standard.cmyk_only()); self.images.insert(cache, img.clone()); img } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 85cb29f503..b32f1788be 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -78,7 +78,7 @@ fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> /// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, /// which reads tags of any size), another format's as its decoder reads it. `None` without one. -fn source_icc(bytes: &[u8]) -> Option> { +pub(crate) fn source_icc(bytes: &[u8]) -> Option> { let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; if reader.format() == Some(ImageFormat::Tiff) { return vectorcraft_doc::cmyk::tiff_icc_profile(bytes); @@ -140,10 +140,11 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: Image::from_custom(raw, interpolate).ok() } -/// Image `bytes` decoded within [`limits`] with the ICC profile it carries ([`source_icc`]), as -/// [`from_decoded`] writes it. `None` when it can't be decoded. -pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool) -> Option { - from_decoded(decoded(bytes)?, source_icc(bytes), interpolate, sixteen_bit) +/// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when +/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`] +/// writes it. `None` when it can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { + from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -304,9 +305,17 @@ fn resample(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> DynamicImage { /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours -/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that -/// can't be decoded is kept as it is. -pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded { +/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile +/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only). +/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +pub(crate) fn recode( + bytes: &[u8], + size: (f64, f64), + c: &CompressionSettings, + interpolate: bool, + convert: Option<&Pixels>, + profiles: bool, +) -> Recoded { let keep = Recoded { image: None, warning: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; let jpeg = reader.format() == Some(ImageFormat::Jpeg); @@ -338,7 +347,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in // 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 = 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| vectorcraft_doc::cmyk::icc_color_space(icc) == Some(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 8adf91879a..4d36e8aced 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -234,7 +234,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true); assert!(r.image.is_none(), "kept as it is"); } @@ -391,4 +391,19 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } + // PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as + // it is, resampled or compressed with JPEG. + let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; + let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] { + for compression in &compressions { + let mut settings: PdfSettings = + serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }); + let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes); + let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none(); + assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}"); + } + } } From d2e36f689b45f36589fc697fa05ac9e9b08e3f9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:10:37 -0800 Subject: [PATCH 3/9] Docs: PDF/X-1a writes tagged gray images without their profile The progress-doc line for gray and RGB image profiles in PDF export now states that PDF/X-1a, which allows no ICC-based color spaces, writes a gray image with a gray profile in DeviceGray without the profile, and converts RGB images to CMYK as before. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 37780eb391..129079e0c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1831,7 +1831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - 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, and a profile whose color space differs from the samples', are written without it. CMYK images are unchanged (`crates/pdf/src/tests/compression.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, and a profile whose color space differs from the samples', are written without it. 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, and RGB images are converted to CMYK as before. CMYK images are unchanged (`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`). - 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`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). From 6b9564c60d1ec480c48a73b6b1341ade6c6f12ad Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:48:45 -0800 Subject: [PATCH 4/9] PDF: PDF/X-1a test covers a tagged gray JPEG The PDF/X-1a case of tagged_tiffs_keep_their_icc_profile also exports a gray JPEG with a gray profile, kept as it is, resampled and compressed with JPEG, and checks that it is written in DeviceGray without the profile. The comments on decoded_image, recode and the test say that PDF/X-1a allows no ICC-based color spaces. --- crates/pdf/src/images.rs | 6 +++--- crates/pdf/src/tests/compression.rs | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index eee79ee3bb..0085f07538 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -144,8 +144,8 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: } /// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when -/// `profiles` allows it (not in PDF/X-1a, which has device colours only), as [`from_decoded`] -/// writes it. `None` when it can't be decoded. +/// `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces), as +/// [`from_decoded`] writes it. `None` when it can't be decoded. pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } @@ -371,7 +371,7 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask: /// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours /// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile -/// they carry when `profiles` allows it (not in PDF/X-1a, which has device colours only). +/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces). /// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. pub(crate) fn recode( bytes: &[u8], diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 30e9d57e4b..8b88a0731d 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -292,6 +292,11 @@ fn tagged(img: &DynamicImage, icc: &[u8], format: ImageFormat) -> Vec { enc.set_icc_profile(icc.to_vec()).unwrap(); img.write_with_encoder(enc).unwrap(); } + ImageFormat::Jpeg => { + let mut enc = image::codecs::jpeg::JpegEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + img.write_with_encoder(enc).unwrap(); + } _ => { let mut enc = image::codecs::png::PngEncoder::new(&mut out); enc.set_icc_profile(icc.to_vec()).unwrap(); @@ -423,11 +428,12 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } - // PDF/X-1a has device colours only: a tagged gray TIFF or PNG is written in DeviceGray, kept as - // it is, resampled or compressed with JPEG. + // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF, PNG or JPEG is written in + // DeviceGray, kept as it is, resampled or compressed with JPEG. let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); - for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png)] { + let jpeg = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg"); + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png), ("JPEG", jpeg)] { for compression in &compressions { let mut settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); From 9317ee06533b50852f51ad4101725253968a2b9c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:43:26 -0800 Subject: [PATCH 5/9] PDF: 16-bit TIFF test checks the written samples The tagged TIFF test now compares the decoded image data with the source for a 16-bit RGB TIFF whose high and low sample bytes differ: big-endian 16-bit samples at PDF 1.7, and the samples reduced to 8 bits at PDF 1.4. A byte-swapped or truncated stream no longer passes. --- crates/pdf/src/tests/compression.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 8b88a0731d..968bf1226c 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -349,6 +349,21 @@ fn image_spaces(pdf: &[u8]) -> Vec { out } +/// The decoded samples of each image XObject of `pdf` other than soft masks. +fn image_samples(pdf: &[u8]) -> Vec> { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + let images: Vec> = file + .objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s), + _ => None, + }) + .collect(); + let masks: Vec> = images.iter().filter_map(|s| s.dict().get::>(b"SMask")).map(|m| m.raw_data().to_vec()).collect(); + images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())).map(|s| s.decoded().unwrap().into_owned()).collect() +} + /// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based /// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as /// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels @@ -415,6 +430,18 @@ fn tagged_tiffs_keep_their_icc_profile() { for (what, d, settings, want) in cases { assert_eq!(export(&d, settings), want, "{what}"); } + // 16-bit samples are written big-endian from PDF 1.5 on, and reduced to 8 bits before it. + // Their high and low bytes differ, so a swapped or truncated stream does not match. + let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| { + image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16]) + })); + let sixteen: Vec = deep.to_rgb16().iter().flat_map(|v| v.to_be_bytes()).collect(); + for (compatibility, want) in [("1.7", sixteen), ("1.4", deep.to_rgb8().into_raw())] { + let mut settings: PdfSettings = serde_json::from_value(json!({ "compatibility": compatibility })).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&tiff(&deep, &rgb), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(image_samples(&r.bytes) == [want], "16-bit samples, PDF {compatibility}"); + } // A gray image in an RGB profile stays RGB to keep it, resampled too. let neutral = tiff(&DynamicImage::ImageRgb8(colour().grayscale().to_rgb8()), &rgb); for settings in [json!({}), json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})] { From aa3e74384ba5492d5d43b14875f71dad402977b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:11:04 -0800 Subject: [PATCH 6/9] PDF: PDF/X-1a test covers a tagged 16-bit gray TIFF The PDF/X-1a case of tagged_tiffs_keep_their_icc_profile also exports a 16-bit gray TIFF with a gray profile, kept as it is, resampled and compressed with JPEG, and checks each written file with the PDF/X-1a check as well as its image color space. --- crates/pdf/src/tests/compression.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 968bf1226c..e8c750f024 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -455,18 +455,22 @@ fn tagged_tiffs_keep_their_icc_profile() { let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}})); assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}"); } - // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF, PNG or JPEG is written in - // DeviceGray, kept as it is, resampled or compressed with JPEG. + // PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is + // written in DeviceGray, kept as it is, resampled or compressed with JPEG, and the file passes + // the PDF/X-1a check. let compressions = [json!({}), json!({"gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}), json!({"gray": {"compression": "jpeg"}})]; + let tiff16 = tiff(&DynamicImage::ImageLuma16(colour().to_luma16()), &gray); let png = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png"); let jpeg = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg"); - for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("PNG", png), ("JPEG", jpeg)] { + for (format, d) in [("TIFF", tiff(&colour().grayscale(), &gray)), ("TIFF, 16 bits", tiff16), ("PNG", png), ("JPEG", jpeg)] { for compression in &compressions { let mut settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); settings.compression.compress_text = false; let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }); - let spaces = image_spaces(&r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes); + let bytes = r.unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")).bytes; + crate::pdfx::verify(&bytes, Standard::PdfX1a).unwrap_or_else(|e| panic!("PDF/X-1a, {format}, {compression}: {e}")); + let spaces = image_spaces(&bytes); let gray = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none(); assert!(gray, "PDF/X-1a, {format}, {compression}: {spaces:?}"); } From af47bc5c3bb640aa434963bbb1a381f4dc27d551 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:33:08 -0800 Subject: [PATCH 7/9] 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. --- crates/pdf/src/images.rs | 28 ++++++++++++++-------------- crates/pdf/src/tests/compression.rs | 23 +++++++++++++++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) 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}})); From 8e6c948010325317be652c47dee89c2993ff98ca Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:56:47 -0800 Subject: [PATCH 8/9] PDF export: a JPEG written without its profile keeps its JPEG data In PDF/X-1a, a tagged gray JPEG that the Compression settings keep as JPEG was decoded and written with Flate. It is now written with its JPEG data unchanged, in DeviceGray without its profile. A PNG or JPEG embedded as it is now has its gray or RGB profile checked with valid_icc, like the images the recoder writes: a malformed profile is left out, the JPEG keeping its JPEG data and the PNG decoded. --- crates/pdf/src/export/images.rs | 25 +++++++------ crates/pdf/src/images.rs | 2 +- crates/pdf/src/tests/compression.rs | 58 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index c76ac6de98..d2225d6766 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -319,9 +319,10 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in /// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an -/// image of any format that carries a gray or RGB profile is decoded too, and written without it. -/// Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of 16 bits a sample is -/// written at 8 bits ([`png_8_bit`]). +/// image that carries a gray or RGB profile is written without it, and so is one whose gray or RGB +/// profile is malformed ([`crate::images::valid_icc`]): a JPEG with its data unchanged, an image of +/// another format decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of +/// 16 bits a sample is written at 8 bits ([`png_8_bit`]). pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. @@ -330,14 +331,16 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, such an image is - // decoded and written without it. - let tagged = || { - let space = crate::images::source_icc(&bytes).and_then(|icc| vectorcraft_doc::cmyk::icc_color_space(&icc)); - matches!(space.as_ref(), Some(b"GRAY" | b"RGB ")) - }; - let mime = if !profiles && tagged() { "" } else { blob.mime.as_str() }; - let direct = match mime { + // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, or when the + // profile is malformed, such a JPEG is written without its profile, and an image of another + // format is decoded and written without it. + let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match vectorcraft_doc::cmyk::icc_color_space(&icc).as_ref() { + Some(space @ (b"GRAY" | b"RGB ")) => !profiles || !crate::images::valid_icc(&icc, *space), + _ => false, + }); + let direct = match blob.mime.as_str() { + "image/jpeg" | "image/jpg" if strip => Image::from_jpeg_with_icc(bytes.clone().into(), None, interpolate).ok(), + _ if strip => None, "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 9f31b1742d..5204e4f396 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -188,7 +188,7 @@ pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { /// 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 { +pub(crate) 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()) { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 10bcb54dc1..1f8e3e80d6 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -365,6 +365,64 @@ fn image_samples(pdf: &[u8]) -> Vec> { images.iter().filter(|s| !masks.contains(&s.raw_data().to_vec())).map(|s| s.decoded().unwrap().into_owned()).collect() } +/// The raw (still encoded) data of each image XObject of `pdf`. +fn image_data(pdf: &[u8]) -> Vec> { + let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap(); + file.objects() + .into_iter() + .filter_map(|o| match o { + Object::Stream(s) if s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => Some(s.raw_data().to_vec()), + _ => None, + }) + .collect() +} + +/// A PNG or JPEG embedded as it is whose gray or RGB profile is malformed is written without it, +/// the JPEG with its JPEG data unchanged: a profile whose header declares another size, one without +/// the `acsp` signature, and one whose tag table runs past its end. +#[test] +fn a_malformed_profile_is_left_out_of_a_png_or_jpeg_kept_as_it_is() { + for (space, img, device) in [(b"RGB ", colour(), "DeviceRGB"), (b"GRAY", colour().grayscale(), "DeviceGray")] { + let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; + 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()); + for (i, icc) in malformed.iter().enumerate() { + for (format, mime, filter) in [(ImageFormat::Png, "image/png", "FlateDecode"), (ImageFormat::Jpeg, "image/jpeg", "DCTDecode")] { + let what = format!("{device}, {format:?}, malformed profile {i}"); + let bytes = tagged(&img, icc, format); + let mut settings = PdfSettings::default(); + settings.compression.compress_text = false; + let pdf = export_with_report(&doc(bytes.clone(), mime), &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let spaces = image_spaces(&pdf); + let plain = spaces.len() == 1 && spaces[0].space == device && spaces[0].icc.is_none() && spaces[0].filter == filter; + assert!(plain, "{what}: {spaces:?}"); + if format == ImageFormat::Jpeg { + assert!(image_data(&pdf) == [bytes], "{what}: the JPEG data unchanged"); + } + } + } + } +} + +/// In PDF/X-1a a tagged gray JPEG kept as JPEG is written with its JPEG data, without its profile. +#[test] +fn pdf_x1a_keeps_a_tagged_gray_jpeg_as_jpeg() { + let jpeg = tagged(&colour().grayscale(), &icc_profile(b"GRAY", 300), ImageFormat::Jpeg); + let d = doc(jpeg.clone(), "image/jpeg"); + for compression in [json!({}), json!({"gray": {"compression": "jpeg"}})] { + let mut settings: PdfSettings = + serde_json::from_value(json!({"standard": "pdfX1a", "compatibility": "1.3", "compression": compression.clone()})).unwrap(); + settings.compression.compress_text = false; + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + crate::pdfx::verify(&bytes, Standard::PdfX1a).unwrap_or_else(|e| panic!("{compression}: {e}")); + let spaces = image_spaces(&bytes); + let dct = spaces.len() == 1 && spaces[0].space == "DeviceGray" && spaces[0].icc.is_none() && spaces[0].filter == "DCTDecode"; + assert!(dct, "{compression}: {spaces:?}"); + assert!(image_data(&bytes) == [jpeg.clone()], "{compression}: the JPEG data unchanged"); + } +} + /// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based /// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as /// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 129079e0c0..bbd79834a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1831,7 +1831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - 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, and a profile whose color space differs from the samples', are written without it. 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, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.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`). - 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`). - 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`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). From 5afe6b3b730d1f444105053ec51f8829bd22053f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:12:25 -0800 Subject: [PATCH 9/9] PDF export: a PNG or JPEG profile cut short is left out too The check before a PNG or JPEG is embedded as it is read the profile's color space only from a profile of at least 128 bytes, so a gray or RGB profile cut short after its color space reached the PDF writer. The color space is now read from bytes 16..20 of any profile that holds them, and valid_icc rejects the short profile. --- crates/pdf/src/export/images.rs | 7 ++++--- crates/pdf/src/tests/compression.rs | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d2225d6766..af686e1857 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -333,9 +333,10 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee let bytes = blob.bytes.as_ref().clone(); // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, or when the // profile is malformed, such a JPEG is written without its profile, and an image of another - // format is decoded and written without it. - let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match vectorcraft_doc::cmyk::icc_color_space(&icc).as_ref() { - Some(space @ (b"GRAY" | b"RGB ")) => !profiles || !crate::images::valid_icc(&icc, *space), + // format is decoded and written without it. The color space is read from the header's bytes + // 16..20 even in a profile cut short, as the PDF writer reads it. + let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match icc.get(16..20) { + Some(space @ (b"GRAY" | b"RGB ")) => !profiles || <[u8; 4]>::try_from(space).is_ok_and(|s| !crate::images::valid_icc(&icc, s)), _ => false, }); let direct = match blob.mime.as_str() { diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 1f8e3e80d6..400bc0b0a8 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -379,14 +379,16 @@ fn image_data(pdf: &[u8]) -> Vec> { /// A PNG or JPEG embedded as it is whose gray or RGB profile is malformed is written without it, /// the JPEG with its JPEG data unchanged: a profile whose header declares another size, one without -/// the `acsp` signature, and one whose tag table runs past its end. +/// the `acsp` signature, one whose tag table runs past its end, and one cut short after 64 bytes. #[test] fn a_malformed_profile_is_left_out_of_a_png_or_jpeg_kept_as_it_is() { for (space, img, device) in [(b"RGB ", colour(), "DeviceRGB"), (b"GRAY", colour().grayscale(), "DeviceGray")] { - let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; + let mut malformed = [icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600), icc_profile(space, 600)]; 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()); + malformed[3].truncate(64); + malformed[3][..4].copy_from_slice(&64u32.to_be_bytes()); for (i, icc) in malformed.iter().enumerate() { for (format, mime, filter) in [(ImageFormat::Png, "image/png", "FlateDecode"), (ImageFormat::Jpeg, "image/jpeg", "DCTDecode")] { let what = format!("{device}, {format:?}, malformed profile {i}");