vectorcraft/crates/engine/src/tests_tints.rs

183 lines
9.2 KiB
Rust

//! Tints of global and spot colours: applying a swatch at a tint, tints following swatch edits,
//! tint swatches, spot tints on their plate and in PDF, Adjust Color Balance's Global mode.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_render::proof;
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
}
/// A selected rectangle.
fn rect(s: &mut Session) -> NodeId {
let id = NodeId(run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap());
run(s, "select.set", json!({"ids": [id.0]}));
id
}
fn fill(s: &Session, id: NodeId) -> Paint {
doc(s).node(id).unwrap().appearance.fill_paint()
}
const MAGENTA: Color = Color::Cmyk { c: 0.0, m: 1.0, y: 0.0, k: 0.2 };
/// A document with a spot swatch "Ink" (magenta + 20 % black) and a global process "Brand".
fn inks() -> Session {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Ink", "color": MAGENTA, "spot": true}));
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#3366cc", "global": true}));
s
}
fn linked(color: Color, name: &str, tint: f32) -> Paint {
Paint::Solid { color, swatch: Some(name.into()), tint }
}
#[test]
fn a_swatch_applies_at_a_tint_and_the_tint_follows_swatch_edits() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 40}));
let brand = Color::from_hex("#3366cc").unwrap();
assert_eq!(fill(&s, a), linked(brand.tinted(0.4), "Brand", 0.4));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "swatch.edit", json!({"name": "Brand", "color": "#cc3300"}));
assert_eq!(r["relinked"], 1);
let red = Color::from_hex("#cc3300").unwrap();
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Brand", 0.4), "the 40% tint follows the edit");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
// Renaming keeps the tint; deleting unlinks it and the art keeps its colour.
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Logo"}));
assert_eq!(fill(&s, a), linked(red.tinted(0.4), "Logo", 0.4));
run(&mut s, "swatch.delete", json!({"name": "Logo"}));
assert_eq!(fill(&s, a), Paint::solid(red.tinted(0.4)), "unlinked: tint 100% of its own colour");
// The proxies report the tint; process colours have none.
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 25}));
assert_eq!(run(&mut s, "paint.proxies", json!({}))["fill"]["tint"], json!(0.25));
assert!(s.execute("paint.setFill", &json!({"swatch": "Black", "tint": 50})).unwrap_err().to_string().contains("process"));
assert!(s.execute("paint.setFill", &json!({"color": "#ff0000", "tint": 50})).is_err(), "a tint needs a swatch");
}
#[test]
fn new_swatch_saves_a_tint_swatch_linked_to_its_base() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
let r = run(&mut s, "swatch.new", json!({}));
assert_eq!(r["name"], "Ink 40%");
let w = doc(&s).swatch("Ink 40%").unwrap();
assert_eq!((w.tint_of(), w.global, w.spot), (Some(("Ink", 0.4)), false, false), "its kind is its base's");
let listed = run(&mut s, "swatch.list", json!({}));
let row = listed["swatches"].as_array().unwrap().iter().find(|w| w["name"] == "Ink 40%").unwrap().clone();
assert_eq!((row["tintOf"].clone(), row["tint"].clone()), (json!("Ink"), json!(40.0)));
// `tint` alone: that tint of the fill's swatch.
assert_eq!(run(&mut s, "swatch.new", json!({"tint": 70}))["name"], "Ink 70%");
assert_eq!(run(&mut s, "swatch.new", json!({"swatch": "Brand", "tint": 10}))["name"], "Brand 10%");
// Applying a tint swatch links to the base at that tint.
let b = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink 70%"}));
assert_eq!(fill(&s, b), linked(MAGENTA.tinted(0.7), "Ink", 0.7));
// Editing the base recolours the tint swatch and the art; the tint swatch only takes a name.
run(&mut s, "swatch.edit", json!({"name": "Ink", "color": {"c": 1, "m": 0, "y": 0, "k": 0}}));
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
assert_eq!(doc(&s).swatch("Ink 70%").unwrap().paint, linked(cyan.tinted(0.7), "Ink", 0.7));
assert_eq!(fill(&s, a), linked(cyan.tinted(0.4), "Ink", 0.4));
assert!(s.execute("swatch.edit", &json!({"name": "Ink 70%", "color": "#ff0000"})).is_err());
run(&mut s, "swatch.edit", json!({"name": "Ink 70%", "newName": "Pale Ink"}));
// A tint swatch is used where its tint of its base is.
let unused = run(&mut s, "swatch.unused", json!({}));
let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert!(!names.contains(&"Pale Ink") && !names.contains(&"Ink 40%") && names.contains(&"Brand 10%"), "{names:?}");
// A tint asked for in another mode, or as a global colour, is a process copy.
run(&mut s, "select.set", json!({"ids": [a.0]}));
let r = run(&mut s, "swatch.new", json!({"global": true}));
assert!(doc(&s).swatch(r["name"].as_str().unwrap()).unwrap().tint_of().is_none());
}
#[test]
fn spot_tints_print_their_percentage_of_the_plate() {
let mut s = inks();
rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 40}));
let d = doc(&s);
let c = vectorcraft_color::cms::active();
let Paint::Solid { color, .. } = fill(&s, s.doc().unwrap().selection.objects[0]) else { panic!() };
let ink = proof::inks(d, &c, &color, Some(("Ink", 0.4)), proof::Intent::RelativeColorimetric);
assert_eq!((ink.cmyk, ink.spot), ([0.0; 4], Some(("Ink".to_string(), 0.4))));
// A process colour linked to a global swatch separates into process inks.
assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none());
// PDF: the Separation value is the tint (102 / 255 = 0.4).
let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap();
let pdf = String::from_utf8_lossy(&bytes);
assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation");
assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value");
}
#[test]
fn global_mode_shifts_tints_of_linked_colours_only() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 50}));
run(&mut s, "paint.setStroke", json!({"color": "#808080"}));
let b = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "edit.colors.adjustBalance", json!({"tint": -30}));
assert_eq!(r["changed"], 2, "two linked fills; the grey stroke isn't global");
let tint_of = |p: Paint| match p {
Paint::Solid { color, swatch, tint } => (color, swatch, tint),
_ => panic!("{p:?}"),
};
let (color, swatch, tint) = tint_of(fill(&s, a));
assert!((tint - 0.2).abs() < 1e-6 && swatch.as_deref() == Some("Ink") && color == MAGENTA.tinted(tint), "{tint}");
let brand = Color::from_hex("#3366cc").unwrap();
let (color, swatch, tint) = tint_of(fill(&s, b));
assert!((tint - 0.7).abs() < 1e-6 && swatch.as_deref() == Some("Brand") && color == brand.tinted(tint), "{tint}");
assert_eq!(doc(&s).node(a).unwrap().appearance.stroke_paint(), Paint::solid(Color::from_hex("#808080").unwrap()));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// Clamped at 100%; nothing left to change leaves the document alone.
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 100}));
assert_eq!(r["changed"], 2);
assert_eq!(fill(&s, a), linked(MAGENTA, "Ink", 1.0));
let r = run(&mut s, "edit.colors.adjustBalance", json!({"mode": "global", "tint": 10}));
assert_eq!(r["changed"], 0);
}
#[test]
fn tints_round_trip_through_the_native_format() {
let mut s = inks();
rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Ink", "tint": 35}));
run(&mut s, "swatch.new", json!({}));
let d = doc(&s).clone();
let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap();
let id = s.doc().unwrap().selection.objects[0];
assert_eq!(back.node(id).unwrap().appearance.fill_paint(), fill(&s, id));
assert_eq!(back.swatch("Ink 35%"), d.swatch("Ink 35%"));
}
#[test]
fn converting_the_document_colour_mode_keeps_tints_exact() {
let mut s = inks();
let a = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "Brand", "tint": 30}));
run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"}));
let base = doc(&s).global_color("Brand").unwrap();
assert!(matches!(base, Color::Cmyk { .. }));
assert_eq!(fill(&s, a), linked(base.tinted(0.3), "Brand", 0.3), "30 % of the converted swatch's inks");
}