M3.85: spot colours defined in Lab, with Spot Colors options for Lab values or CMYK equivalents

Colours gain a CIE Lab model (`Color::Lab {l,a,b}`, serde tag `lab`), handled through the
CMS (display, separation, conversions, gamut), tints (toward paper white), keep_model, harmonies,
recolouring, blends, Info and the Color panel. Swatch Options gets a Lab mode
(`swatch.edit {mode: "lab"}` / `{color: {l,a,b}}`), and colour params accept `{l,a,b}`.

Swatches panel → Spot Colors… (`ui.spotColors`, dialog `spotColors`) runs `swatch.spotOptions
{useLab}` (Document.spot_use_lab, default on): Lab spot colours show and separate from their Lab
values, or art linked to them takes their working-CMYK equivalents, as one undo step. PDF export
writes Lab spot inks as Separation spaces with a Lab alternate (tint function from paper white),
or a DeviceCMYK one with CMYK equivalents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 03:46:57 +07:00
co-authored by Claude Opus 5.5
parent c1df6f0621
commit ea61d34a93
30 changed files with 855 additions and 92 deletions
+5 -3
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Document Color Mode",
["File", "Document Color Mode"],
None,
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept) through the colour settings → {changed}",
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}",
has_doc,
convert_mode
),
@@ -245,8 +245,9 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
let c = cms::active();
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
// Greys stay greys (they print on the black plate in either mode).
let conv = |col: &Color| if matches!(col, Color::Gray { .. }) { *col } else { c.convert(col, model, intent) };
// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
// definitions) are device independent and fit either mode.
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
let mut changed = 0usize;
s.edit("Document Color Mode", |d, _| {
d.color_mode = mode;
@@ -287,6 +288,7 @@ fn values(c: &Color) -> Vec<f32> {
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Gray { k } => vec![k],
Color::Lab { l, a, b } => vec![l, a, b],
}
}
+5 -1
View File
@@ -253,7 +253,8 @@ pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
}
Some(Affine::new(c))
}
/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`.
/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}`, `{"gray":..}` or
/// `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about −128..127).
pub fn color_value(v: &Value) -> Option<Color> {
match v {
Value::String(s) => Color::from_hex(s),
@@ -267,6 +268,9 @@ pub fn color_value(v: &Value) -> Option<Color> {
let g = g.as_f64()?;
return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32));
}
if let (Some(l), Some(a), Some(b), None) = (o.get("l"), o.get("a"), o.get("b"), o.get("model")) {
return Some(Color::lab(l.as_f64()? as f32, a.as_f64()? as f32, b.as_f64()? as f32));
}
serde_json::from_value(v.clone()).ok()
}
_ => None,
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
has_doc,
|s, p| set_paint(s, p, true)
),
+52 -6
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Swatch",
["Window", "Swatches"],
None,
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\") → {name, names: [every new swatch]}",
"{name?, color? | colors?: [colour] (one swatch each, in one undo step) | swatch? | gradient? | pattern?: name (default: the current fill), tint?: 0..100 (% of the global or spot `swatch`, or of the one the current fill links to), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour; lab: CIE L*a*b*, how spot colours are usually defined), global?, spot? (a spot colour, always global), group?: colour group name (solid colours only; default: ungrouped)} save a colour, gradient or pattern as a swatch. A tint of a global or spot colour (below 100%, without mode, global or spot) becomes a tint swatch, \"Name 40%\", linked to its base: it follows edits to the base and applies as that tint of it. Names are unique (\"Sky 2\"); a colour's default name is its values (\"C=10 M=20 Y=30 K=0\", \"R=255 G=128 B=0\", \"Gray K=40\", \"L=55 a=60 b=40\") → {name, names: [every new swatch]}",
has_doc,
swatch_new
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color?, mode?: \"gray\"|\"rgb\"|\"hsb\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour, gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
@@ -113,6 +113,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
swatch_sort_by_kind
),
cmd!(
"swatch.spotOptions",
"Spot Colors",
["Window", "Swatches"],
None,
"{useLab?: bool (omitted: unchanged)} the document's Spot Colors options: spot colours defined in Lab show, print and export from their Lab values (true, the default: PDF Separation spaces get a Lab alternate) or from their working-CMYK equivalents (false: a DeviceCMYK alternate, matching older files). Art, gradient stops and tint swatches linked to Lab spot swatches take the chosen colour at their tint, as one undo step → {useLab, relinked: paints changed}",
has_doc,
swatch_spot_options
),
]
}
@@ -420,12 +429,13 @@ fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result<Color> {
"gray" | "grayscale" => to(Model::Gray),
"rgb" | "hsb" => to(Model::Rgb),
"cmyk" => to(Model::Cmyk),
"lab" => to(Model::Lab),
"web" => {
let snap = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0;
let [r, g, b] = to(Model::Rgb).to_rgb_uncalibrated();
Color::rgb(snap(r), snap(g), snap(b))
}
other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, cmyk or web)"))),
other => return Err(bad(cmd, format!("unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web)"))),
})
}
@@ -434,7 +444,8 @@ struct Relink {
from: String,
/// The swatch's name after the edit.
to: String,
/// The new colour of a global swatch (linked paints take it, at their tint).
/// The colour paints linked to the global swatch now show ([`Document::linked_color`]); they
/// take it at their tint.
color: Option<Color>,
/// false: drop the links (the swatch stopped being global); paints keep their colour.
keep: bool,
@@ -518,7 +529,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
let spot = bool_or(p, "spot", sw.spot);
let global = spot || bool_or(p, "global", sw.global);
let to = name_param(p, "newName").filter(|n| *n != name).map_or_else(|| name.clone(), |n| d.free_swatch_name(&n));
let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global), keep: global };
let relink = Relink { from: name.clone(), to: to.clone(), color: color.filter(|_| global).map(|c| d.linked_color(c, spot)), keep: global };
let relinked = s.edit("Swatch Options", |d, _| {
let w = d.swatch_mut(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?;
w.name = to.clone();
@@ -673,7 +684,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "give two or more swatches"));
}
let kept = d.swatch(keep).ok_or_else(|| bad(C, format!("no swatch `{keep}`")))?;
let (color, link) = (kept.paint.color().unwrap_or_default(), kept.global.then(|| keep.clone()));
let (color, link) = (d.linked_color(kept.paint.color().unwrap_or_default(), kept.spot), kept.global.then(|| keep.clone()));
// Tints keep their tint (of the kept colour).
let mut relink = |c: &mut Color, l: &mut Option<String>, t: &mut f32| {
if !l.as_ref().is_some_and(|l| merged.contains(l)) {
@@ -724,3 +735,38 @@ fn swatch_sort_by_kind(s: &mut Session, _: &Value) -> Result<Value> {
})?;
ok()
}
fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.spotOptions";
let was = s.doc()?.doc.spot_use_lab;
let use_lab = match p.get("useLab").filter(|v| !v.is_null()) {
Some(v) => v.as_bool().ok_or_else(|| bad(C, "`useLab` must be true or false"))?,
None => was,
};
if use_lab == was {
return Ok(json!({"useLab": use_lab, "relinked": 0}));
}
let (relinked, bases) = s.edit("Spot Colors", |d, _| {
d.spot_use_lab = use_lab;
// Each Lab spot swatch with the colour its linked paints now show.
let bases: Vec<(String, Color)> = d
.swatches_iter()
.filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. }))
.filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?)))
.collect();
let relinked = d.map_solid_paints(&mut follow_bases(&bases));
Ok((relinked, bases))
})?;
map_default_paints(s, &mut follow_bases(&bases));
Ok(json!({"useLab": use_lab, "relinked": relinked}))
}
/// Recolour the colours linked to the swatches of `bases` (name, colour) to that colour at their
/// tint; true when a colour changed.
fn follow_bases(bases: &[(String, Color)]) -> impl FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + '_ {
|c, link, tint| {
let Some((_, base)) = bases.iter().find(|(n, _)| link.as_ref() == Some(n)) else { return false };
let new = base.tinted(*tint);
std::mem::replace(c, new) != new
}
}
+2
View File
@@ -843,6 +843,8 @@ mod tests_gradpanel;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_labspots;
#[cfg(test)]
mod tests_layerclip;
#[cfg(test)]
mod tests_linked_stops;
+204
View File
@@ -0,0 +1,204 @@
//! Spot colours defined in Lab: Lab swatches through Swatch Options, the Spot Colors options
//! (Lab values or CMYK equivalents for display and separations), Lab alternates in PDF, and the
//! saved setting.
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Model};
use vectorcraft_color::{Color, Paint};
use vectorcraft_render::proof;
use super::tests_colormgmt::GLOBAL;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn doc(s: &Session) -> &vectorcraft_doc::Document {
&s.doc().unwrap().doc
}
fn fill(s: &Session, id: NodeId) -> Paint {
doc(s).node(id).unwrap().appearance.fill_paint()
}
const LAB: Color = Color::Lab { l: 55.0, a: 60.0, b: 40.0 };
fn linked(color: Color, tint: f32) -> Paint {
Paint::Solid { color, swatch: Some("Lab Ink".into()), tint }
}
/// The working-CMYK equivalent of a Lab colour, what CMYK spot options show.
fn cmyk_of(c: Color) -> Color {
let cms = cms::active();
cms.convert(&c, Model::Cmyk, cms.settings().intent)
}
/// A document with the Lab spot swatch "Lab Ink" and a rectangle filled with 40 % of it.
fn lab_ink() -> (Session, NodeId) {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Lab Ink", "color": {"l": 55, "a": 60, "b": 40}, "spot": true}));
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Lab Ink", "tint": 40}));
(s, id)
}
#[test]
fn swatch_options_define_spot_colours_in_lab() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true}));
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Ink"}));
// Lab mode converts the swatch's colour through the CMS; linked art follows.
run(&mut s, "swatch.edit", json!({"name": "Ink", "mode": "lab"}));
let lab = doc(&s).swatch("Ink").unwrap().paint.color().unwrap();
assert_eq!(lab.model(), Model::Lab);
let back = cmyk_of(lab);
let Color::Cmyk { c, m, y, k } = back else { panic!("{back:?}") };
assert!(c < 0.03 && (m - 0.8).abs() < 0.03 && (y - 0.6).abs() < 0.03 && k < 0.03, "Lab of C0 M80 Y60 K0 separates back: {back:?}");
assert_eq!(fill(&s, id), Paint::Solid { color: lab, swatch: Some("Ink".into()), tint: 1.0 });
// Lab values given directly; the swatch list reports the model.
let r = run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"l": 55, "a": 60, "b": 40}}));
assert_eq!(r["relinked"], 1);
assert_eq!(fill(&s, id).color(), Some(LAB));
let list = run(&mut s, "swatch.list", json!({}));
let ink = list["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink").unwrap();
assert_eq!((ink["color"]["model"].as_str(), ink["spot"].as_bool()), (Some("lab"), Some(true)));
assert!(s.execute("swatch.edit", &json!({"name": "Ink", "mode": "xyz"})).unwrap_err().to_string().contains("lab"));
}
#[test]
fn spot_colors_options_switch_lab_spots_between_lab_and_cmyk() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let (mut s, id) = lab_ink();
assert!(doc(&s).spot_use_lab, "Lab values by default");
assert_eq!(fill(&s, id), linked(LAB.tinted(0.4), 0.4));
// A gradient stop and a tint swatch of the ink follow too; a process swatch doesn't.
run(&mut s, "swatch.new", json!({"swatch": "Lab Ink", "tint": 70}));
let g = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(
&mut s,
"paint.setFill",
json!({"ids": [g.0], "gradient": {"stops": [{"offset": 0, "swatch": "Lab Ink"}, {"offset": 1, "color": "#ffffff"}]}}),
);
assert_eq!(run(&mut s, "swatch.spotOptions", json!({})), json!({"useLab": true, "relinked": 0}), "no change: a query");
let r = run(&mut s, "swatch.spotOptions", json!({"useLab": false}));
assert_eq!(r, json!({"useLab": false, "relinked": 3}));
let cmyk = cmyk_of(LAB);
assert!(matches!(cmyk, Color::Cmyk { .. }));
assert_eq!(fill(&s, id), linked(cmyk.tinted(0.4), 0.4), "the CMYK equivalent at the same tint");
assert_eq!(doc(&s).swatch("Lab Ink 70%").unwrap().paint, linked(cmyk.tinted(0.7), 0.7));
let Paint::Gradient(gp) = fill(&s, g) else { panic!("gradient") };
assert_eq!((gp.gradient.stops[0].color, gp.gradient.stops[0].swatch.as_deref()), (cmyk, Some("Lab Ink")));
assert_eq!(doc(&s).swatch("Lab Ink").unwrap().paint.color(), Some(LAB), "the definition stays Lab");
assert_eq!(doc(&s).global_color("Lab Ink"), Some(cmyk));
// New art takes the CMYK equivalent; the Separations plate shows it.
run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Lab Ink", "tint": 50}));
assert_eq!(fill(&s, id), linked(cmyk.tinted(0.5), 0.5));
let plate = |s: &Session| proof::plates(doc(s)).into_iter().find(|p| p.name == "Lab Ink").unwrap().rgb;
assert_eq!(plate(&s), cmyk.to_rgb());
// One undo step back to Lab values.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert!(doc(&s).spot_use_lab);
assert_eq!(fill(&s, id), linked(LAB.tinted(0.4), 0.4));
assert_eq!(plate(&s), LAB.to_rgb());
assert!(s.execute("swatch.spotOptions", &json!({"useLab": "yes"})).is_err());
}
#[test]
fn colour_values_parse_lab() {
let mut s = session();
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": {"l": 50, "a": -20, "b": 30.5}}));
assert_eq!(fill(&s, id).color(), Some(Color::lab(50.0, -20.0, 30.5)));
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": {"model": "lab", "l": 20, "a": 1, "b": 2}}));
assert_eq!(fill(&s, id).color(), Some(Color::lab(20.0, 1.0, 2.0)));
// Lab colours keep their model through colour operations.
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "paint.complement", json!({}));
assert_eq!(fill(&s, id).color(), Some(Color::lab(20.0, -1.0, -2.0))); // A Lab swatch is named by its values.
let r = run(&mut s, "swatch.new", json!({"color": {"l": 50.4, "a": -20, "b": -0.2}}));
assert_eq!(r["name"], "L=50 a=-20 b=0");
}
#[test]
fn spot_options_and_lab_colours_round_trip() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let (mut s, id) = lab_ink();
let saved = vectorcraft_format::save(doc(&s), false);
assert!(!String::from_utf8_lossy(&saved).contains("spot_use_lab"), "the default isn't written");
run(&mut s, "swatch.spotOptions", json!({"useLab": false}));
let d = doc(&s).clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
assert!(!back.spot_use_lab);
assert_eq!(back.swatch("Lab Ink").unwrap().paint.color(), Some(LAB));
assert_eq!(back.node(id).unwrap().appearance.fill_paint(), d.node(id).unwrap().appearance.fill_paint());
// Files from before the option use Lab values.
let mut v: Value = serde_json::to_value(&d).unwrap();
v.as_object_mut().unwrap().remove("spot_use_lab");
assert!(serde_json::from_value::<vectorcraft_doc::Document>(v).unwrap().spot_use_lab);
}
fn pdf_bytes(s: &Session) -> Vec<u8> {
let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() };
vectorcraft_pdf::export(doc(s), &opts).unwrap()
}
/// Every in-use cross-reference entry points at its object, and `startxref` at the table.
fn assert_xref_valid(pdf: &[u8]) {
let sx = pdf.windows(9).rposition(|w| w == b"startxref").unwrap();
let xref: usize = std::str::from_utf8(&pdf[sx + 9..]).unwrap().split_whitespace().next().unwrap().parse().unwrap();
assert!(pdf[xref..].starts_with(b"xref\n0 "), "startxref → the table");
// The table and trailer are ASCII.
let mut lines = std::str::from_utf8(&pdf[xref..]).unwrap().lines().skip(1);
let count: usize = lines.next().unwrap().split_whitespace().nth(1).unwrap().parse().unwrap();
for (id, e) in lines.take(count).enumerate() {
if e.trim_end().ends_with('n') {
let off: usize = e[..10].parse().unwrap();
assert!(pdf[off..].starts_with(format!("{id} 0 obj").as_bytes()), "object {id} at {off}");
}
}
}
#[test]
fn lab_spots_export_with_a_lab_alternate() {
let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let (mut s, _) = lab_ink();
// A tint gradient of the ink is a Separation shading of the same space.
let g = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(
&mut s,
"paint.setFill",
json!({"ids": [g.0], "gradient": {"stops": [{"offset": 0, "swatch": "Lab Ink"}, {"offset": 1, "color": "#ffffff"}]}}),
);
let pdf = pdf_bytes(&s);
let text = String::from_utf8_lossy(&pdf);
assert!(
text.contains("[/Separation/Lab#20Ink[/Lab<</WhitePoint[0.9642 1 0.8252]/Range[-128 127 -128 127]>>]<</FunctionType 2/Domain[0 1]/Range[0 100 -128 127 -128 127]/C0[100 0 0]/C1[55 60 40]/N 1>>]"),
"a Lab alternate running from paper white to the Lab values"
);
assert!(!text.contains("/Separation/Lab#20Ink/DeviceCMYK"));
assert!(text.contains("/ShadingType 2"), "the gradient is still a shading");
assert_xref_valid(&pdf);
// The file reads back: the 40 % tint shows as that tint of the Lab colour.
let back = vectorcraft_pdf::import(&pdf).unwrap();
let mut fills = vec![];
back.visit_paints(&mut |p| fills.extend(p.color()));
let want = LAB.tinted(0.4).to_rgba8(1.0);
assert!(fills.iter().any(|c| c.to_rgba8(1.0).iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 3)), "{fills:?} has {want:?}");
// CMYK values: a DeviceCMYK alternate, as for process-defined spots.
run(&mut s, "swatch.spotOptions", json!({"useLab": false}));
let pdf = pdf_bytes(&s);
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/Separation/Lab#20Ink/DeviceCMYK") && !text.contains("/Lab<<"));
assert_xref_valid(&pdf);
}
+2 -2
View File
@@ -195,7 +195,7 @@ fn grouped_swatches_edit_and_convert_modes() {
assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "color": "#000000"})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "Sunset", "spot": true})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "[None]", "newName": "x"})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "lab"})).is_err());
assert!(s.execute("swatch.edit", &json!({"name": "Bright Red", "mode": "xyz"})).is_err());
assert_eq!(run(&mut s, "swatch.edit", json!({"name": "Sunset", "newName": "Dusk"}))["name"], "Dusk");
}
@@ -264,7 +264,7 @@ fn new_swatches_go_into_groups_convert_modes_and_can_be_spot() {
assert_eq!(d.swatch_group_of("Ink").map(|g| d.swatch_groups[g].name.as_str()), Some("Brights"));
assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "group": "Nope"})).is_err());
assert!(s.execute("swatch.new", &json!({"swatch": "Sunset", "group": "Brights"})).is_err(), "groups hold solid colours only");
assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "lab"})).is_err());
assert!(s.execute("swatch.new", &json!({"color": "#00ff00", "mode": "xyz"})).is_err());
}
/// Two rectangles: `a` filled red and stroked green, `b` filled red and stroked with global swatch