From 6920adf0924de4eafb4c3031e4495cb06b3df339 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 21:30:28 +0200 Subject: [PATCH] PDF export: report RGB colours and images in a CMYK document (#2162) A CMYK document holding RGB colours or images separates them into CMYK on export, and dark ones come out as rich blacks. The export report now says how many there are. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 64 +++++++++++++++++++++++++++++-- crates/pdf/src/tests_cmykblend.rs | 27 +++++++++++++ docs/gaps.md | 1 + 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d1901a7d4..1da3b3710 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1,7 +1,7 @@ //! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`. use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use craft_pdf::write::backend as krilla; @@ -147,6 +147,42 @@ fn user_unit(w: f64, h: f64) -> f64 { if side.is_finite() && side > MAX_PAGE_SIDE { (side / MAX_PAGE_SIDE * 1000.0).ceil() / 1000.0 } else { 1.0 } } +/// The RGB colours and non-CMYK images a CMYK document carries, which the export separates into +/// CMYK. Their darkest values come out as rich blacks, so the export says how many there are. +#[derive(Default)] +pub(crate) struct RgbInCmyk { + /// Distinct RGB colours, by the bits of their channels. + colours: HashSet<[u32; 3]>, + /// Keys of the raster images that aren't CMYK. + images: HashSet, +} + +impl RgbInCmyk { + fn merge(&mut self, other: RgbInCmyk) { + self.colours.extend(other.colours); + self.images.extend(other.images); + } + + /// The warning for the export report; `None` when the document has nothing of the sort. + fn warning(&self) -> Option { + let (c, i) = (self.colours.len(), self.images.len()); + if c == 0 && i == 0 { + return None; + } + let mut parts = vec![]; + if c > 0 { + parts.push(format!("{c} RGB colour{}", if c == 1 { "" } else { "s" })); + } + if i > 0 { + parts.push(format!("{i} RGB or grey image{}", if i == 1 { "" } else { "s" })); + } + Some(format!( + "this CMYK document has {}: they are converted to CMYK through the colour settings, so dark ones can come out as rich blacks on several plates; paint them with CMYK colours (Edit › Edit Colors › Convert to CMYK) for pure black ink", + parts.join(" and ") + )) + } +} + /// A PDF being written, shared by export and print: the file's configuration and metadata, then /// its pages ([`Self::page`]), each drawn by an [`Exporter`]. pub(crate) struct Writer { @@ -159,6 +195,8 @@ pub(crate) struct Writer { /// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]). lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, warnings: Vec, + /// RGB content of a CMYK document, gathered from the exporters ([`Self::absorb`]). + rgb_in_cmyk: RgbInCmyk, /// A PDF 1.3 file ([`crate::PdfSettings::pdf13`]): flat, without page groups. pdf13: bool, } @@ -234,7 +272,15 @@ impl Writer { created: created.map(date_time), }; let pdf = write::Writer::new(settings, metadata); - Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings, pdf13: set.pdf13() }) + Ok(Self { + pdf, + cmyk: out.blends_cmyk(cmyk), + out: Arc::new(out), + lab_spots: vec![], + warnings, + rgb_in_cmyk: RgbInCmyk::default(), + pdf13: set.pdf13(), + }) } /// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks. @@ -323,6 +369,7 @@ impl Writer { self.warnings.push(w); } } + self.rgb_in_cmyk.merge(ex.rgb_in_cmyk); for spot in ex.lab_spots { if !self.lab_spots.iter().any(|(n, _)| *n == spot.0) { self.lab_spots.push(spot); @@ -342,6 +389,7 @@ impl Writer { let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; let (bytes, more) = self.out.write_catalog(bytes)?; self.warnings.extend(more); + self.warnings.extend(self.rgb_in_cmyk.warning()); Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) } } @@ -480,6 +528,8 @@ pub(crate) struct Exporter<'a> { pub out: Arc, /// Colours are converted to the destination (printer's marks aren't). convert: bool, + /// RGB colours and images of a CMYK document drawn so far. + rgb_in_cmyk: RgbInCmyk, /// Text as glyph outlines; else the characters' fills are real text in embedded fonts. outline_text: bool, /// The fonts real text is written in, by face id, with their units per em; `None` for faces @@ -523,6 +573,7 @@ impl<'a> Exporter<'a> { transparent: None, out: Arc::default(), convert: true, + rgb_in_cmyk: RgbInCmyk::default(), outline_text: set.advanced.outline_text, fonts: HashMap::new(), layers: false, @@ -691,6 +742,9 @@ impl Exporter<'_> { /// active colour settings, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; + if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { + self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); + } let c = self.out.color(c, self.intent, cmyk_doc, self.convert); if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { let note = note.clone(); @@ -1641,7 +1695,11 @@ impl Exporter<'_> { let doc = self.doc; let out = self.out.clone(); let img = doc.images.get(key).and_then(|blob| { - let r = if blob.is_cmyk() { + let cmyk_image = blob.is_cmyk(); + if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { + 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()) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels()) diff --git a/crates/pdf/src/tests_cmykblend.rs b/crates/pdf/src/tests_cmykblend.rs index 5655dd0e6..dd3e8183e 100644 --- a/crates/pdf/src/tests_cmykblend.rs +++ b/crates/pdf/src/tests_cmykblend.rs @@ -103,3 +103,30 @@ fn a_cmyk_page_without_blending_through_is_an_isolated_group() { assert!(!t.contains("/Itrue") && t.contains("/S/Alpha"), "grouped {grouped}: {t}"); } } + +fn rgb_warning(d: &Document) -> Option { + export_with_report(d, &PdfOptions::uncompressed()).unwrap().warnings.into_iter().find(|w| w.contains("RGB")) +} + +#[test] +fn rgb_colours_in_a_cmyk_document_are_reported_and_separated() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(50.0, 0.0, 100.0, 50.0), Color::rgb(0.11, 0.1, 0.1))); + add(&mut d, rect(Rect::new(0.0, 50.0, 50.0, 100.0), Color::rgb(0.9, 0.2, 0.2))); + let w = rgb_warning(&d).expect("a warning"); + assert!(w.contains("2 RGB colours"), "{w}"); + // The colours are written as CMYK, not RGB. + let t = text(&d); + assert!(t.contains("k0") && !t.contains("rg\n"), "{t}"); +} + +#[test] +fn cmyk_colours_and_rgb_documents_have_no_rgb_warning() { + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!(rgb_warning(&d), None); + let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Rgb); + add(&mut d, rect(Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.1, 0.1, 0.1))); + assert_eq!(rgb_warning(&d), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 132c865f4..5d38a57e3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -335,6 +335,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | G11: PDF export of a CMYK document reports its RGB colours and non-CMYK images, with a count, in the export warnings (#2162); no entry closed, estimates unchanged | | 2026-10-16 | minor | EPS editing data: legacy mesh corners, vector opacity masks and compound member paint retained; raster page comparison filters minification without relaxing limits; scores and hours unchanged. Synthetic EPS/parser/minification regressions; user EPS/SVG/JPG kept local. | | 2026-10-16 | minor | Correction to G12: quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button in the Control bar or Properties opens the controls (). Wording only; no entry opened or closed, estimates unchanged. | | 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) |