From 92a801a04fea6a060711b01e4b831990134e2173 Mon Sep 17 00:00:00 2001 From: japbcoelho Date: Fri, 16 Oct 2026 16:51:08 -0300 Subject: [PATCH] PDF: colours are written in the profiles assigned to the document A document assigned a CMYK profile (Edit > Assign Profile) keeps it when it is saved, but the working spaces are the app's, and a new session starts with the defaults. Opened again, such a document exported its CMYK colours tagged with the working CMYK space (VectorCraft Generic CMYK (SWOP-like)) instead of its own profile, made that profile the PDF/X output intent, and separated its RGB colours through it; print separations did the same. edit.assignProfile still reported the assigned profile, and document.exportPdf's documentation says CMYK colours are tagged with "the document's CMYK profile". Found on a book cover assigned FOGRA39L Coated: built in several vectorcraft-cli runs, each reopening the saved document, its PDF/X-4 came out tagged with the SWOP-like profile unless every run set FOGRA39 as the working CMYK space again. Export and print now work in the document's colour spaces: the active settings with the assigned profiles in their place (document_cms). An assigned profile that isn't available leaves the working space, with a warning. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/export.rs | 4 +-- crates/pdf/src/output.rs | 46 +++++++++++++++++++++++++++++++--- crates/pdf/src/print/plates.rs | 19 +++++++------- crates/pdf/src/tests_output.rs | 30 ++++++++++++++++++++++ crates/pdf/src/tests_print.rs | 21 ++++++++++++++++ 5 files changed, 106 insertions(+), 14 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 10a39e75d..6838a65c4 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -242,7 +242,7 @@ impl Writer { /// CMYK (unless the colours are converted to RGB). pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option, cmyk: bool) -> Result { let out = ColorOut::new(doc, set)?; - let mut warnings = vec![]; + let mut warnings = out.profile_notes.clone(); // Tagged CMYK colours need the profile (PDF/A tags every colour). let cmyk_profile = if out.tagged { out.cmyk_icc() } else { None }; if out.tagged && cmyk_profile.is_none() { @@ -747,7 +747,7 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { impl Exporter<'_> { /// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without /// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the - /// active colour settings, so the file carries press values. + /// document's colour spaces, 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) { diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 30a32c5e3..710bbe82f 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -1,6 +1,9 @@ //! Save PDF › Output: colours converted to a destination profile, the ICC profiles that tag them, //! and the output intent and Trapped entries. //! +//! A document's colours are in its own colour spaces ([`document_cms`]): the profiles assigned to +//! it (Edit › Assign Profile), which it keeps when saved and opened again, else the working spaces. +//! //! - Conversion ([`ColorConversion`]): `destination` converts every colour to the destination //! profile's model (CMYK or RGB) with the colour settings' intent, also colours already in that //! model when their profile differs from the destination; `preserveNumbers` converts only the @@ -49,8 +52,10 @@ struct Dest { /// How an export writes colours (see the module documentation). pub(crate) struct ColorOut { - /// The colour settings colours come from. + /// The colour settings colours come from: the document's colour spaces ([`document_cms`]). source: Arc, + /// Why colours aren't in a profile assigned to the document (warnings). + pub profile_notes: Vec, dest: Option, /// Colours are written in ICC-based colour spaces. pub tagged: bool, @@ -97,6 +102,7 @@ impl Default for ColorOut { let intent = source.settings().intent; Self { source, + profile_notes: vec![], dest: None, tagged: false, cmyk_profile, @@ -110,6 +116,38 @@ impl Default for ColorOut { } } +/// The colour settings `doc`'s colours are in: the active ones, with the profiles assigned to the +/// document (Edit › Assign Profile) as its RGB and CMYK spaces. The document keeps them when it +/// is saved and opened again, while the working spaces are the app's (a new session starts with +/// the defaults), so the two can differ. An assigned profile that isn't available leaves its +/// working space, with a warning in the second value. +pub(crate) fn document_cms(doc: &Document) -> (Arc, Vec) { + let active = cms::active(); + let mut settings = active.settings().clone(); + let mut notes = vec![]; + let assigned = [(&doc.color_profiles.rgb, ProfileKind::Rgb, "RGB"), (&doc.color_profiles.cmyk, ProfileKind::Cmyk, "CMYK")]; + for (name, kind, label) in assigned { + let Some(name) = name else { continue }; + let space = if kind == ProfileKind::Rgb { &mut settings.rgb } else { &mut settings.cmyk }; + match cms::profile(name).filter(|p| p.kind == kind) { + Some(p) => *space = p.name, + None => notes.push(format!( + "the document's {label} profile “{name}” isn't available: its {label} colours are written in the working {label} space, {space}" + )), + } + } + if settings == *active.settings() { + return (active, notes); + } + match Cms::new(&settings) { + Ok(own) => (Arc::new(own), notes), + Err(e) => { + notes.push(format!("the document's colour profiles can't be used ({e}): its colours are written in the working spaces")); + (active, notes) + } + } +} + /// The profile of `set`'s destination and its colour model; `None` without conversion. A blank /// destination is the profile of a document in colour mode `mode`. PDF/X-1a files always convert, /// to a CMYK destination: blank, their output intent's profile ([`crate::pdfx::cmyk_destination`]). @@ -172,9 +210,11 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec { } impl ColorOut { - /// How `set` writes the colours of `doc` (the colour settings are the source). + /// How `set` writes the colours of `doc` (its colour spaces, [`document_cms`], are the source). pub(crate) fn new(doc: &Document, set: &PdfSettings) -> Result { - let mut out = Self::default(); + let (source, profile_notes) = document_cms(doc); + let cmyk_profile = source.settings().cmyk.clone(); + let mut out = Self { source, profile_notes, cmyk_profile, ..Self::default() }; let source = out.source.clone(); let st = source.settings(); if let Some((name, model)) = destination(set, doc.color_mode, &source)? { diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index bb63f75bd..e7a9cc8ce 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -42,16 +42,17 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec, Vec Arc { - let active = cms::active(); +/// The colour settings separations of `doc` convert with: the document's colour spaces +/// ([`crate::output::document_cms`]), with the printer profile as their CMYK profile when it is a +/// CMYK one. +fn separation_cms(doc: &Document, set: &PrintSettings) -> Arc { + let (own, _) = crate::output::document_cms(doc); match cms::profile(set.color.profile.trim()) { - Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&active.settings().cmyk) != p.name => { - let settings = cms::ColorSettings { cmyk: p.name, ..active.settings().clone() }; - Cms::new(&settings).map_or(active, Arc::new) + Some(p) if p.kind == ProfileKind::Cmyk && cms::canonical_name(&own.settings().cmyk) != p.name => { + let settings = cms::ColorSettings { cmyk: p.name, ..own.settings().clone() }; + Cms::new(&settings).map_or(own, Arc::new) } - _ => active, + _ => own, } } @@ -64,7 +65,7 @@ pub(crate) struct Separator<'a> { impl<'a> Separator<'a> { pub fn new(doc: &'a Document, set: &'a PrintSettings) -> Self { - Self { doc, cms: separation_cms(set), set } + Self { doc, cms: separation_cms(doc, set), set } } /// How much of ink `plate` colour `c` (with swatch link `link`) prints, 0..1. diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 009bd3d78..95b8439f2 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -359,3 +359,33 @@ fn a_profile_that_tags_colours_and_is_the_output_intent_is_embedded_once() { assert_eq!(oi.get::>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*cms::icc_bytes(DEVICE_CMYK).unwrap()) }); } + +/// The profiles assigned to a document (Edit › Assign Profile) are its colour spaces whatever the +/// working spaces of the session that exports it: a document opened again in a new session, +/// which starts with the default working spaces, keeps its profiles. Its CMYK colours are tagged +/// with its CMYK profile, which is also the PDF/X output intent, and its RGB colours separate +/// through it. +#[test] +fn colours_are_in_the_profiles_assigned_to_the_document() { + let working = cms::active_settings().cmyk; + assert_ne!(working, DEVICE_CMYK, "the document is assigned another profile than the working one"); + let intent = cms::active_settings().intent; + let grey = Color::rgb(0.5, 0.5, 0.5); + let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(0.1, 0.2, 0.3, 0.4), grey]); + d.color_profiles.cmyk = Some(DEVICE_CMYK.into()); + let r = standard_pdf(&d, json!({"standard": "pdfX4"})); + assert!(!r.warnings.iter().any(|w| w.contains("profile")), "{:?}", r.warnings); + let own = cms::icc_bytes(DEVICE_CMYK).unwrap(); + assert!(icc_streams(&r.bytes) == [own.to_vec()], "tagged with the document's CMYK profile, not the working one"); + output_intent(&r.bytes, None, |oi| assert_eq!(oi.get::>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*own)); + let c = colours(&r.bytes); + assert!(c[0].0 == "scn" && close(&c[0].1, &q8(&[0.1, 0.2, 0.3, 0.4])), "CMYK keeps its numbers: {c:?}"); + let separated = q8(&to_cmyk(DEVICE_CMYK).to_cmyk(&grey, intent)); + assert!(!close(&separated, &q8(&cms::active().to_cmyk(&grey, intent))), "the two profiles separate grey differently"); + assert!(c[1].0 == "scn" && close(&c[1].1, &separated), "RGB separated through the document's profile: {c:?} vs {separated:?}"); + // A profile that isn't available leaves the working space, with a warning. + d.color_profiles.cmyk = Some("No Such Press".into()); + let r = standard_pdf(&d, json!({"standard": "pdfX4"})); + assert!(r.warnings.iter().any(|w| w.contains("No Such Press")), "{:?}", r.warnings); + assert!(icc_streams(&r.bytes) == [cms::icc_bytes(&working).unwrap().to_vec()]); +} diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests_print.rs index d1bbeacc5..5c57fa42b 100644 --- a/crates/pdf/src/tests_print.rs +++ b/crates/pdf/src/tests_print.rs @@ -396,3 +396,24 @@ fn golden_page_one() { assert!(c.iter().zip(want).all(|(a, b)| a.abs_diff(b) < 40), "({px}, {py}): {c:?} vs {want:?}"); } } + +/// Separations go through the document's colour spaces: the CMYK profile assigned to it, not the +/// working one (a document opened again in a new session keeps its profile, while the working +/// spaces go back to the defaults). +#[test] +fn separations_use_the_cmyk_profile_assigned_to_the_document() { + let mut d = doc(100.0, 100.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), rgb(0.5, 0.5, 0.5))]); + d.color_mode = vectorcraft_doc::ColorMode::Cmyk; + // The cyan of the grey square, in percent. + let cyan = |d: &Document| -> f32 { + let p = pages(&job(d, json!({"output": {"mode": "separations"}})).bytes); + match p[0].1.iter().find(|(b, _)| (b.width() - 50.0).abs() < 1.0).and_then(|(_, p)| p.color()) { + Some(Color::Gray { k }) => (k * 100.0).round(), + c => panic!("{c:?}"), + } + }; + let working = cyan(&d); + assert!(working > 0.0, "the working CMYK profile separates grey into four inks: cyan {working}"); + d.color_profiles.cmyk = Some(vectorcraft_color::cms::DEVICE_CMYK.into()); + assert_eq!(cyan(&d), 0.0, "Device CMYK puts a neutral grey on the black plate only"); +}