mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c1df6f0621
commit
ea61d34a93
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user