From 7d205368e8c9d9c9993c0d3964453a2381d4838a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:59:18 -0800 Subject: [PATCH 1/5] PDF export: Convert to Destination converts tagged images to sRGB With Output > Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC profile (a Display P3 TIFF, a PNG or JPEG with an embedded profile) is converted from that profile to sRGB with the color settings' rendering intent and written without the profile. Before, it kept its own numbers and profile. - A 16-bit image that is not resampled or recompressed stays at 16 bits a sample from PDF 1.5 on. - A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB profile) keeps its JPEG data; other JPEGs are encoded again. - A black-and-white image whose converted levels are gray is compressed as grayscale. - A profile the color engine can't read is kept, with a warning. - Without conversion, and with Preserve Numbers, images keep their profile as before. IccProfile::to_srgb_pixels converts a run of pixels in one pass. Docs: docs/mcp.md, document.exportPdf params, target-app-parity. --- crates/color/src/cms/icc.rs | 15 ++ crates/color/src/cms/tests_icc.rs | 22 +++ crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/pdf/src/export/images.rs | 3 +- crates/pdf/src/images.rs | 136 ++++++++++++- crates/pdf/src/output.rs | 22 ++- crates/pdf/src/tests/compression.rs | 284 +++++++++++++++++++++++++++- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 9 files changed, 474 insertions(+), 16 deletions(-) diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 0b09062b84..f6a2316b89 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -172,6 +172,21 @@ impl IccProfile { Some(dst.map(|x| x.clamp(0.0, 1.0))) } + /// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of + /// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the + /// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values + /// for each, or no transform can be built. + pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> { + let n = self.channels(); + if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 { + return None; + } + let xf = self.to_xf(intent)?; + xf.transform(v, dst).ok()?; + dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0)); + Some(()) + } + /// sRGB → device values. pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option> { let xf = self.inverse_xf(intent)?; diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 1d312c09c3..b2920c7c7b 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -48,6 +48,28 @@ fn gray_is_srgb_toned() { } } +#[test] +fn many_pixels_convert_as_each_one_does() { + let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap(); + let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]]; + for intent in Intent::ALL { + let v: Vec = px.iter().flatten().copied().collect(); + let mut dst = vec![0.0; v.len()]; + p.to_srgb_pixels(&v, &mut dst, intent).unwrap(); + for (i, rgb) in px.iter().enumerate() { + let one = p.to_srgb(rgb, intent).unwrap(); + assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}"); + } + } + // Lengths that aren't whole pixels, or a destination of another size, are refused. + assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none()); + assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none()); + let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap(); + let mut dst = [0.0; 6]; + gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap(); + assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}"); +} + #[test] fn encoded_profiles_are_reproducible() { assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap()); diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index da6da069e9..c38f5478fe 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index af686e1857..1b34110326 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -139,6 +139,7 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let profiles = !out.standard.cmyk_only(); + let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit }); 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 { @@ -148,7 +149,7 @@ impl Exporter<'_> { let icc = out.cmyk_image_icc(blob); crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref()) } else { - crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles) + crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; if let Some(w) = r.warning { self.warn(w); diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 5204e4f396..99b44d5167 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -6,6 +6,11 @@ //! monochrome (greyscale that is only black and white). An image already as asked (not resampled, //! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded, //! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless. +//! A gray or RGB image that carries a profile the Output settings convert from ([`ToSrgb`]) is +//! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a +//! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the +//! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose +//! converted levels are grey is compressed as greyscale. //! //! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is //! when its numbers are kept; the others' ink amounts are resampled, converted when the Output @@ -22,6 +27,7 @@ use image::imageops::FilterType; use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader}; use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use vectorcraft_color::cms::{IccProfile, Intent}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, jpeg_components}; use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply}; @@ -308,6 +314,93 @@ pub(crate) struct Recoded { pub warning: Option<&'static str>, } +/// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to +/// Destination with an sRGB destination, [`crate::output::ColorOut::image_srgb_intent`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct ToSrgb { + /// The rendering intent of the conversion. + pub intent: Intent, + /// 16-bit samples may be written (PDF 1.5 and later). + pub sixteen_bit: bool, +} + +/// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of +/// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when +/// the CMS can't read it. +fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option> { + let icc = source_icc(bytes).filter(|icc| valid_icc(icc, space))?; + Some(IccProfile::from_bytes(None, &icc).ok()) +} + +const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it 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. +fn into_srgb(img: DynamicImage, p: &IccProfile, intent: Intent) -> Option<(DynamicImage, bool)> { + let kind = img.color(); + let deep = kind.bits_per_pixel() > 8 * u16::from(kind.channel_count()); + let to8 = |v: f32| v.round() as u8; + let to16 = |v: f32| v.round() as u16; + Some(match (kind.has_color(), deep) { + (true, true) => { + let mut s = img.into_rgba16(); + let changed = convert_samples(&mut s, 3, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageRgba16(s), changed) + } + (true, false) => { + let mut s = img.into_rgba8(); + let changed = convert_samples(&mut s, 3, p, intent, 255.0, to8)?; + (DynamicImage::ImageRgba8(s), changed) + } + (false, true) => { + let mut s = img.into_luma_alpha16(); + let changed = convert_samples(&mut s, 1, p, intent, 65_535.0, to16)?; + (DynamicImage::ImageLumaA16(s), changed) + } + (false, false) => { + let mut s = img.into_luma_alpha8(); + let changed = convert_samples(&mut s, 1, p, intent, 255.0, to8)?; + (DynamicImage::ImageLumaA8(s), changed) + } + }) +} + +/// Convert the `n` colour samples (1: grey, 3: RGB) of each pixel of `samples` (followed by an +/// alpha sample each, which is left as it is) from ICC profile `p` to sRGB with `intent`, a run of +/// pixels at a time; `max` is a sample's largest value, `back` turns a value of 0 to `max` into a +/// sample. Grey pixels take the mean of the sRGB values. Whether any sample changed; `None` when +/// the transform fails. +fn convert_samples>( + samples: &mut [T], + n: usize, + p: &IccProfile, + intent: Intent, + max: f32, + back: impl Fn(f32) -> T, +) -> Option { + const RUN: usize = 4096; + let mut src = Vec::with_capacity(RUN * n); + let mut dst = vec![0.0f32; RUN * 3]; + let mut changed = false; + for run in samples.chunks_mut(RUN * (n + 1)) { + src.clear(); + src.extend(run.chunks_exact(n + 1).flat_map(|px| px.iter().take(n).map(|v| (*v).into() / max))); + let out = dst.get_mut(..src.len() / n * 3)?; + p.to_srgb_pixels(&src, out, intent)?; + for (px, rgb) in run.chunks_exact_mut(n + 1).zip(out.as_chunks::<3>().0) { + let grey = [rgb.iter().sum::() / 3.0]; + let values: &[f32] = if n == 1 { &grey } else { rgb }; + for (v, c) in px.iter_mut().zip(values) { + let new = back(c * max); + changed |= new != *v; + *v = new; + } + } + } + Some(changed) +} + /// Is `img` (a grey image) only black and white? fn bilevel(img: &DynamicImage) -> bool { img.to_luma_alpha8().pixels().all(|p| p[0] == 0 || p[0] == 255) @@ -372,8 +465,10 @@ 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 allows no ICC-based color spaces). -/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is. +/// 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. pub(crate) fn recode( bytes: &[u8], size: (f64, f64), @@ -381,6 +476,7 @@ pub(crate) fn recode( interpolate: bool, convert: Option<&Pixels>, profiles: bool, + to_srgb: Option, ) -> Recoded { let keep = Recoded { image: None, warning: None }; let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep }; @@ -393,10 +489,16 @@ pub(crate) fn recode( 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); - let (kind, pixels) = if grey { + // Pixels in a profile they are converted from are decoded, whatever the Compression settings. + let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) { + Some((Some(p), t)) => Some((p, t)), + Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED) }, + None => None, + }; + 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)); - if kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { + if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { return Recoded { image: None, warning: g.instead }; } let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; @@ -404,16 +506,34 @@ pub(crate) fn recode( } else { (Kind::Color, None) }; - let rule = rule(c, kind); + let mut rule = rule(c, kind); let convert = convert.filter(|_| kind == Kind::Color); - if kept(&rule, dims, size, jpeg) && convert.is_none() { + if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() { return Recoded { image: None, warning: rule.instead }; } - let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep }; + if let Some((p, t)) = &source { + let Some((srgb, changed)) = into_srgb(img, p, t.intent) else { return Recoded { image: None, warning: Some(UNCONVERTED) } }; + // The converted levels of a black-and-white image may be grey. + if kind == Kind::Mono && !bilevel(&srgb) { + (kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray)); + } + let as_is = kept(&rule, dims, size, jpeg); + // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB profile): its + // JPEG data, without the profile. + if !changed && as_is && jpeg { + return Recoded { image: Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(), warning: rule.instead }; + } + // Not resampled and lossless: written at its own depth. + if as_is && jpeg_quality(&rule, jpeg).is_none() { + return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead }; + } + img = srgb; + } // 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| valid_icc(icc, want)); + let icc = (profiles && convert.is_none() && source.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/output.rs b/crates/pdf/src/output.rs index 8114931bc4..628fda5a33 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -10,8 +10,11 @@ //! colours of the other model (and Lab ones), keeping the numbers of those already in it. //! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel //! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]); -//! printer's marks are not. The destination is a profile name; empty, the document's -//! profile for its colour mode. +//! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image +//! that carries an ICC profile is converted from it to sRGB with the same intent and written +//! without it ([`ColorOut::image_srgb_intent`]); without conversion it keeps its +//! profile. The destination is a profile name; empty, the document's profile for its colour +//! mode. //! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a //! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK //! with the CMYK profile in effect (the destination when converting to CMYK, else the @@ -50,6 +53,8 @@ struct Dest { keep: bool, /// The destination is sRGB (what images are). srgb: bool, + /// Convert to Destination was asked for (not Preserve Numbers, nor PDF/X-1a's own conversion). + destination: bool, } /// How an export writes colours (see the module documentation). @@ -233,11 +238,12 @@ impl ColorOut { }; let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?; // PDF/X-1a converts without being asked: as Preserve Numbers does. - let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name; + let destination = set.output.conversion == ColorConversion::Destination; + let keep = !destination || cms::canonical_name(&own) == name; if model == Model::Cmyk { out.cmyk_profile = name.clone(); } - out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep }); + out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination }); } out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4) || (!set.standard.cmyk_only() @@ -338,6 +344,14 @@ impl ColorOut { .as_ref() } + /// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB + /// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination + /// and an sRGB destination, also when the document's RGB space is sRGB. `None` otherwise: + /// such an image keeps its profile. + pub(crate) fn image_srgb_intent(&self) -> Option { + self.dest.as_ref().filter(|d| d.destination && d.model == Model::Rgb && d.srgb).map(|_| self.intent) + } + /// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it /// carries, when its numbers are kept and the Profiles setting or the standard includes source /// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK. diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 400bc0b0a8..856528cb7e 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -266,7 +266,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, true); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None); assert!(r.image.is_none(), "kept as it is"); } @@ -555,3 +555,285 @@ fn tagged_tiffs_keep_their_icc_profile() { } } } + +/// A gray ICC profile (version 2.1, display class) with a D50 white point whose tone curve is `trc`: +/// one entry, a gamma (u8Fixed8), or two, the ends of a table. 192 bytes. +fn gray_profile(trc: &[u16]) -> Vec { + let mut icc = vec![0u8; 192]; + icc[..4].copy_from_slice(&192u32.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(b"GRAY"); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&2u32.to_be_bytes()); + let curve_len = 12 + 2 * trc.len() as u32; + for (i, (sig, at, len)) in [(b"kTRC", 156u32, curve_len), (b"wtpt", 172, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + icc[156..160].copy_from_slice(b"curv"); + icc[164..168].copy_from_slice(&(trc.len() as u32).to_be_bytes()); + for (i, v) in trc.iter().enumerate() { + icc[168 + i * 2..170 + i * 2].copy_from_slice(&v.to_be_bytes()); + } + icc[172..176].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[180 + i * 4..184 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The samples of `img` converted from ICC profile `icc` to sRGB with `intent`, as an image +/// XObject holds them: RGB (or grey, the mean of the sRGB values, when `img` is grey) at 8 bits a +/// sample, or at 16 big-endian with `deep`. +fn srgb_samples(img: &DynamicImage, icc: &[u8], intent: vectorcraft_color::cms::Intent, deep: bool) -> Vec { + let p = vectorcraft_color::cms::IccProfile::from_bytes(None, icc).unwrap(); + let (max, n) = (if deep { 65_535.0 } else { 255.0 }, if img.color().has_color() { 3 } else { 1 }); + let values: Vec = match (n, deep) { + (3, true) => img.to_rgb16().into_raw().into_iter().map(f32::from).collect(), + (3, false) => img.to_rgb8().into_raw().into_iter().map(f32::from).collect(), + (_, true) => img.to_luma16().into_raw().into_iter().map(f32::from).collect(), + _ => img.to_luma8().into_raw().into_iter().map(f32::from).collect(), + }; + let mut out = vec![]; + for px in values.chunks(n) { + let v: Vec = px.iter().map(|v| v / max).collect(); + let rgb = p.to_srgb(&v, intent).unwrap(); + let rgb = if n == 1 { vec![rgb.iter().sum::() / 3.0] } else { rgb.to_vec() }; + for c in rgb { + let s = (c * max).round(); + if deep { out.extend_from_slice(&(s as u16).to_be_bytes()) } else { out.push(s as u8) } + } + } + out +} + +/// Do samples `a` and `b` (8 bits each, or 16 big-endian with `deep`) differ by at most `tolerance`? +fn samples_close(a: &[u8], b: &[u8], deep: bool, tolerance: i32) -> bool { + let values = |s: &[u8]| -> Vec { + if deep { s.chunks(2).map(|v| i32::from(u16::from_be_bytes([v[0], v[1]]))).collect() } else { s.iter().map(|v| i32::from(*v)).collect() } + }; + let (a, b) = (values(a), values(b)); + a.len() == b.len() && a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance) +} + +/// The image XObjects of `d` exported with `settings`, which add no warning, and the file. +fn export_images(d: &Document, settings: Value) -> (Vec, Vec) { + 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), r.bytes) +} + +/// With Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC +/// profile is converted from it to sRGB and written without it: an 8-bit TIFF, a 16-bit one (at +/// 16 bits a sample from PDF 1.5 on, else at 8), a PNG, a gray TIFF, a resampled image and a JPEG +/// kept as JPEG. Without conversion each keeps its profile. An image in an sRGB profile keeps its +/// samples (a JPEG its JPEG data), and a black-and-white image converted to grays stays gray. +#[test] +fn convert_to_srgb_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, SRGB, icc_bytes}; + // Marked version 4.2: PDF 1.7 holds ICC profiles up to that version. + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + // Gamma 1.8. + let gray = gray_profile(&[0x01CD]); + let to_srgb = |more: Value| { + let mut s = json!({"output": {"conversion": "destination", "destination": SRGB}}); + s.as_object_mut().unwrap().extend(more.as_object().unwrap().clone()); + s + }; + let device = |space: &str, bits: u8, filter: &str| vec![Written { space: space.into(), icc: None, bits, filter: filter.into(), smask: false }]; + let flate = "FlateDecode"; + 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 cases = [ + ("RGB TIFF", colour(), tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ("RGB PNG", colour(), tagged(&colour(), &p3, ImageFormat::Png), "image/png", &p3, json!({}), device("DeviceRGB", 8, flate), false), + ( + "16-bit RGB TIFF", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.7"}), + device("DeviceRGB", 16, flate), + true, + ), + ( + "16-bit RGB TIFF, PDF 1.4", + deep.clone(), + tagged(&deep, &p3, ImageFormat::Tiff), + "image/tiff", + &p3, + json!({"compatibility": "1.4"}), + device("DeviceRGB", 8, flate), + false, + ), + ( + "gray TIFF", + colour().grayscale(), + tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), + "image/tiff", + &gray, + json!({}), + device("DeviceGray", 8, flate), + false, + ), + ]; + for (what, img, bytes, mime, icc, more, want, sixteen) in cases { + let d = doc(bytes, mime); + let (spaces, pdf) = export_images(&d, to_srgb(more.clone())); + assert_eq!(spaces, want, "{what}"); + let samples = image_samples(&pdf); + // 16-bit samples are converted, then reduced to 8 bits before PDF 1.5. + let deep_source = matches!(img, DynamicImage::ImageRgb16(_)); + let want = srgb_samples(&img, icc, Intent::RelativeColorimetric, deep_source); + let want: Vec = if deep_source && !sixteen { + want.chunks(2).map(|v| (f32::from(u16::from_be_bytes([v[0], v[1]])) / 257.0).round() as u8).collect() + } else { + want + }; + assert!(samples.len() == 1 && samples_close(&samples[0], &want, sixteen, if sixteen { 2 } else { 1 }), "{what}: converted samples"); + let unconverted: Vec = match &img { + DynamicImage::ImageRgb16(s) if sixteen => s.iter().flat_map(|v| v.to_be_bytes()).collect(), + s if s.color().has_color() => s.to_rgb8().into_raw(), + s => s.to_luma8().into_raw(), + }; + assert!(!samples_close(&samples[0], &unconverted, sixteen, 0), "{what}: the samples changed"); + // Without conversion the image keeps its profile (a version 4 profile from PDF 1.5 on). + let mut plain = more; + plain["compatibility"] = json!("1.7"); + let (spaces, _) = export_images(&d, plain); + assert!( + spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(icc.as_slice()), + "{what}, no conversion: {spaces:?}" + ); + } + // Resampled, or kept as a JPEG: converted and written without the profile. + let resampled = to_srgb(json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}})); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff"), resampled); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "resampled"); + let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Jpeg), "image/jpeg"), to_srgb(json!({}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "JPEG"); + // An image in an sRGB profile keeps its samples, and a JPEG its JPEG data, written without the + // profile; so does an image whose samples the conversion leaves unchanged (black in P3). + let srgb = icc_bytes(SRGB).unwrap().to_vec(); + let (spaces, pdf) = export_images(&doc(tagged(&colour(), &srgb, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "sRGB PNG"); + assert!(image_samples(&pdf) == [colour().to_rgb8().into_raw()], "sRGB PNG: the samples unchanged"); + let jpeg = tagged(&colour(), &srgb, ImageFormat::Jpeg); + let (spaces, pdf) = export_images(&doc(jpeg.clone(), "image/jpeg"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "sRGB JPEG"); + assert!(image_data(&pdf) == [jpeg], "sRGB JPEG: the JPEG data unchanged"); + let black = DynamicImage::ImageRgb8(image::RgbImage::new(600, 600)); + let (spaces, _) = export_images(&doc(tagged(&black, &p3, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"}))); + assert_eq!(spaces, device("DeviceRGB", 8, flate), "black P3 PNG"); + // A black-and-white image whose profile makes black a gray is written in those grays, not + // thresholded back to black and white when it is resampled. + let bilevel = + DynamicImage::ImageLuma8(image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 10 + y / 10) % 2 == 0 { 0 } else { 255 }]))); + let lifted = gray_profile(&[0x3333, 0xFFFF]); + let resample = json!({"downsample": "bicubic", "ppi": 150, "abovePpi": 225}); + let (spaces, pdf) = export_images( + &doc(tagged(&bilevel, &lifted, ImageFormat::Tiff), "image/tiff"), + to_srgb(json!({"compression": {"gray": resample.clone(), "mono": resample}})), + ); + assert_eq!(spaces, device("DeviceGray", 8, flate), "lifted black and white"); + let samples = image_samples(&pdf); + assert!(samples.len() == 1 && samples[0].iter().all(|v| *v > 0) && samples[0].iter().any(|v| *v > 200), "lifted black and white: grays kept"); + // A well-formed profile the CMS can't read (no tags) is kept, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&doc(tagged(&colour(), &unread, ImageFormat::Tiff), "image/tiff"), &PdfOptions { settings, ..Default::default() }) + .unwrap(); + 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:?}"); +} + +/// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of +/// two grid points each: the colorimetric one (`A2B1`) maps device RGB linearly onto sRGB's +/// primaries adapted to D50, the perceptual one (`A2B0`) onto half of them. 436 bytes. +fn rgb_two_intents() -> Vec { + let mut icc = vec![0u8; 436]; + icc[..4].copy_from_slice(&436u32.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(b"RGB "); + icc[20..24].copy_from_slice(b"XYZ "); + icc[36..40].copy_from_slice(b"acsp"); + let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D]; + for (i, v) in d50.iter().enumerate() { + icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc[128..132].copy_from_slice(&3u32.to_be_bytes()); + for (i, (sig, at, len)) in [(b"A2B0", 168u32, 124u32), (b"A2B1", 292, 124), (b"wtpt", 416, 20)].into_iter().enumerate() { + let e = 132 + i * 12; + icc[e..e + 4].copy_from_slice(sig); + icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes()); + icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes()); + } + let primaries = [[0.4361f32, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + for (at, scale) in [(168usize, 0.5f32), (292, 1.0)] { + let mut lut = vec![]; + lut.extend_from_slice(b"mft2"); + lut.extend_from_slice(&[0, 0, 0, 0, 3, 3, 2, 0]); + // The identity matrix, as s15Fixed16 numbers. + for v in [1u32, 0, 0, 0, 1, 0, 0, 0, 1] { + lut.extend_from_slice(&(v << 16).to_be_bytes()); + } + lut.extend_from_slice(&2u16.to_be_bytes()); + lut.extend_from_slice(&2u16.to_be_bytes()); + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + for corner in 0..8u32 { + let on = [corner >> 2 & 1, corner >> 1 & 1, corner & 1].map(|b| b as f32); + let xyz = primaries.iter().zip(on).fold([0.0f32; 3], |sum, (p, o)| [sum[0] + o * p[0], sum[1] + o * p[1], sum[2] + o * p[2]]); + for v in xyz { + lut.extend_from_slice(&((v * scale * 32_768.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..3 { + lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]); + } + icc[at..at + 124].copy_from_slice(&lut); + } + icc[416..420].copy_from_slice(b"XYZ "); + for (i, v) in d50.iter().enumerate() { + icc[424 + i * 4..428 + i * 4].copy_from_slice(&v.to_be_bytes()); + } + icc +} + +/// The conversion of a tagged image to an sRGB destination uses the colour settings' intent: an +/// RGB profile with a perceptual table and a colorimetric one converts with the table the intent +/// picks. +#[test] +fn convert_to_srgb_converts_images_with_the_color_settings_intent() { + use vectorcraft_color::cms::{self, ColorSettings, Intent, SRGB}; + let icc = rgb_two_intents(); + let img = colour(); + let d = doc(tagged(&img, &icc, ImageFormat::Tiff), "image/tiff"); + let settings = json!({"output": {"conversion": "destination", "destination": SRGB}}); + let mut seen = vec![]; + for intent in [Intent::RelativeColorimetric, Intent::Perceptual] { + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let samples = image_samples(&cms::with_active(active, || export_images(&d, settings.clone())).1); + let want = srgb_samples(&img, &icc, intent, false); + assert!(samples.len() == 1 && samples_close(&samples[0], &want, false, 1), "{intent:?}: converted samples"); + seen.push(samples[0].clone()); + } + assert!(seen[0] != seen[1], "the two intents give different samples"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 99b0bfef38..0de5dc87ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5597,7 +5597,10 @@ the colour bars are process, spot and black-tint patches. Layers whose Print opt The Output and Advanced sections convert colours and write real text. `output: {conversion, destination}` converts every colour into the destination profile's model (`destination`; CMYK of another profile too) or only the colours of the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted -too and printer's marks are not. The destination is any profile `edit.colorSettings` lists (default: the document's +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 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 c6972468cc..36f92c2d72 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1861,6 +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`). - 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`). From 5956076b9f86178c654c39ec599dd6521e193a5e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:43:13 -0800 Subject: [PATCH 2/5] PDF export: CMYK and other RGB destinations convert tagged images from their profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, an RGB image that carries an ICC profile had its numbers read as sRGB, so a Display P3 image came out with the wrong colors. Such an image is now converted from its profile to sRGB with the color settings' rendering intent, then to the destination. - An untagged image converts from sRGB as before. - A gray image keeps its profile, as gray is not converted. - A profile the color engine can't read leaves the numbers read as sRGB, with a warning. - Preserve Numbers is unchanged. docs/mcp.md, the document.exportPdf params text and the progress doc describe the behavior. --- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/pdf/src/export/images.rs | 2 +- crates/pdf/src/images.rs | 93 +++++++++++++++------- crates/pdf/src/output.rs | 16 ++-- crates/pdf/src/tests/compression.rs | 116 +++++++++++++++++++++++++++- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 7 files changed, 196 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index c38f5478fe..77902b4a15 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; with destination and a CMYK destination, or another RGB one images are converted to (not when colours are ICC-tagged), an RGB image tagged with one is converted from it through sRGB; printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 1b34110326..325e5e0cf2 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -151,7 +151,7 @@ impl Exporter<'_> { } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; - if let Some(w) = r.warning { + for w in [r.warning, r.note].into_iter().flatten() { self.warn(w); } r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles)) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 99b44d5167..65fb8829cb 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -10,7 +10,9 @@ //! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a //! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the //! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose -//! converted levels are grey is compressed as greyscale. +//! converted levels are grey is compressed as greyscale. A colour image the Output settings also +//! convert to their destination ([`Pixels`]) goes from its profile to sRGB, then to the +//! destination; a grey one keeps its profile there, as grey pixels are not converted. //! //! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is //! when its numbers are kept; the others' ink amounts are resampled, converted when the Output @@ -312,10 +314,14 @@ pub(crate) struct Recoded { pub image: Option, /// Set when the image isn't compressed as asked. pub warning: Option<&'static str>, + /// Set when the image's colors are converted to the destination without its ICC profile, + /// which can't be read. + pub note: Option<&'static str>, } /// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to -/// Destination with an sRGB destination, [`crate::output::ColorOut::image_srgb_intent`]). +/// Destination, [`crate::output::ColorOut::image_srgb_intent`]): the destination when it is sRGB, +/// else the step before the image's [`Pixels`] conversion. #[derive(Clone, Copy, Debug)] pub(crate) struct ToSrgb { /// The rendering intent of the conversion. @@ -333,6 +339,7 @@ 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 CONVERTED_AS_SRGB: &str = "an image's ICC profile couldn't be read: its colors are converted to the destination as 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 @@ -467,8 +474,9 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// 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 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. +/// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as +/// they are keep their profile. `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), @@ -478,7 +486,7 @@ pub(crate) fn recode( profiles: bool, to_srgb: Option, ) -> Recoded { - let keep = Recoded { image: None, warning: None }; + let keep = Recoded { image: None, warning: None, note: None }; 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 }; @@ -490,16 +498,24 @@ pub(crate) fn recode( 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); // Pixels in a profile they are converted from are decoded, whatever the Compression settings. + // Grey pixels, which `convert` leaves as they are, keep their profile when it converts. + let to_srgb = to_srgb.filter(|_| !(grey && convert.is_some())); + // Converted to the destination as sRGB, as before, when the profile can't be read. + let mut note = None; let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) { Some((Some(p), t)) => Some((p, t)), - Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED) }, + Some((None, _)) if convert.is_some() => { + note = Some(CONVERTED_AS_SRGB); + None + } + Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, None => None, }; 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)); if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead { - return Recoded { image: None, warning: g.instead }; + return Recoded { image: None, warning: g.instead, note: None }; } let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; (if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img)) @@ -509,26 +525,45 @@ pub(crate) fn recode( let mut rule = rule(c, kind); let convert = convert.filter(|_| kind == Kind::Color); if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() { - return Recoded { image: None, warning: rule.instead }; + return Recoded { image: None, warning: rule.instead, note: None }; } let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep }; if let Some((p, t)) = &source { - let Some((srgb, changed)) = into_srgb(img, p, t.intent) else { return Recoded { image: None, warning: Some(UNCONVERTED) } }; - // The converted levels of a black-and-white image may be grey. - if kind == Kind::Mono && !bilevel(&srgb) { - (kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray)); - } - let as_is = kept(&rule, dims, size, jpeg); - // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB profile): its - // JPEG data, without the profile. - if !changed && as_is && jpeg { - return Recoded { image: Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(), warning: rule.instead }; - } - // Not resampled and lossless: written at its own depth. - if as_is && jpeg_quality(&rule, jpeg).is_none() { - return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead }; - } - img = srgb; + let srgb = match into_srgb(img, p, t.intent) { + Some(s) => Some(s), + None if convert.is_some() => { + // Converted to the destination as sRGB: decoded again (a colour image), as the + // conversion took the pixels. + note = Some(CONVERTED_AS_SRGB); + None + } + None => return Recoded { image: None, warning: Some(UNCONVERTED), note: None }, + }; + img = match srgb { + Some((srgb, changed)) => { + // The converted levels of a black-and-white image may be grey. + if kind == Kind::Mono && !bilevel(&srgb) { + (kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray)); + } + // Written as it is only when nothing converts it further. + let as_is = kept(&rule, dims, size, jpeg) && convert.is_none(); + // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB + // profile): its JPEG data, without the profile. + if !changed && as_is && jpeg { + let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(); + return Recoded { image, warning: rule.instead, note: None }; + } + // Not resampled and lossless: written at its own depth. + if as_is && jpeg_quality(&rule, jpeg).is_none() { + return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead, note: None }; + } + srgb + } + None => { + let Some(img) = decoded(bytes) else { return keep }; + img + } + }; } // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. @@ -561,7 +596,7 @@ pub(crate) fn recode( 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, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note } } /// A colorized image's coverage (one sample a pixel, `w` × `h`, see @@ -612,16 +647,16 @@ pub(crate) fn recode_cmyk( let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS) .then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) .flatten(); - return Recoded { image, warning: rule.instead }; + return Recoded { image, warning: rule.instead, note: None }; } } let Some(inks) = blob.cmyk() else { - return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) }; + return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }), note: None }; }; let (width, height) = sized(&rule, (inks.width, inks.height), size); let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) { Ok(r) => r, - Err(warning) => return Recoded { image: None, warning }, + Err(warning) => return Recoded { image: None, warning, note: None }, }; let (samples, space) = match convert { Some(p) => p.convert(&inks), @@ -630,7 +665,7 @@ pub(crate) fn recode_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, icc)); let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc)); - Recoded { image, warning: rule.instead } + Recoded { image, warning: rule.instead, note: None } } /// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any. diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 628fda5a33..7eda301372 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -12,9 +12,10 @@ //! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]); //! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image //! that carries an ICC profile is converted from it to sRGB with the same intent and written -//! without it ([`ColorOut::image_srgb_intent`]); without conversion it keeps its -//! profile. The destination is a profile name; empty, the document's profile for its colour -//! mode. +//! without it ([`ColorOut::image_srgb_intent`]); with `destination` and a destination its +//! pixels are converted to (CMYK, another RGB space), a colour image is converted from its +//! profile to sRGB and then to the destination. Without conversion it keeps its profile. The +//! destination is a profile name; empty, the document's profile for its colour mode. //! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a //! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK //! with the CMYK profile in effect (the destination when converting to CMYK, else the @@ -346,10 +347,13 @@ impl ColorOut { /// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB /// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination - /// and an sRGB destination, also when the document's RGB space is sRGB. `None` otherwise: - /// such an image keeps its profile. + /// and an sRGB destination, also when the document's RGB space is sRGB, or a destination + /// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space). + /// `None` otherwise, Preserve Numbers included: such an image keeps its profile, or its + /// numbers are converted as sRGB ones. pub(crate) fn image_srgb_intent(&self) -> Option { - self.dest.as_ref().filter(|d| d.destination && d.model == Model::Rgb && d.srgb).map(|_| self.intent) + let d = self.dest.as_ref().filter(|d| d.destination)?; + ((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent) } /// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 856528cb7e..3db8df4d25 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -529,9 +529,14 @@ fn tagged_tiffs_keep_their_icc_profile() { ); } } - // Converted pixels are in the destination's colours, not the image's profile. + // Converted pixels are in the destination's colours, not the image's profile, which the CMS + // can't read (it has no tags): they are converted as sRGB ones, with a warning. 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}})); + let mut settings: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": destination}})).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&tiff(&colour(), &rgb), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert!(r.warnings.len() == 1 && r.warnings[0].contains("converted to the destination as sRGB"), "{destination}: {:?}", r.warnings); + let spaces = image_spaces(&r.bytes); 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 (8 or 16 bits), PNG or JPEG is @@ -837,3 +842,110 @@ fn convert_to_srgb_converts_images_with_the_color_settings_intent() { } assert!(seen[0] != seen[1], "the two intents give different samples"); } + +/// A 600 × 600 image of sixteen blocks of colour, saturated ones (the primaries and secondaries) +/// among them. +fn colour_blocks() -> DynamicImage { + const LEVELS: [u8; 4] = [0, 90, 200, 255]; + image::RgbImage::from_fn(600, 600, |x, y| { + let (i, j) = ((x / 150) as usize, (y / 150) as usize); + image::Rgb([LEVELS[i], LEVELS[j], LEVELS[(i + 2 * j) % 4]]) + }) + .into() +} + +/// The samples (8 bits each) of `img`'s pixels as `convert` gives each one's: each distinct +/// colour converts once. +fn converted_samples(img: &DynamicImage, convert: impl Fn([u8; 3]) -> Vec) -> Vec { + let mut seen: std::collections::HashMap<[u8; 3], Vec> = std::collections::HashMap::new(); + let mut out = vec![]; + for p in img.to_rgb8().pixels() { + out.extend_from_slice(seen.entry(p.0).or_insert_with(|| convert(p.0))); + } + out +} + +/// The largest difference between samples `a` and `b` (8 bits each) and where it is: (difference, +/// sample index, the two samples), or the two lengths when they differ. +fn max_diff(a: &[u8], b: &[u8]) -> String { + if a.len() != b.len() { + return format!("lengths {} and {}", a.len(), b.len()); + } + let worst = a.iter().zip(b).enumerate().max_by_key(|(_, (a, b))| a.abs_diff(**b)); + worst.map_or_else(String::new, |(i, (x, y))| format!("largest difference {} at sample {i}: {x} vs {y}", x.abs_diff(*y))) +} + +/// With Convert to Destination and a CMYK destination, or an RGB one other than sRGB, a colour +/// image that carries an ICC profile is converted from that profile (to sRGB, then to the +/// destination) rather than from its numbers read as sRGB ones; an untagged image is converted +/// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the +/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. +#[test] +fn convert_to_destination_converts_tagged_images_from_their_profile() { + use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes}; + let intent = Intent::RelativeColorimetric; + let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap()); + let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec(); + p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]); + let p3_profile = cms::IccProfile::from_bytes(None, &p3).unwrap(); + // A P3 pixel's sRGB samples. + let from_p3 = |rgb: [u8; 3]| p3_profile.to_srgb(&rgb.map(|v| f32::from(v) / 255.0), intent).unwrap().map(|v| (v * 255.0).round() as u8); + let img = colour_blocks(); + let tagged_png = doc(tagged(&img, &p3, ImageFormat::Png), "image/png"); + let untagged_png = doc(encoded(img.clone(), ImageFormat::Png), "image/png"); + let device = |space: &str| vec![Written { space: space.into(), icc: None, bits: 8, filter: "FlateDecode".into(), smask: false }]; + // Each destination: the space images are written in, and how an sRGB pixel's samples convert + // to it (the tables the export builds from the destination's color settings). + let cmyk = cms::Cms::new(&ColorSettings { intent, cmyk: GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap(); + let wide = cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap(); + let cmy = cms::ProofLut::build(|rgb| { + let v = cmyk.srgb_to_cmyk(rgb, intent); + [v[0], v[1], v[2]] + }); + let black = cms::ProofLut::build(|rgb| [cmyk.srgb_to_cmyk(rgb, intent)[3], 0.0, 0.0]); + let wide_lut = cms::ProofLut::build(|rgb| wide.srgb_to_rgb(rgb)); + let to_cmyk = |rgb: [u8; 3]| [cmy.apply8(rgb).as_slice(), &black.apply8(rgb)[..1]].concat(); + let to_wide = |rgb: [u8; 3]| wide_lut.apply8(rgb).to_vec(); + type ToDest<'a> = &'a dyn Fn([u8; 3]) -> Vec; + let destinations: [(&str, &str, ToDest<'_>); 2] = [(GENERIC_CMYK, "DeviceCMYK", &to_cmyk), (WIDE_GAMUT_RGB, "DeviceRGB", &to_wide)]; + for (name, space, to_dest) in destinations { + let settings = json!({"output": {"conversion": "destination", "destination": name}}); + let export = |d: &Document| cms::with_active(active.clone(), || export_images(d, settings.clone())); + let (spaces, pdf) = export(&tagged_png); + assert_eq!(spaces, device(space), "{name}: tagged"); + let samples = image_samples(&pdf); + let want = converted_samples(&img, |rgb| to_dest(from_p3(rgb))); + let as_srgb = converted_samples(&img, to_dest); + assert!( + samples.len() == 1 && samples_close(&samples[0], &want, false, 1), + "{name}: converted from the profile, {}", + max_diff(&samples[0], &want) + ); + assert!(!samples_close(&samples[0], &as_srgb, false, 8), "{name}: not converted as sRGB"); + // An untagged image is converted from sRGB, as before. + let (spaces, pdf) = export(&untagged_png); + assert_eq!(spaces, device(space), "{name}: untagged"); + let samples = image_samples(&pdf); + assert!( + samples.len() == 1 && samples_close(&samples[0], &as_srgb, false, 1), + "{name}: untagged converted as sRGB, {}", + max_diff(&samples[0], &as_srgb) + ); + } + // A gray image keeps its gray profile in a CMYK destination. + let gray = gray_profile(&[0x01CD]); + let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}); + let gray_doc = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff"); + let (spaces, _) = cms::with_active(active.clone(), || export_images(&gray_doc, settings.clone())); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(gray.as_slice()), "gray: {spaces:?}"); + // A profile the CMS can't read: converted as sRGB, with a warning. + let unread = icc_profile(b"RGB ", 600); + let mut s: PdfSettings = serde_json::from_value(settings).unwrap(); + s.compression.compress_text = false; + let unread_doc = doc(tagged(&img, &unread, ImageFormat::Png), "image/png"); + let r = cms::with_active(active, || export_with_report(&unread_doc, &PdfOptions { settings: s, ..Default::default() })).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("converted to the destination as sRGB")), "{:?}", r.warnings); + assert_eq!(image_spaces(&r.bytes), device("DeviceCMYK"), "unread profile"); + let as_srgb = converted_samples(&img, to_cmyk); + assert!(image_samples(&r.bytes).first().is_some_and(|s| samples_close(s, &as_srgb, false, 1)), "unread profile: converted as sRGB"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 0de5dc87ee..51613a4f1c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5600,7 +5600,11 @@ the other model (`preserveNumbers`: colours already in it keep their numbers); g 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 +the color engine can't read is kept, with a warning); without conversion it keeps its profile. With `destination` and a +CMYK destination, or an RGB one other than sRGB that images are converted to, an RGB image that carries an ICC profile +is converted from that profile to sRGB with the same intent and then to the destination, instead of having its numbers +read as sRGB (a profile the color engine can't read leaves them read as sRGB, with a warning); a gray image keeps its +profile, as gray is not converted. `preserveNumbers` still reads an image's numbers as sRGB. 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..202ae95e12 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1862,6 +1862,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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 a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning (`convert_to_destination_converts_tagged_images_from_their_profile` in `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`). From 18e5e393d2786f6016de465afd4f00913d672304 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:57:51 -0800 Subject: [PATCH 3/5] 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`). From f55e42343e7361a04969747200a35637849dfbc9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:19:33 -0800 Subject: [PATCH 4/5] PDF export: state that the document's own RGB profile as destination keeps tagged images With Convert to Destination and an RGB destination that is the document's own RGB profile, RGB colors and RGB images keep their numbers, so an image that carries an ICC profile keeps it. The export contract and progress line now say so, and the test checks it. --- crates/pdf/src/output.rs | 5 +++-- crates/pdf/src/tests/compression.rs | 10 +++++++++- docs/mcp.md | 4 +++- docs/target-app-parity.md | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 7eda301372..92381e1628 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -349,8 +349,9 @@ impl ColorOut { /// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination /// and an sRGB destination, also when the document's RGB space is sRGB, or a destination /// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space). - /// `None` otherwise, Preserve Numbers included: such an image keeps its profile, or its - /// numbers are converted as sRGB ones. + /// `None` otherwise, Preserve Numbers and an RGB destination that is the document's own RGB + /// profile (which leaves RGB colours and RGB images as they are) included: such an image + /// keeps its profile, or its numbers are converted as sRGB ones. pub(crate) fn image_srgb_intent(&self) -> Option { let d = self.dest.as_ref().filter(|d| d.destination)?; ((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 37c67ed63b..963615307a 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -902,7 +902,9 @@ fn max_diff(a: &[u8], b: &[u8]) -> String { /// image that carries an ICC profile is converted from that profile (to sRGB, then to the /// destination) rather than from its numbers read as sRGB ones; an untagged image is converted /// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the -/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. +/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination +/// that is the document's own RGB profile leaves RGB images as they are, so the image keeps its +/// profile. #[test] fn convert_to_destination_converts_tagged_images_from_their_profile() { use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes}; @@ -955,6 +957,12 @@ fn convert_to_destination_converts_tagged_images_from_their_profile() { max_diff(&samples[0], &as_srgb) ); } + // A destination that is the document's own RGB profile leaves RGB images as they are: the + // image keeps its profile, as untagged images keep their numbers. + let own = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap()); + let settings = json!({"compatibility": "1.7", "output": {"conversion": "destination", "destination": WIDE_GAMUT_RGB}}); + let (spaces, _) = cms::with_active(own, || export_images(&tagged_png, settings)); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(p3.as_slice()), "own RGB profile: {spaces:?}"); // A gray image keeps its gray profile in a CMYK destination. let gray = gray_profile(&[0x01CD]); let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}); diff --git a/docs/mcp.md b/docs/mcp.md index 35ced7d167..ee1523e927 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5604,7 +5604,9 @@ whose profile the color engine can't read, or whose pixels can't be decoded, kee conversion it keeps its profile. With `destination` and a CMYK destination, or an RGB one other than sRGB that images are converted to, an RGB image that carries an ICC profile is converted from that profile to sRGB with the same intent and then to the destination, instead of having its numbers read as sRGB (a profile the color engine can't read leaves -them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. +them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. An RGB destination that +is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile +there. `preserveNumbers` still reads an image's numbers as sRGB. 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d7d846787d..43383032a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1862,7 +1862,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. 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`). - - PDF export with Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning (`convert_to_destination_converts_tagged_images_from_their_profile` in `crates/pdf/src/tests/compression.rs`). + - PDF export with Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning. An RGB destination that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile there (`convert_to_destination_converts_tagged_images_from_their_profile` in `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`). From 1a4dd4f8005defbd0c394f2c7df979f1453323cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:39:38 -0800 Subject: [PATCH 5/5] PDF export: limit the own-profile rule to destinations other than sRGB An sRGB destination converts tagged gray and RGB images to sRGB even when the document's own RGB profile is sRGB, so the statement that the document's own profile as destination keeps tagged images applies only to other RGB profiles. --- crates/pdf/src/tests/compression.rs | 2 +- docs/mcp.md | 4 ++-- docs/target-app-parity.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index 963615307a..f36cb88e4b 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -903,7 +903,7 @@ fn max_diff(a: &[u8], b: &[u8]) -> String { /// destination) rather than from its numbers read as sRGB ones; an untagged image is converted /// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the /// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination -/// that is the document's own RGB profile leaves RGB images as they are, so the image keeps its +/// other than sRGB that is the document's own RGB profile leaves RGB images as they are, so the image keeps its /// profile. #[test] fn convert_to_destination_converts_tagged_images_from_their_profile() { diff --git a/docs/mcp.md b/docs/mcp.md index ee1523e927..da8cad13d3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5604,8 +5604,8 @@ whose profile the color engine can't read, or whose pixels can't be decoded, kee conversion it keeps its profile. With `destination` and a CMYK destination, or an RGB one other than sRGB that images are converted to, an RGB image that carries an ICC profile is converted from that profile to sRGB with the same intent and then to the destination, instead of having its numbers read as sRGB (a profile the color engine can't read leaves -them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. An RGB destination that -is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile +them read as sRGB, with a warning); a gray image keeps its profile, as gray is not converted. An RGB destination other +than sRGB that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile there. `preserveNumbers` still reads an image's numbers as sRGB. 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 43383032a5..68c26fd2af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1862,7 +1862,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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. 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`). - - PDF export with Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning. An RGB destination that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile there (`convert_to_destination_converts_tagged_images_from_their_profile` in `crates/pdf/src/tests/compression.rs`). + - PDF export with Output › Convert to Destination and a CMYK destination, or an RGB destination other than sRGB that images are converted to, converts an RGB image that carries an ICC profile from that profile to sRGB and then to the destination, with the color settings' rendering intent, instead of reading its numbers as sRGB ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)). An untagged image converts from sRGB as before, a gray image keeps its profile, and a profile the color engine can't read leaves the numbers read as sRGB, with a warning. An RGB destination other than sRGB that is the document's own RGB profile leaves RGB colors and RGB images as they are, so a tagged RGB image keeps its profile there (`convert_to_destination_converts_tagged_images_from_their_profile` in `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`).