vectorcraft/crates/engine/src/tests_labspots.rs

205 lines
10 KiB
Rust

//! 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 { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() };
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);
}