diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index ad52eea1a2..8cd3f85a4f 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -386,6 +386,21 @@ impl ImageBlob { if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) } } + /// The CMYK ICC profile a CMYK image carries: a four-component JPEG's `ICC_PROFILE` APP2 + /// segments put together, or a CMYK TIFF's tag 34675. `None` for other images, and for a + /// profile that isn't one of CMYK colors. Reads only the file's headers and tags. + pub fn cmyk_icc_profile(&self) -> Option> { + let bytes = self.bytes.as_slice(); + if jpeg_components(bytes) == Some(4) { + return cmyk_icc(jpeg_icc(bytes)); + } + let (kind, mut d) = tiff(bytes)?; + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + return None; + } + cmyk_icc(tiff_icc(&mut d)) + } + /// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image /// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered /// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index c834e653e4..a891a81ff2 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, 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), 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, 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", has_doc, export_pdf ), diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index d0158d8cd2..8110ce2db5 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -144,7 +144,8 @@ impl Exporter<'_> { self.rgb_in_cmyk.images.insert(key.to_string()); } let r = if cmyk_image { - crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None) + 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()) }; @@ -191,7 +192,8 @@ impl Exporter<'_> { let doc = self.doc; let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; - let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha)); + let icc = self.out.cmyk_image_icc(blob); + let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha), icc.as_deref()); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); } @@ -313,10 +315,6 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { Some(png) } -/// Most pixels an image may have to be embedded: twice what the app reads (64 million), so every image -/// it holds is still written. -const MAX_EMBED_PIXELS: u64 = 1 << 27; - /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), /// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a /// sample is written at 8 bits ([`png_8_bit`]). @@ -324,7 +322,7 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); - if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > MAX_EMBED_PIXELS) { + if size.is_ok_and(|(w, h)| u64::from(w) * u64::from(h) > crate::images::MAX_EMBED_PIXELS) { return None; } let bytes = blob.bytes.as_ref().clone(); diff --git a/crates/pdf/src/export/writer.rs b/crates/pdf/src/export/writer.rs index 9f6d9d6906..ca70ef2695 100644 --- a/crates/pdf/src/export/writer.rs +++ b/crates/pdf/src/export/writer.rs @@ -271,7 +271,8 @@ impl Writer { PdfError::from(e) })?; if self.cmyk { - cmyk_blending(&mut bytes, self.out.tagged); + let profile = self.out.tagged.then(|| vectorcraft_color::cms::icc_bytes(self.out.cmyk_profile()).ok()).flatten(); + cmyk_blending(&mut bytes, self.out.tagged, profile.as_deref(), self.out.standard != Standard::PdfA2b); } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; @@ -285,12 +286,21 @@ impl Writer { /// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries /// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the /// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that -/// of screen colours, as on screen. -fn cmyk_blending(pdf: &mut [u8], tagged: bool) { +/// of screen colours, as on screen. Tagged groups blend in the space of CMYK profile `profile` +/// (the one that tags CMYK colours) when the file has one. When its only CMYK spaces are of other +/// profiles (images' own), they blend in DeviceCMYK where `device` allows it, else they keep RGB; +/// without `profile`, they blend in the first CMYK space. +fn cmyk_blending(pdf: &mut [u8], tagged: bool, profile: Option<&[u8]>, device: bool) { let masks = luminosity_mask_groups(pdf); let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())]; if tagged { - spaces.extend(icc_spaces(pdf)); + let own = profile.and_then(|icc| crate::output::tagging_stream(pdf, &crate::patch::Xref::read(pdf)?, icc)); + let without = match (profile, device) { + (None, _) => Without::FirstCmyk, + (Some(_), true) => Without::DeviceCmyk, + (Some(_), false) => Without::Rgb, + }; + spaces.extend(icc_spaces(pdf, own, without)); } let mut from = 0; while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) { @@ -303,11 +313,22 @@ fn cmyk_blending(pdf: &mut [u8], tagged: bool) { } } +/// What tagged groups blend in when the file holds no space of the document's CMYK profile. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Without { + /// The first ICC-based CMYK space of the file. + FirstCmyk, + DeviceCmyk, + /// The RGB space they have. + Rgb, +} + /// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead, -/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one. -fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { +/// as group `/CS` entries (`/CS 3 0 R`): the one whose profile is stream `own`, else as `without` +/// says; none without a CMYK one. +fn icc_spaces(pdf: &[u8], own: Option, without: Without) -> Vec<(String, String)> { const ICC: &[u8] = b"[/ICCBased "; - let (mut rgb, mut cmyk) = (vec![], None); + let (mut rgb, mut cmyk, mut own_cmyk) = (vec![], None, None); let mut from = 0; while let Some(at) = find(pdf, ICC, from) { from = at + ICC.len(); @@ -319,11 +340,19 @@ fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> { let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue }; let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default(); if find(dict, b"/N 4", 0).is_some() { + if own == Some(stream) { + own_cmyk.get_or_insert(format!("/CS {array} 0 R")); + } cmyk.get_or_insert(format!("/CS {array} 0 R")); } else if find(dict, b"/N 3", 0).is_some() { rgb.push(format!("/CS {array} 0 R")); } } + let cmyk = own_cmyk.or_else(|| match without { + Without::FirstCmyk => cmyk, + Without::DeviceCmyk => cmyk.map(|_| "/CS/DeviceCMYK".to_string()), + Without::Rgb => None, + }); let Some(cmyk) = cmyk else { return vec![] }; rgb.into_iter().map(|r| (r, cmyk.clone())).collect() } @@ -385,3 +414,27 @@ pub(super) fn date_time(t: i64) -> krilla::metadata::DateTime { .utc_offset_hour(0) .utc_offset_minute(0) } + +#[cfg(test)] +mod tests { + use super::{Without, icc_spaces}; + + /// Objects 1 to 3: an RGB ICC-based space (profile 4), then two CMYK ones (profiles 5, 6). + const SPACES: &[u8] = b"%PDF-1.7\n1 0 obj\n[/ICCBased 4 0 R]\nendobj\n2 0 obj\n[/ICCBased 5 0 R]\nendobj\n3 0 obj\n[/ICCBased 6 0 R]\nendobj\n\ + 4 0 obj\n<>stream\n\nendstream\nendobj\n5 0 obj\n<>stream\n\nendstream\nendobj\n\ + 6 0 obj\n<>stream\n\nendstream\nendobj\n"; + + #[test] + fn groups_blend_in_the_documents_cmyk_space_else_device_cmyk() { + let rgb = "/CS 1 0 R".to_string(); + // The space of the document's profile, wherever it is. + for without in [Without::FirstCmyk, Without::DeviceCmyk, Without::Rgb] { + assert_eq!(icc_spaces(SPACES, Some(6), without), [(rgb.clone(), "/CS 3 0 R".to_string())], "{without:?}"); + } + // Without it, DeviceCMYK, or RGB where DeviceCMYK isn't allowed, rather than another + // profile's space; the first CMYK space when the document's profile isn't known. + assert_eq!(icc_spaces(SPACES, None, Without::DeviceCmyk), [(rgb.clone(), "/CS/DeviceCMYK".to_string())]); + assert_eq!(icc_spaces(SPACES, None, Without::Rgb), []); + assert_eq!(icc_spaces(SPACES, None, Without::FirstCmyk), [(rgb, "/CS 2 0 R".to_string())]); + } +} diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 4139259583..d1382449e5 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -10,7 +10,9 @@ //! 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 //! settings convert CMYK colours ([`CmykPixels`]) and compressed again. CMYK JPEGs hold Adobe's -//! inverted samples, which the PDF writer's Decode array undoes. +//! inverted samples, which the PDF writer's Decode array undoes. A CMYK image whose numbers are +//! kept carries the CMYK profile it is given ([`cmyk_icc`]), whether it is embedded as it is or +//! written again; without one it is untagged, a JPEG's own `ICC_PROFILE` segments included. use std::io::Cursor; use std::sync::Arc; @@ -26,7 +28,8 @@ use vectorcraft_doc::cmyk::jpeg_components; use crate::output::{CmykPixels, Pixels}; use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; -/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel. +/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel, with the +/// ICC profile the colour samples are in, if any. #[derive(Clone, Hash)] struct Raw { color: Arc>, @@ -34,6 +37,7 @@ struct Raw { width: u32, height: u32, space: ImageColorspace, + icc: Option>>, } impl CustomImage for Raw { @@ -54,7 +58,7 @@ impl CustomImage for Raw { } fn icc_profile(&self) -> Option<&[u8]> { - None + self.icc.as_deref().map(Vec::as_slice) } fn color_space(&self) -> ImageColorspace { @@ -79,7 +83,7 @@ fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Op (px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb) }; let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect())); - Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok() + Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space, icc: None }, interpolate).ok() } /// Are all of the RGBA pixels `rgba` grey? @@ -88,9 +92,57 @@ fn neutral(rgba: &[u8]) -> bool { } /// Colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) with an optional `alpha` -/// channel, written losslessly. -fn lossless(samples: Vec, alpha: Option>, space: ImageColorspace, width: u32, height: u32, interpolate: bool) -> Option { - Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space }, interpolate).ok() +/// channel, written losslessly, tagged with ICC profile `icc` when given. +fn lossless( + samples: Vec, + alpha: Option>, + space: ImageColorspace, + width: u32, + height: u32, + interpolate: bool, + icc: Option<&[u8]>, +) -> Option { + let icc = icc.map(|icc| Arc::new(icc.to_vec())); + Image::from_custom(Raw { color: Arc::new(samples), alpha: alpha.map(Arc::new), width, height, space, icc }, interpolate).ok() +} + +/// Most pixels an image may have to be embedded: twice what the app reads (64 million), so every image +/// it holds is still written. +pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; + +/// The most bytes a CMYK profile carried by an image is written with (16 MiB). +const MAX_ICC: usize = 16 << 20; + +/// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a +/// CMYK image can be tagged with: at most [`MAX_ICC`] bytes, the size its header declares, the +/// `acsp` signature, an input, display, output or colour space profile of CMYK colours, and a tag +/// table whose tags lie within it. `None` for other images and other profiles. +pub(crate) fn cmyk_icc(blob: &ImageBlob) -> Option> { + blob.cmyk_icc_profile().filter(|icc| valid_cmyk_icc(icc)) +} + +/// Is `icc` a CMYK profile as [`cmyk_icc`] describes? +fn valid_cmyk_icc(icc: &[u8]) -> bool { + let be = |at: usize| icc.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let fits = |at: u32, len: u32| u64::from(at) + u64::from(len) <= icc.len() as u64; + if icc.len() > MAX_ICC || be(0).and_then(|n| usize::try_from(n).ok()) != Some(icc.len()) { + return false; + } + let class = icc.get(12..16); + if icc.get(36..40) != Some(b"acsp".as_slice()) + || icc.get(16..20) != Some(b"CMYK".as_slice()) + || ![b"scnr", b"mntr", b"prtr", b"spac"].iter().any(|c| class == Some(c.as_slice())) + { + return false; + } + let Some(count) = be(128) else { return false }; + if !fits(132, count.saturating_mul(12)) { + return false; + } + (0..count as usize).all(|i| { + let at = 132 + i * 12; + matches!((be(at + 4), be(at + 8)), (Some(offset), Some(len)) if fits(offset, len)) + }) } /// [`from_rgba`] of pixels converted by `pixels` (when given). @@ -114,7 +166,7 @@ pub(crate) fn from_inks(inks: Vec, alpha: Vec, width: u32, height: u32, Some(p) => p.convert(&inks), None => (inks, ImageColorspace::Cmyk), }; - lossless(samples, alpha, space, width, height, interpolate) + lossless(samples, alpha, space, width, height, interpolate, None) } /// The kinds of images the Compression section has settings for. @@ -291,7 +343,7 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in None if grey => (rgba.pixels().map(|p| p[0]).collect(), ImageColorspace::Luma), None => (rgba.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb), }; - to_jpeg(&samples, space, width, height, q, interpolate) + to_jpeg(&samples, space, width, height, q, interpolate, None) }); let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert)); Recoded { image, warning: rule.instead } @@ -323,7 +375,8 @@ pub(crate) fn coverage_sized(w: u32, h: u32, cover: Vec, size: (f64, f64), c /// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled, /// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see /// [`mask_alpha`]) the image is lossless and carries it as its soft mask; `None` then when its -/// inks can't be read. +/// inks can't be read. When its numbers are kept, the image is tagged with CMYK profile `icc` +/// (see [`cmyk_icc`]), and is untagged without one; converted pixels are never tagged with it. pub(crate) fn recode_cmyk( blob: &ImageBlob, size: (f64, f64), @@ -331,13 +384,20 @@ pub(crate) fn recode_cmyk( interpolate: bool, convert: Option<&CmykPixels>, alpha: Option>, + icc: Option<&[u8]>, ) -> Recoded { let rule = rule(c, Kind::Color); let jpeg = jpeg_components(&blob.bytes).is_some(); + let icc = icc.filter(|_| convert.is_none()); if jpeg && convert.is_none() && alpha.is_none() { let dims = ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); - if dims.is_some_and(|d| kept(&rule, d, size, true)) { - return Recoded { image: None, warning: rule.instead }; + if let Some((w, h)) = dims.filter(|d| kept(&rule, *d, size, true)) { + // The JPEG data as it is, tagged with `icc` rather than with its own segments; one + // larger than [`MAX_EMBED_PIXELS`] is not written. + 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 }; } } let Some(inks) = blob.cmyk() else { @@ -360,8 +420,8 @@ pub(crate) fn recode_cmyk( None => (inks, ImageColorspace::Cmyk), }; // JPEG needs opaque pixels. - let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate)); - let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate)); + 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 } } @@ -405,9 +465,9 @@ pub(crate) fn mask_alpha(blob: &ImageBlob, width: u32, height: u32, clip: bool) } /// Opaque colour `samples` (grey, RGB, or CMYK ink amounts, as `space` says) as a JPEG at -/// `quality`. CMYK JPEGs hold Adobe's inverted samples, which the PDF writer's Decode array -/// undoes. -fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool) -> Option { +/// `quality`, tagged with ICC profile `icc` when given. CMYK JPEGs hold Adobe's inverted samples, +/// which the PDF writer's Decode array undoes. +fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, quality: u8, interpolate: bool, icc: Option<&[u8]>) -> Option { let mut out = Vec::new(); match space { ImageColorspace::Cmyk => { @@ -420,5 +480,5 @@ fn to_jpeg(samples: &[u8], space: ImageColorspace, width: u32, height: u32, qual } ImageColorspace::DeviceN => return None, } - Image::from_jpeg(out.into(), interpolate).ok() + Image::from_jpeg_with_icc(out.into(), icc.map(|i| i.to_vec().into()), interpolate).ok() } diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 77bc7d1d66..8114931bc4 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -18,7 +18,9 @@ //! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey //! with the sRGB tone curve. Otherwise they are device colours. PDF/A, PDF/X-3 and PDF/X-4 //! always tag them, whatever `profiles` says; PDF/X-1a never does, and always converts to a -//! CMYK destination (see [`crate::pdfx`]). +//! CMYK destination (see [`crate::pdfx`]). A CMYK image that keeps its numbers is tagged with +//! the CMYK profile it carries, if any, with `all` and `taggedSource`, and in PDF/A, PDF/X-3 +//! and PDF/X-4 unless `profiles` is `destination` ([`ColorOut::cmyk_image_icc`]). //! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent //! of the catalog with the condition, identifier and registry; Trapped goes in the document //! information (`/False` when an output intent is written untrapped). PDF/A files carry their @@ -59,6 +61,9 @@ pub(crate) struct ColorOut { dest: Option, /// Colours are written in ICC-based colour spaces. pub tagged: bool, + /// CMYK images that keep their numbers are tagged with the CMYK profile they carry + /// ([`Self::cmyk_image_icc`]). + image_icc: bool, /// The CMYK profile that tags CMYK colours. cmyk_profile: String, /// Tagged RGB colours are written as their sRGB equivalents: why (a warning), when their RGB @@ -105,6 +110,7 @@ impl Default for ColorOut { profile_notes: vec![], dest: None, tagged: false, + image_icc: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), @@ -240,6 +246,14 @@ impl ColorOut { ProfileInclusion::All | ProfileInclusion::Destination => true, ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(), }); + // Source profiles: with `all` and `taggedSource`, and in the files that tag every colour + // unless only the destination profile is asked for; never in PDF/X-1a. + out.image_icc = !set.standard.cmyk_only() + && match set.output.profiles { + ProfileInclusion::All | ProfileInclusion::TaggedSource => true, + ProfileInclusion::None => matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4), + ProfileInclusion::Destination => false, + }; let rgb = match &out.dest { Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()), _ => (source.rgb_is_srgb(), st.rgb.clone()), @@ -324,6 +338,16 @@ impl ColorOut { .as_ref() } + /// 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. + pub(crate) fn cmyk_image_icc(&self, blob: &vectorcraft_doc::ImageBlob) -> Option> { + if !self.image_icc || self.cmyk_pixels().is_some() { + return None; + } + crate::images::cmyk_icc(blob) + } + /// How CMYK images' ink amounts are converted; `None` when they keep their numbers (no /// conversion, or to a CMYK destination that keeps them). pub(crate) fn cmyk_pixels(&self) -> Option<&CmykPixels> { @@ -477,7 +501,7 @@ fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option { /// The object number of the stream of an ICC-based colour space of `pdf` that holds profile `icc` /// (its data as it is, or Flate-compressed); `None` when no colour space uses that profile. -fn tagging_stream(pdf: &[u8], xref: &Xref, icc: &[u8]) -> Option { +pub(crate) fn tagging_stream(pdf: &[u8], xref: &Xref, icc: &[u8]) -> Option { use std::io::Read; const ICC: &[u8] = b"[/ICCBased "; let mut from = 0; diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 45222bb04d..4719beec70 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -75,16 +75,18 @@ fn cmyk_dict(w: u32, h: u32, more: &str) -> String { const INVERTED: &str = "/Decode [1 0 1 0 1 0 1 0]"; -/// An image XObject of a written PDF: its colour space and the components of its ICC profile, -/// its Decode array, its filter and its stream data. +/// An image XObject of a written PDF: its colour space and the components and bytes of its ICC +/// profile, its Decode array, its filter, its stream data and whether it has a soft mask. #[derive(Debug)] struct Written { space: String, icc_n: Option, + icc: Option>, decode: Vec, filter: Option, data: Vec, width: u32, + smask: bool, } fn written(pdf: &[u8]) -> Vec { @@ -96,22 +98,26 @@ fn written(pdf: &[u8]) -> Vec { if d.get::>(b"Subtype").is_none_or(|n| n.as_ref() != b"Image") { continue; } - let (space, icc_n) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { - (Some(n), _) => (n.as_str().to_string(), None), + let (space, icc_n, icc) = match (d.get::>(b"ColorSpace"), d.get::>(b"ColorSpace")) { + (Some(n), _) => (n.as_str().to_string(), None, None), (_, Some(a)) => { let mut it = a.flex_iter(); let name = it.next::>().unwrap().as_str().to_string(); - (name, it.next::>().and_then(|s| s.dict().get::(b"N"))) + let profile = it.next::>(); + let n = profile.as_ref().and_then(|s| s.dict().get::(b"N")); + (name, n, profile.map(|s| s.decoded().unwrap().into_owned())) } - _ => (String::new(), None), + _ => (String::new(), None, None), }; out.push(Written { space, icc_n, + icc, decode: d.get::>(b"Decode").map(|a| a.iter::().collect()).unwrap_or_default(), filter: d.get::>(b"Filter").map(|n| n.as_str().to_string()), data: s.raw_data().to_vec(), width: d.get::(b"Width").unwrap_or(0), + smask: d.get::>(b"SMask").is_some(), }); } out @@ -447,6 +453,222 @@ fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_ assert_eq!(blob.cmyk().unwrap().data, flat(ink, 16)); } +/// A version 2.1 output profile of CMYK colors with a one-tag table (`cprt`), its header and table +/// consistent with its size; `edit` changes it after it is made. +fn cmyk_profile(edit: impl FnOnce(&mut Vec)) -> Vec { + let mut icc = vec![0u8; 156]; + icc[0..4].copy_from_slice(&156u32.to_be_bytes()); + icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + icc[12..16].copy_from_slice(b"prtr"); + icc[16..20].copy_from_slice(b"CMYK"); + icc[20..24].copy_from_slice(b"Lab "); + icc[36..40].copy_from_slice(b"acsp"); + icc[128..132].copy_from_slice(&1u32.to_be_bytes()); + icc[132..136].copy_from_slice(b"cprt"); + icc[136..140].copy_from_slice(&144u32.to_be_bytes()); + icc[140..144].copy_from_slice(&12u32.to_be_bytes()); + icc[144..156].copy_from_slice(b"text\0\0\0\0VC\0\0"); + edit(&mut icc); + icc +} + +/// `inks` (four bytes a pixel) as a CMYK JPEG carrying `icc` in its `ICC_PROFILE` segments. +fn cmyk_jpeg_with(inks: &[u8], w: u16, h: u16, icc: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut e = jpeg_encoder::Encoder::new(&mut out, 100); + e.add_icc_profile(icc).unwrap(); + e.encode(inks, w, h, jpeg_encoder::ColorType::Cmyk).unwrap(); + out +} + +/// A CMYK TIFF of `w` × `h` pixels of `ink` carrying `icc` as its tag 34675 (written as Open +/// stores a CMYK JPEG that carries one). +fn cmyk_tiff_with(ink: [u8; 4], w: u16, h: u16, icc: &[u8]) -> ImageBlob { + let jpg = cmyk_jpeg_with(&flat(ink, usize::from(w) * usize::from(h)), w, h, icc); + let (tiff, _) = vectorcraft_doc::cmyk::turned_cmyk(&jpg, image::metadata::Orientation::NoTransforms, None).unwrap(); + let blob = ImageBlob::new("image/tiff", tiff); + assert_eq!(blob.cmyk_icc_profile().as_deref(), Some(icc), "the TIFF carries the profile"); + blob +} + +/// A CMYK image whose numbers are kept is written in an ICC-based space of the CMYK profile it +/// carries, byte for byte, when the Profiles setting or the standard includes source profiles: +/// a TIFF written again, a JPEG kept as it is or compressed again, and an image with its mask as +/// its soft mask. Its inks are those it had. Without source profiles it is DeviceCMYK, and a +/// converted image never carries the profile its old numbers were in. +#[test] +fn a_cmyk_image_that_keeps_its_numbers_carries_its_cmyk_profile() { + let ink = [0u8, 102, 255, 0]; + let icc = cmyk_profile(|_| {}); + let tiff = cmyk_tiff_with(ink, 4, 4, &icc); + let tiff_inks = tiff.cmyk().unwrap().data; + let d = image_doc(tiff, 4, 4); + let export = |d: &Document, s: serde_json::Value| { + let out = export_with_report(d, &opts(s.clone())).unwrap_or_else(|e| panic!("{s}: {e}")); + let w: Vec = written(&out.bytes).into_iter().filter(|w| w.space != "DeviceGray").collect(); + assert_eq!(w.len(), 1, "{s}: {w:?}"); + (w.into_iter().next().unwrap(), out.bytes) + }; + let tagged = |w: &Written, s: &serde_json::Value| { + assert_eq!((w.space.as_str(), w.icc_n), ("ICCBased", Some(4)), "{s}"); + assert!(w.icc.as_deref() == Some(icc.as_slice()), "{s}: the image's own profile, byte for byte"); + }; + for s in [ + json!({"output": {"profiles": "all"}}), + json!({"output": {"profiles": "taggedSource"}}), + json!({"standard": "pdfX4"}), + json!({"standard": "pdfX3"}), + json!({"output": {"profiles": "all", "conversion": "preserveNumbers", "destination": DEVICE_CMYK}}), + ] { + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert_eq!(blobs(&import(&bytes).unwrap())[0].cmyk().unwrap().data, tiff_inks, "{s}: the inks as they were"); + } + // Without source profiles, untagged; PDF/X-1a images are always DeviceCMYK. + for s in [json!({}), json!({"output": {"profiles": "destination"}}), json!({"standard": "pdfX1a", "output": {"profiles": "all"}})] { + let (w, _) = export(&d, s.clone()); + assert!(w.icc.as_deref() != Some(icc.as_slice()), "{s}: {w:?}"); + } + let (w, _) = export(&d, json!({"standard": "pdfX1a", "output": {"profiles": "all"}})); + assert_eq!(w.space, "DeviceCMYK"); + // Converted to another CMYK profile: its new numbers aren't in the old profile. + let (w, _) = export(&d, json!({"output": {"profiles": "all", "conversion": "destination", "destination": DEVICE_CMYK}})); + assert!(w.icc.as_deref() != Some(icc.as_slice()), "{w:?}"); + + // A CMYK JPEG kept as it is carries the profile only when source profiles are included, and + // PDF/X-1a writes it untagged. + let jpg = cmyk_jpeg_with(&flat(ink, 64 * 64), 64, 64, &icc); + let d = image_doc(ImageBlob::new("image/jpeg", jpg.clone()), 64, 64); + let s = json!({"output": {"profiles": "all"}}); + let (w, _) = export(&d, s.clone()); + tagged(&w, &s); + assert!(w.data == jpg, "the JPEG data unchanged"); + for s in [json!({"standard": "pdfX1a"}), json!({})] { + let (w, _) = export(&d, s.clone()); + assert_eq!((w.space.as_str(), w.filter.as_deref()), ("DeviceCMYK", Some("DCTDecode")), "{s}"); + assert!(w.data == jpg, "{s}: the JPEG data unchanged"); + } + // Resampled and compressed again, it keeps the profile. + let s = json!({"output": {"profiles": "all"}, "compression": {"color": {"downsample": "bicubic", "ppi": 72.0, "abovePpi": 100.0}}}); + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert_eq!((w.width, w.filter.as_deref()), (40, Some("DCTDecode")), "{s}"); + assert!(blobs(&import(&bytes).unwrap())[0].cmyk().unwrap().data.as_chunks::<4>().0.iter().all(|p| close(p, &ink, 3))); + + // With its mask as its soft mask. + let mut d = masked_doc(png(image::GrayImage::from_raw(2, 2, vec![255, 128, 0, 64]).unwrap()), true, false); + d.images.insert("img".into(), cmyk_tiff_with(ink, 2, 2, &icc)); + let s = json!({"output": {"profiles": "all"}}); + let (w, bytes) = export(&d, s.clone()); + tagged(&w, &s); + assert!(w.smask, "{w:?}"); + assert!(!String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "no luminosity soft mask"); +} + +/// A CMYK JPEG kept as it is whose header declares far more pixels than the app reads is left out +/// with a warning, as other images that large are. +#[test] +fn a_kept_cmyk_jpeg_declaring_too_many_pixels_is_left_out() { + let mut jpg = cmyk_jpeg_with(&flat([0, 102, 255, 0], 4), 2, 2, &cmyk_profile(|_| {})); + // Height and width of the SOF0 segment (after its marker, length and precision): 16384 × 16384. + let sof = jpg.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap(); + jpg[sof + 5..sof + 9].copy_from_slice(&[0x40, 0, 0x40, 0]); + assert_eq!(ImageBlob::new("image/jpeg", jpg.clone()).header_size(), Some((16384, 16384))); + let d = image_doc(ImageBlob::new("image/jpeg", jpg), 2, 2); + for s in [ + json!({"compression": {"color": {"downsample": "none"}}}), + json!({"output": {"profiles": "all"}, "compression": {"color": {"downsample": "none"}}}), + ] { + let out = export_with_report(&d, &opts(s.clone())).unwrap(); + assert!(written(&out.bytes).is_empty(), "{s}"); + assert!(out.warnings.iter().any(|w| w.contains("couldn't be decoded")), "{s}: {:?}", out.warnings); + } +} + +/// The profile each transparency group of written PDF `bytes` blends in: its ICC-based space's +/// profile, or `None` for DeviceCMYK. +fn group_profiles(bytes: &[u8]) -> Vec>> { + let text = String::from_utf8_lossy(bytes); + let file = Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let groups: Vec<&str> = text.split("/Group<<").skip(1).filter(|g| g.contains("/S/Transparency")).collect(); + assert!(!groups.is_empty(), "{text}"); + groups + .into_iter() + .map(|group| { + let cs = group.split("/CS").nth(1).unwrap(); + if cs.starts_with("/DeviceCMYK") { + return None; + } + let cs: i32 = cs.trim_start().split(' ').next().unwrap().parse().unwrap(); + let space = text.split(&format!("\n{cs} 0 obj\n")).nth(1).unwrap(); + let stream: i32 = space.strip_prefix("[/ICCBased ").unwrap().split(' ').next().unwrap().parse().unwrap(); + let profile = file.xref().get::>(craft_pdf::syntax::object::ObjectIdentifier::new(stream, 0)).unwrap(); + Some(profile.decoded().unwrap().into_owned()) + }) + .collect() +} + +/// Transparency in a tagged CMYK document blends in the document's CMYK profile space, not in the +/// space of the profile a CMYK image carries, whichever the file holds first. +#[test] +fn tagged_cmyk_documents_blend_in_their_own_profile_beside_an_image_profile() { + let icc = cmyk_profile(|_| {}); + let document = cms::icc_bytes(&cms::active_settings().cmyk).unwrap().to_vec(); + // A transparent CMYK rectangle over the image, and the image itself transparent. + let mut d = image_doc(cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc), 4, 4); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), + Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.opacity = 0.5; + let l = d.layers[0].id; + d.insert(Some(l), 1, n).unwrap(); + let mut alone = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + alone.images.insert("img".into(), cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc)); + let image = + ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default(), pixels: None }; + let mut n = Node::new(alone.alloc_id(), NodeKind::Image(Box::new(image))); + n.opacity = 0.5; + let l = alone.layers[0].id; + alone.insert(Some(l), 0, n).unwrap(); + for d in [d, alone] { + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "all"}}))).unwrap(); + let w = written(&out.bytes); + assert!(w.iter().any(|w| w.icc.as_deref() == Some(icc.as_slice())), "the image's own profile: {w:?}"); + for profile in group_profiles(&out.bytes) { + assert!(profile.as_ref().is_none_or(|p| *p == document), "the group blends in the document's CMYK profile or DeviceCMYK"); + } + } +} + +/// Only a well-formed CMYK profile tags an image: one whose size, signature, class, colour space +/// or tag table is wrong leaves it untagged. +#[test] +fn a_cmyk_image_with_a_malformed_profile_is_written_untagged() { + let tag = |icc: Vec| crate::images::cmyk_icc(&ImageBlob::new("image/jpeg", cmyk_jpeg_with(&flat([0, 102, 255, 0], 4), 2, 2, &icc))); + let good = cmyk_profile(|_| {}); + assert_eq!(tag(good.clone()), Some(good)); + let bad: [(&str, Vec); 7] = [ + ("declared size", cmyk_profile(|i| i[0..4].copy_from_slice(&200u32.to_be_bytes()))), + ("signature", cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx"))), + ("device link class", cmyk_profile(|i| i[12..16].copy_from_slice(b"link"))), + ("RGB colour space", cmyk_profile(|i| i[16..20].copy_from_slice(b"RGB "))), + ("tag beyond the end", cmyk_profile(|i| i[136..140].copy_from_slice(&150u32.to_be_bytes()))), + ("table beyond the end", cmyk_profile(|i| i[128..132].copy_from_slice(&3u32.to_be_bytes()))), + ("header only", cmyk_profile(|i| i.truncate(100))), + ]; + for (what, icc) in bad { + assert_eq!(tag(icc), None, "{what}"); + } + // And the image is written untagged. + let icc = cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx")); + let d = image_doc(cmyk_tiff_with([0, 102, 255, 0], 4, 4, &icc), 4, 4); + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "taggedSource"}}))).unwrap(); + let w = written(&out.bytes); + assert_eq!((w.len(), w[0].space.as_str()), (1, "DeviceCMYK"), "{w:?}"); +} + /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { diff --git a/docs/mcp.md b/docs/mcp.md index d64aa59c3d..9199105658 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5467,7 +5467,9 @@ too and printer's marks are not. The destination is any profile `edit.colorSetti 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 -always tagged, whatever `profiles` says). `outputIntent` embeds a profile as the file's +always tagged, whatever `profiles` says). A CMYK image that keeps its numbers carries the CMYK profile it embeds +with `all` and `taggedSource`, and in PDF/A, PDF/X-3 and PDF/X-4 unless `profiles` is `destination`; PDF/X-1a +images are always untagged CMYK. `outputIntent` embeds a profile as the file's `/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped` (PDF/A files keep their own output intent). Type is written as selectable, searchable text in embedded subset fonts with a ToUnicode map (fonts whose licence forbids embedding stay outlines, with a warning); `advanced: {outlineText: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b7efef4e91..06e5e3072a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1810,6 +1810,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Revert also drops what was started for the content it discards: a file being read for Place, for the place cursor or dropped on the canvas, a bitmap being read for Paste, and a paste waiting at its Swatch Conflict dialog are not placed or pasted in the reverted document, and the status bar says the document was reverted. Edits made while a file is read, a Revert that fails or that a batch rolls back, and a Revert of another document leave the read to finish as before (`crates/ui-egui/src/placing/tests.rs`). - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. + - 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`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`).