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 <noreply@anthropic.com>
This commit is contained in:
japbcoelho
2026-10-17 15:14:42 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 53100a458f
commit 92a801a04f
5 changed files with 106 additions and 14 deletions
+2 -2
View File
@@ -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<i64>, cmyk: bool) -> Result<Self, PdfError> {
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) {
+43 -3
View File
@@ -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<Cms>,
/// Why colours aren't in a profile assigned to the document (warnings).
pub profile_notes: Vec<String>,
dest: Option<Dest>,
/// 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<Cms>, Vec<String>) {
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<String> {
}
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<Self, PdfError> {
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)? {
+10 -9
View File
@@ -42,16 +42,17 @@ pub fn print_inks(doc: &Document, set: &PrintSettings) -> (Vec<PrintInk>, Vec<St
(list, warnings)
}
/// The colour settings separations convert with: the active ones, with the printer profile as
/// their CMYK profile when it is a CMYK one.
fn separation_cms(set: &PrintSettings) -> Arc<Cms> {
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<Cms> {
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.
+30
View File
@@ -359,3 +359,33 @@ fn a_profile_that_tags_colours_and_is_the_output_intent_is_embedded_once() {
assert_eq!(oi.get::<Stream<'_>>(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::<Stream<'_>>(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()]);
}
+21
View File
@@ -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");
}