mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Add-ons: Color Effects — Duotone effect and Print Ink Fixes
Effect › Color Effects › Duotone… (addon.color.duotone, also in Object › Plug-ins › Color Effects) prints the grey-equivalent tone of each colour with one to four inks, each with its own tone curve. It is a colour adjustment like the add-on's others, so it stacks with them and works as a filter through addon.color.filter. - One tone of a swatch the document has gives a tint of that swatch, linked, so a spot tone separates onto its plate; several tones give the CMYK mix of their tints (the inks overprint, so they multiply). - Colour adjustments now map paints with their swatch links (ColorMap::apply_link); the existing adjustments behave as before. - Skip Objects with Spot Colors leaves spot-linked colours as they are. - The dialog has a row per tone and the picked tone's curve (the Curves graph, reused), its points and Reset to Linear / Inverse. Print Ink Fixes (addon.color.printFixes, Object › Plug-ins › Color Effects › Print Ink Fixes…) scales CMYK colours over a total ink limit down to it, removes white overprint and sets black overprint (Overprint Black's logic) in a CMYK document, as one undo step, and reports what changed. RGB documents are refused. The panel opens both under Inks. Deferred: an opaque undercoat, tones for type only, extra gradient stops for smoother tints, curve flip and all-100 % buttons.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate and
|
||||
//! Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as Filter writes
|
||||
//! any colour adjustment into the colours themselves, and Select by Color Kind selects the objects
|
||||
//! that use spot colours, carry a live colour adjustment, or are images of a colour model. Its panel
|
||||
//! and its submenu, Object › Plug-ins › Color Effects, are the UI's.
|
||||
//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate,
|
||||
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
|
||||
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
|
||||
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
|
||||
//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and
|
||||
//! its submenu, Object › Plug-ins › Color Effects, are the UI's.
|
||||
|
||||
mod print_fixes;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -11,7 +13,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::colorcmds::recolor;
|
||||
use super::super::colorcmds::recolor_links;
|
||||
use super::super::select::walk_selectable;
|
||||
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, text_param};
|
||||
use crate::{Result, Session};
|
||||
@@ -35,10 +37,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply as Filter",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay → {changed}",
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}",
|
||||
has_selection,
|
||||
filter
|
||||
),
|
||||
cmd!(
|
||||
"addon.color.printFixes",
|
||||
"Print Ink Fixes",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
|
||||
has_doc,
|
||||
print_fixes::run
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -116,5 +127,7 @@ fn filter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
|
||||
}
|
||||
let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…'));
|
||||
recolor(s, &given, label, &|c| map.apply(c))
|
||||
let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc);
|
||||
let swatches = |name: &str| kinds.get(name).copied();
|
||||
recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Print Ink Fixes (`addon.color.printFixes`, Object › Plug-ins › Color Effects › Print Ink
|
||||
//! Fixes…): a CMYK document's inks made ready for print in one undo step. Colours with more total
|
||||
//! ink than the limit are scaled down to it, white fills and strokes stop overprinting, and black
|
||||
//! ones start to, as Edit › Edit Colors › Overprint Black sets it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::overprint::{OverprintBlack, is_white};
|
||||
use vectorcraft_doc::{ColorMode, Node, NodeKind};
|
||||
|
||||
use super::super::super::colorcmds::{Recolor, Scope};
|
||||
use super::super::super::overprint::edit_counted;
|
||||
use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const C: &str = "addon.color.printFixes";
|
||||
|
||||
/// What `p` asks for.
|
||||
struct Fixes {
|
||||
/// Most total ink, 0..4 (0: off).
|
||||
ink_limit: f32,
|
||||
exclude_pure_black: bool,
|
||||
remove_white_overprint: bool,
|
||||
/// Overprint Black's least black, 0..1 (0: off).
|
||||
overprint_black: f32,
|
||||
text_only: bool,
|
||||
fill: bool,
|
||||
stroke: bool,
|
||||
}
|
||||
|
||||
/// The percentage `p` gives for `key` in 0..=`max`, as a fraction (0 when left out).
|
||||
fn percent(p: &Value, key: &str, max: f64) -> Result<f32> {
|
||||
let v = number_or(p, key, 0.0, C)?;
|
||||
if !(0.0..=max).contains(&v) {
|
||||
return Err(bad(C, format!("`{key}` must be 0..{max}")));
|
||||
}
|
||||
Ok((v / 100.0) as f32)
|
||||
}
|
||||
|
||||
impl Fixes {
|
||||
fn of(p: &Value) -> Result<Self> {
|
||||
let f = Fixes {
|
||||
ink_limit: percent(p, "inkLimit", 400.0)?,
|
||||
exclude_pure_black: switch_or(p, "excludePureBlack", true, C)?,
|
||||
remove_white_overprint: switch_or(p, "removeWhiteOverprint", false, C)?,
|
||||
overprint_black: percent(p, "overprintBlack", 100.0)?,
|
||||
text_only: switch_or(p, "textOnly", false, C)?,
|
||||
fill: switch_or(p, "fill", true, C)?,
|
||||
stroke: switch_or(p, "stroke", true, C)?,
|
||||
};
|
||||
if f.ink_limit <= 0.0 && f.overprint_black <= 0.0 && !f.remove_white_overprint {
|
||||
return Err(bad(C, "give `inkLimit`, `removeWhiteOverprint` or `overprintBlack`"));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `c` with its total ink scaled down to the limit: CMYK, and grey (black ink) unless
|
||||
/// pure blacks are excluded, which CMYK with black ink only is too.
|
||||
fn limit(&self, c: Color) -> Color {
|
||||
const E: f32 = 0.005;
|
||||
let max = self.ink_limit;
|
||||
match c {
|
||||
Color::Cmyk { c: cy, m, y, k } => {
|
||||
let total = cy + m + y + k;
|
||||
let pure_black = cy <= E && m <= E && y <= E;
|
||||
if total <= max || (self.exclude_pure_black && pure_black) || !total.is_finite() {
|
||||
return c;
|
||||
}
|
||||
let s = max / total;
|
||||
Color::cmyk(cy * s, m * s, y * s, k * s)
|
||||
}
|
||||
Color::Gray { k } if k > max && !self.exclude_pure_black => Color::gray(max),
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove white overprint from `n`'s own fills and strokes and its characters': true when
|
||||
/// that changed anything.
|
||||
fn unset_white(&self, n: &mut Node) -> bool {
|
||||
let mut changed = false;
|
||||
let mut off = |aimed: bool, white: bool, flag: &mut bool| {
|
||||
if aimed && white && *flag {
|
||||
*flag = false;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
for it in &mut n.appearance.items {
|
||||
let aimed = if it.is_fill() { self.fill } else { self.stroke };
|
||||
let white = is_white(it.paint());
|
||||
off(aimed, white, it.overprint_mut());
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
off(self.fill, is_white(&r.style.fill), &mut r.style.overprint_fill);
|
||||
off(self.stroke, is_white(&r.style.stroke), &mut r.style.overprint_stroke);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The overprint fixes on `n` and everything inside it: (objects whose white overprint was
|
||||
/// removed, objects set to overprint black).
|
||||
fn overprint(&self, n: &mut Node, black: &OverprintBlack, spots: &[String]) -> (usize, usize) {
|
||||
let white = usize::from(self.remove_white_overprint && self.unset_white(n));
|
||||
let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_)));
|
||||
let mut counts = (white, usize::from(blacks && black.apply(n, spots)));
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let (w, b) = self.overprint(Arc::make_mut(c), black, spots);
|
||||
counts = (counts.0 + w, counts.1 + b);
|
||||
}
|
||||
counts
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixes = Fixes::of(p)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.color_mode != ColorMode::Cmyk {
|
||||
return Err(bad(C, "Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first"));
|
||||
}
|
||||
let layers = st.doc.layers.iter().map(|l| l.id).collect();
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
// As Artwork Cleanup's: the selection, or the document while nothing is selected.
|
||||
let ids = match text_param(p, "scope", C)? {
|
||||
Some("document") => layers,
|
||||
None if roots.is_empty() => layers,
|
||||
Some("selection") if roots.is_empty() => return Err(bad(C, "nothing selected")),
|
||||
Some("selection") | None => roots,
|
||||
Some(other) => return Err(bad(C, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))),
|
||||
};
|
||||
let black = OverprintBlack { min_k: fixes.overprint_black, fill: fixes.fill, stroke: fixes.stroke, ..OverprintBlack::default() };
|
||||
let (mut ink, mut white, mut blacks) = (0, 0, 0);
|
||||
let changed = edit_counted(s, "Print Ink Fixes", |d| {
|
||||
if fixes.ink_limit > 0.0 {
|
||||
let spots = d.spot_names();
|
||||
// Spot inks and Registration aren't process ink: they keep their colour and link.
|
||||
let links = |c: &mut Color, link: &mut Option<String>, _: &mut f32| {
|
||||
if link.as_ref().is_some_and(|n| n == REGISTRATION || spots.contains(n)) {
|
||||
return false;
|
||||
}
|
||||
let new = fixes.limit(*c);
|
||||
if new == *c {
|
||||
return false;
|
||||
}
|
||||
(*c, *link) = (new, None);
|
||||
true
|
||||
};
|
||||
let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false };
|
||||
ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids);
|
||||
}
|
||||
if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 {
|
||||
return Ok(ink);
|
||||
}
|
||||
let spots = d.spot_names();
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
let (w, b) = fixes.overprint(n, &black, &spots);
|
||||
(white, blacks) = (white + w, blacks + b);
|
||||
}
|
||||
}
|
||||
Ok(ink + white + blacks)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed, "inkLimited": ink, "whiteOverprintRemoved": white, "blackOverprinted": blacks }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Print Ink Fixes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const C: &str = "addon.color.printFixes";
|
||||
|
||||
/// A CMYK document with a rectangle per fill, each with overprint as given; nothing selected.
|
||||
fn doc(fills: &[(Value, bool)]) -> (Session, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200, "colorMode": "cmyk"})).unwrap();
|
||||
let ids = fills
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (fill, overprint))| {
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({ "color": fill })).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("object.setOverprint", &json!({ "fill": overprint })).unwrap();
|
||||
NodeId(id)
|
||||
})
|
||||
.collect();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> (Color, bool) {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let item = n.appearance.items.iter().find(|i| i.is_fill()).unwrap().clone();
|
||||
(item.paint().color().unwrap(), item.overprint())
|
||||
}
|
||||
|
||||
fn cmyk(c: f64, m: f64, y: f64, k: f64) -> Value {
|
||||
json!({"c": c, "m": m, "y": y, "k": k})
|
||||
}
|
||||
|
||||
fn close(a: Color, b: [f32; 4]) -> bool {
|
||||
let Color::Cmyk { c, m, y, k } = a else { return false };
|
||||
[c, m, y, k].iter().zip(b).all(|(x, y)| (x - y).abs() < 0.001)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ink_limit_scales_heavy_colours_down_to_it() {
|
||||
let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), false), (cmyk(40.0, 30.0, 20.0, 10.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false)]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
// Nothing selected: the whole document.
|
||||
let v = s.execute(C, &json!({"inkLimit": 300})).unwrap();
|
||||
assert_eq!((v["inkLimited"].clone(), v["changed"].clone()), (json!(1), json!(1)));
|
||||
assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "{:?}", fill(&s, ids[0]));
|
||||
assert!(close(fill(&s, ids[1]).0, [0.4, 0.3, 0.2, 0.1]), "under the limit: as it was");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// The selection only.
|
||||
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
|
||||
s.execute(C, &json!({"inkLimit": 50})).unwrap();
|
||||
assert!(close(fill(&s, ids[1]).0, [0.2, 0.15, 0.1, 0.05]));
|
||||
assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "left out");
|
||||
// Pure black keeps its ink unless it is included.
|
||||
s.execute(C, &json!({"inkLimit": 50, "scope": "document"})).unwrap();
|
||||
assert!(close(fill(&s, ids[0]).0, [0.125; 4]));
|
||||
assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.8]));
|
||||
s.execute(C, &json!({"inkLimit": 50, "scope": "document", "excludePureBlack": false})).unwrap();
|
||||
assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_and_registration_colours_keep_their_ink() {
|
||||
let (mut s, ids) = doc(&[(cmyk(0.0, 0.0, 0.0, 0.0), false), (cmyk(0.0, 0.0, 0.0, 0.0), false)]);
|
||||
s.execute("swatch.new", &json!({"name": "Heavy", "color": cmyk(100.0, 100.0, 100.0, 50.0), "spot": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Heavy"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "[Registration]"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let before = [fill(&s, ids[0]), fill(&s, ids[1])];
|
||||
assert_eq!(s.execute(C, &json!({"inkLimit": 200})).unwrap()["changed"], 0);
|
||||
assert_eq!([fill(&s, ids[0]), fill(&s, ids[1])], before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_overprint_goes_and_black_overprint_comes_at_the_threshold() {
|
||||
let white = cmyk(0.0, 0.0, 0.0, 0.0);
|
||||
let (mut s, ids) =
|
||||
doc(&[(white.clone(), true), (cmyk(0.0, 0.0, 0.0, 95.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false), (cmyk(30.0, 0.0, 0.0, 100.0), false)]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute(C, &json!({"removeWhiteOverprint": true, "overprintBlack": 90})).unwrap();
|
||||
assert_eq!((v["whiteOverprintRemoved"].clone(), v["blackOverprinted"].clone()), (json!(1), json!(1)), "{v}");
|
||||
let overprints: Vec<bool> = ids.iter().map(|id| fill(&s, *id).1).collect();
|
||||
// White stops; 95 % black starts; 80 % is under the threshold; a rich black isn't pure black.
|
||||
assert_eq!(overprints, [false, true, false, false]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(ids.iter().map(|id| fill(&s, *id).1).collect::<Vec<_>>(), [true, false, false, false]);
|
||||
// On type only: the rectangles stay.
|
||||
let v = s.execute(C, &json!({"overprintBlack": 90, "textOnly": true})).unwrap();
|
||||
assert_eq!(v["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed, no undo step");
|
||||
// Strokes only: the fills stay.
|
||||
assert_eq!(s.execute(C, &json!({"overprintBlack": 90, "fill": false})).unwrap()["changed"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_requests_and_rgb_documents_are_refused() {
|
||||
let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), true)]);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"inkLimit": 0}),
|
||||
json!({"inkLimit": "300"}),
|
||||
json!({"inkLimit": 401}),
|
||||
json!({"inkLimit": -1}),
|
||||
json!({"overprintBlack": 101}),
|
||||
json!({"overprintBlack": true}),
|
||||
json!({"removeWhiteOverprint": "yes"}),
|
||||
json!({"inkLimit": 300, "fill": 1}),
|
||||
json!({"inkLimit": 300, "scope": "page"}),
|
||||
json!({"inkLimit": 300, "scope": "selection"}),
|
||||
json!({"inkLimit": 300, "ids": [999_999]}),
|
||||
] {
|
||||
assert!(s.execute(C, &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(close(fill(&s, ids[0]).0, [1.0; 4]));
|
||||
let mut rgb = Session::new();
|
||||
rgb.execute("file.new", &json!({})).unwrap();
|
||||
rgb.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let e = rgb.execute(C, &json!({"inkLimit": 300})).unwrap_err().to_string();
|
||||
assert!(e.contains("CMYK"), "{e}");
|
||||
}
|
||||
@@ -238,3 +238,79 @@ fn a_halftone_past_the_caps_leaves_the_object_as_it_is() {
|
||||
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
|
||||
assert!(n.children().is_none_or(|c| c.len() < 5), "no dots");
|
||||
}
|
||||
|
||||
/// Duotone, the live effect.
|
||||
const DUOTONE: &str = "addon.color.duotone";
|
||||
|
||||
/// A document with spot swatch "Ink" and a selected grey (50 %) rectangle, unstroked.
|
||||
fn grey_rect() -> (Session, NodeId) {
|
||||
let (mut s, id) = red_rect();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "spot": true})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"gray": 0.5}})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn ink_tone(curve: &str) -> serde_json::Value {
|
||||
json!({"tones": [{"color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "swatch": "Ink", "curve": curve}]})
|
||||
}
|
||||
|
||||
fn fill_paint(s: &Session, id: NodeId) -> vectorcraft_color::Paint {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_draws_a_tint_of_its_spot_tone_and_expands_to_it() {
|
||||
let (mut s, id) = grey_rect();
|
||||
let grey = pixel(&s);
|
||||
s.execute("effect.apply", &json!({"effect": DUOTONE, "params": ink_tone("0,0 100,100")})).unwrap();
|
||||
assert_eq!(fill_paint(&s, id).color().unwrap(), vectorcraft_color::Color::gray(0.5), "the colours stay");
|
||||
let drawn = pixel(&s);
|
||||
assert!(drawn != grey && drawn[0] > drawn[2], "drawn in the ink: {drawn:?}");
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") };
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"), "linked to the spot swatch, so it separates on its plate");
|
||||
assert!((tint - 0.5).abs() < 0.01, "{tint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_as_a_filter_equals_the_live_effect_expanded() {
|
||||
for params in [
|
||||
ink_tone("0,0 100,100"),
|
||||
ink_tone("0,0 40,70 100,100"),
|
||||
json!({"tones": [{"color": "#00ffff", "swatch": "Ink"}, {"color": {"model": "cmyk", "c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,40"}]}),
|
||||
json!({"tones": [{"color": "#000000"}], "skipSpot": true}),
|
||||
] {
|
||||
let (mut live, id) = grey_rect();
|
||||
live.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap();
|
||||
live.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let (mut filtered, _) = grey_rect();
|
||||
let undo = filtered.doc().unwrap().history.undo.len();
|
||||
assert_eq!(filtered.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": params})).unwrap()["changed"], 1);
|
||||
assert_eq!(filtered.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(fill_paint(&filtered, id), fill_paint(&live, id), "{params}");
|
||||
}
|
||||
// The params round-trip: the effect keeps what it was given, and the filter reads the same.
|
||||
let (mut s, id) = grey_rect();
|
||||
let params = ink_tone("0,0 40,70 100,100");
|
||||
s.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap();
|
||||
let kept = s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params.clone();
|
||||
assert_eq!(kept["tones"], params["tones"]);
|
||||
s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": kept})).unwrap();
|
||||
let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") };
|
||||
assert_eq!(swatch.as_deref(), Some("Ink"));
|
||||
let fx = vectorcraft_render::effects::addons::duotone::tone_curve_points(Some(&json!("0,0 40,70 100,100")));
|
||||
let want = vectorcraft_render::effects::curve_at(&fx, 0.5);
|
||||
assert!(want > 0.7 && (tint - want).abs() < 0.01, "the curve lifts the mid tone: {tint}, {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_skips_spot_colours_when_asked() {
|
||||
let (mut s, id) = grey_rect();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
let spot = fill_paint(&s, id);
|
||||
s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}], "skipSpot": true}})).unwrap();
|
||||
assert_eq!(fill_paint(&s, id), spot);
|
||||
s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}]}})).unwrap();
|
||||
assert_ne!(fill_paint(&s, id), spot);
|
||||
}
|
||||
|
||||
@@ -335,6 +335,12 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color)
|
||||
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
|
||||
/// link and tint.
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step.
|
||||
fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
|
||||
Reference in New Issue
Block a user