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 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-17 13:49:48 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 1eb9bea82a
commit 6920adf092
3 changed files with 89 additions and 3 deletions
+61 -3
View File
@@ -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<String>,
}
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<String> {
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<String>,
/// 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<ColorOut>,
/// 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())
+27
View File
@@ -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<String> {
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);
}
+1
View File
@@ -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 (<https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html>). 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) |