mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 13:36:53 +00:00
1198 lines
63 KiB
Rust
1198 lines
63 KiB
Rust
//! Fill and stroke paint (the toolbar proxies and their defaults) and the Transparency panel.
|
||
|
||
use std::borrow::Cow;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind};
|
||
|
||
use super::appearance::{ItemTarget, edit_items, edit_items_then, item_target, row_targets};
|
||
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
|
||
use super::{
|
||
Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok,
|
||
paint_targets, str_param, switch_or,
|
||
};
|
||
use crate::{DocState, EngineError};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"paint.setFill",
|
||
"Fill",
|
||
[],
|
||
None,
|
||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), 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|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), 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 counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), 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?|id?, 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), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||
has_doc,
|
||
|s, p| set_paint(s, p, true)
|
||
),
|
||
cmd!(
|
||
"paint.setStroke",
|
||
"Stroke",
|
||
[],
|
||
None,
|
||
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill",
|
||
has_doc,
|
||
|s, p| { set_paint(s, p, false) }
|
||
),
|
||
cmd!(
|
||
"paint.swap",
|
||
"Swap Fill and Stroke",
|
||
[],
|
||
Some("Shift+X"),
|
||
"{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)",
|
||
has_doc,
|
||
swap
|
||
),
|
||
cmd!(
|
||
"paint.default",
|
||
"Default Fill and Stroke",
|
||
[],
|
||
Some("D"),
|
||
"{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)",
|
||
has_doc,
|
||
default_paint
|
||
),
|
||
cmd!(
|
||
"paint.toggleActive",
|
||
"Toggle Fill/Stroke Focus",
|
||
[],
|
||
Some("X"),
|
||
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
|
||
always,
|
||
|s, p| {
|
||
s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?;
|
||
Ok(json!({ "fillActive": s.fill_active }))
|
||
}
|
||
),
|
||
cmd!(
|
||
"paint.none",
|
||
"None",
|
||
[],
|
||
Some("/"),
|
||
"{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}",
|
||
has_doc,
|
||
|s, _| {
|
||
let f = s.fill_active;
|
||
set_paint(s, &json!({"none": true}), f)
|
||
}
|
||
),
|
||
cmd!(
|
||
"transparency.set",
|
||
"Transparency",
|
||
["Window", "Transparency"],
|
||
None,
|
||
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
|
||
has_doc,
|
||
transparency
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Commands of the Fill/Stroke proxies, registered at the end of the command list.
|
||
pub fn proxy_specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"paint.invert",
|
||
"Invert",
|
||
[],
|
||
None,
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md); no undo step when no color changes → {changed}",
|
||
has_doc,
|
||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||
),
|
||
cmd!(
|
||
"paint.complement",
|
||
"Complement",
|
||
[],
|
||
None,
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item and a selected object of a dynamic symbol instance as paint.invert does; with nothing selected, the default; no undo step when no color changes → {changed}",
|
||
has_doc,
|
||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||
),
|
||
cmd!(
|
||
"paint.lastColor",
|
||
"Apply Last Color",
|
||
[],
|
||
Some(","),
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last solid colour used (paint.recent lastColor) to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||
has_doc,
|
||
|s, p| {
|
||
let paint = Paint::solid(s.last_solid);
|
||
apply_to_proxy(s, p, paint, "paint.lastColor")
|
||
}
|
||
),
|
||
cmd!(
|
||
"paint.lastGradient",
|
||
"Apply Last Gradient",
|
||
[],
|
||
Some("."),
|
||
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} apply the last gradient used (paint.recent lastGradient), fitted to each object, to the active proxy of the selection and the default → {objects} (as paint.setFill)",
|
||
has_doc,
|
||
|s, p| {
|
||
let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone())));
|
||
apply_to_proxy(s, p, paint, "paint.lastGradient")
|
||
}
|
||
),
|
||
cmd!(
|
||
query "paint.recent",
|
||
"Recent Colors",
|
||
[],
|
||
None,
|
||
"{} → {colors: [{hex, color}] newest first, at most 30, each once, kept across documents for the session (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}",
|
||
always,
|
||
|s, _| {
|
||
let c = |c: &Color| json!({"hex": c.to_hex(), "color": c});
|
||
Ok(json!({
|
||
"colors": s.recent_colors.iter().map(c).collect::<Vec<_>>(),
|
||
"lastColor": c(&s.last_solid),
|
||
"lastGradient": s.last_gradient,
|
||
}))
|
||
}
|
||
),
|
||
cmd!(
|
||
query "paint.proxies",
|
||
"Fill and Stroke",
|
||
[],
|
||
None,
|
||
"{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}",
|
||
always,
|
||
proxies
|
||
),
|
||
]
|
||
}
|
||
|
||
impl Session {
|
||
/// How many colors Recent Colors keeps. The Session holds the list, so it stays the same when
|
||
/// another document is opened or made active.
|
||
pub const RECENT_MAX: usize = 30;
|
||
|
||
/// Remember an applied paint: a solid colour becomes the last colour and the newest recent
|
||
/// colour, a gradient the last gradient. During a live preview this waits for the commit.
|
||
pub(crate) fn remember_paint(&mut self, p: &Paint) {
|
||
if self.in_interaction() {
|
||
self.pending_paint = Some(p.clone());
|
||
} else {
|
||
self.remember_paint_now(p);
|
||
}
|
||
}
|
||
|
||
/// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the
|
||
/// first selected object's own stack) for the proxy of its kind, else the first selected
|
||
/// object's (a group's first painted object's; type: its first run's), else the defaults for
|
||
/// new art. When the object whose fill the proxy shows is a gradient mesh with points picked
|
||
/// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid
|
||
/// fill color changes.
|
||
pub fn proxy_paints(&self) -> (Paint, Paint) {
|
||
let points = self.fill_mesh_points();
|
||
let show = |stroke: bool| match self.proxy_source(stroke) {
|
||
Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c),
|
||
Some((n, item)) => proxy_paint(&n, stroke, item),
|
||
None if stroke => self.paint.stroke.clone(),
|
||
None => self.paint.fill.clone(),
|
||
};
|
||
(show(false), show(true))
|
||
}
|
||
|
||
/// The object whose fill (or stroke) the proxy shows, with the Appearance panel's active item
|
||
/// when it stands for it (see [`Session::proxy_paints`]); None: the defaults for new art. With
|
||
/// an object of a dynamic symbol instance selected ([`DocState::selected_symbol_part`]), that
|
||
/// object as the instance draws it.
|
||
pub(crate) fn proxy_source(&self, stroke: bool) -> Option<(Cow<'_, Node>, Option<usize>)> {
|
||
let st = self.active()?;
|
||
if let Some(part) = st.selected_symbol_part() {
|
||
return Some((Cow::Owned(part), None));
|
||
}
|
||
let first = st.doc.node(*st.selection.subjects().first()?)?;
|
||
let item = self.appearance_item();
|
||
if first.appearance.item_of_kind(item, !stroke).is_some() {
|
||
return Some((Cow::Borrowed(first), item));
|
||
}
|
||
let mut nodes = vec![];
|
||
painted(first, true, &mut nodes);
|
||
nodes.first().map(|n| (Cow::Borrowed(*n), None))
|
||
}
|
||
|
||
pub(crate) fn remember_paint_now(&mut self, p: &Paint) {
|
||
match p {
|
||
Paint::Solid { color, .. } => {
|
||
self.last_solid = *color;
|
||
self.recent_colors.retain(|c| c != color);
|
||
self.recent_colors.insert(0, *color);
|
||
self.recent_colors.truncate(Self::RECENT_MAX);
|
||
}
|
||
Paint::Gradient(g) => self.last_gradient = (**g).clone(),
|
||
Paint::None | Paint::Pattern { .. } => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint_only`]: fill or stroke `item` when
|
||
/// it is of that kind, type's first run, else the topmost one).
|
||
pub(crate) fn proxy_paint(n: &Node, stroke: bool, item: Option<usize>) -> Paint {
|
||
match n.proxy_paint_only(stroke, item) {
|
||
Some(p) => p.clone(),
|
||
// Type without runs shows the default character style.
|
||
None if matches!(n.kind, NodeKind::Text(_)) => {
|
||
let st = CharStyle::default();
|
||
if stroke { st.stroke } else { st.fill }
|
||
}
|
||
None => Paint::None,
|
||
}
|
||
}
|
||
|
||
/// Do two paints look the same in a proxy? Solid colours compare without their swatch link and
|
||
/// gradients without their per-object geometry.
|
||
fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
|
||
match (a, b) {
|
||
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => x == y,
|
||
(Paint::Gradient(x), Paint::Gradient(y)) => x.gradient == y.gradient,
|
||
_ => a == b,
|
||
}
|
||
}
|
||
|
||
/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands
|
||
/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and
|
||
/// other containers inside them), a compound path for its parts.
|
||
pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||
// A ruler guide has no paints.
|
||
if n.is_ruler_guide() {
|
||
return;
|
||
}
|
||
let expand = match n.kind {
|
||
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
|
||
NodeKind::Compound { .. } => false,
|
||
_ => !top && n.is_container(),
|
||
};
|
||
if expand {
|
||
n.children().into_iter().flatten().for_each(|c| painted(c, false, out));
|
||
} else {
|
||
out.push(n);
|
||
}
|
||
}
|
||
|
||
impl DocState {
|
||
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
|
||
/// proxy; a group whose contents differ counts). `item` (the Appearance panel's active item)
|
||
/// stands for the proxy of its kind, as in [`Session::proxy_paints`]: that fill or stroke of
|
||
/// each selected object's own stack is compared. One walk of the document, so callers that ask
|
||
/// every frame cache it by revision.
|
||
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
|
||
self.proxy_mixed_with(item, &[])
|
||
}
|
||
|
||
/// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors
|
||
/// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of
|
||
/// its picked points instead of its fill.
|
||
fn proxy_mixed_with(&self, item: Option<usize>, points: &[(NodeId, Vec<usize>)]) -> (bool, bool) {
|
||
if self.selection.is_empty() {
|
||
return (false, false);
|
||
}
|
||
if self.selection.symbol_part.is_some() {
|
||
return (false, false);
|
||
}
|
||
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
|
||
let (mut selected, mut nodes) = (vec![], vec![]);
|
||
self.doc.walk(|n| {
|
||
if ids.contains(&n.id) {
|
||
selected.push(n);
|
||
painted(n, true, &mut nodes);
|
||
}
|
||
});
|
||
let first = self.selection.objects.first().and_then(|id| self.doc.node(*id));
|
||
let differ = |stroke: bool| {
|
||
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
|
||
let set = if item.is_some() { &selected } else { &nodes };
|
||
let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) {
|
||
c if c.is_empty() => vec![proxy_paint(n, stroke, item)],
|
||
c => c.into_iter().map(Paint::solid).collect(),
|
||
});
|
||
let Some(p0) = paints.next() else { return false };
|
||
paints.any(|p| !same_in_proxy(&p0, &p))
|
||
};
|
||
(differ(false), differ(true))
|
||
}
|
||
|
||
/// The object of a dynamic symbol instance's art selected with Direct Selection
|
||
/// ([`vectorcraft_doc::Selection::symbol_part`]), with the instance's own appearance changes
|
||
/// applied, in the symbol's coordinates. `None` without one.
|
||
pub(crate) fn selected_symbol_part(&self) -> Option<Node> {
|
||
let sp = self.selection.symbol_part.as_ref()?;
|
||
let inst = self.doc.node(sp.instance)?;
|
||
let NodeKind::SymbolInstance { symbol, .. } = &inst.kind else { return None };
|
||
let sym = self.doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst);
|
||
vectorcraft_doc::symbol_overrides::part(&art, &sp.path).cloned()
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
|
||
/// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors.
|
||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||
self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points()))
|
||
}
|
||
}
|
||
|
||
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let (fill, stroke) = s.proxy_paints();
|
||
let (fill_mixed, stroke_mixed) = s.proxy_mixed();
|
||
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
|
||
}
|
||
|
||
/// `stroke` param, defaulting to the proxy that is in front.
|
||
fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||
switch_or(p, "stroke", !s.fill_active, cmd)
|
||
}
|
||
|
||
/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must
|
||
/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists
|
||
/// `keepModel` calls it before it changes anything.
|
||
pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> {
|
||
check_switches(p, &["keepModel"], cmd)
|
||
}
|
||
|
||
/// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in
|
||
/// an RGB one, or with `keepModel`).
|
||
pub(super) fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Model> {
|
||
let mode = s.active()?.doc.color_mode;
|
||
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
|
||
}
|
||
|
||
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
|
||
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
|
||
/// are returned as given.
|
||
pub(crate) fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
|
||
match (c, model) {
|
||
(Color::Rgb { .. }, Some(m)) => {
|
||
let cms = vectorcraft_color::cms::active();
|
||
cms.convert(&c, m, cms.settings().intent)
|
||
}
|
||
_ => c,
|
||
}
|
||
}
|
||
|
||
/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without
|
||
/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]).
|
||
pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
|
||
stored_color(new_color_model(s, p), c)
|
||
}
|
||
|
||
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
|
||
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
|
||
/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel`
|
||
/// other than true, false or null is an error.
|
||
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
||
check_keep_model(p, "paint")?;
|
||
let model = new_color_model(s, p);
|
||
let new_color = |c: Color| stored_color(model, c);
|
||
if switch_or(p, "none", false, "paint")? {
|
||
return Ok(Some(Paint::None));
|
||
}
|
||
if let Some(name) = str_param(p, "swatch") {
|
||
let d = &s.doc()?.doc;
|
||
// A pattern definition works as its swatch even without a swatch entry.
|
||
if d.swatch(name).is_none() && d.pattern(name).is_some() {
|
||
return Ok(Some(vectorcraft_doc::pattern::pattern_paint(name)));
|
||
}
|
||
let sw = d.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
|
||
let tint = tint_param(p, "paint")?;
|
||
return match &sw.paint {
|
||
Paint::Solid { .. } => swatch_solid(d, name, tint).map(Some).map_err(|e| bad("paint", e)),
|
||
_ if tint.is_some() => Err(bad("paint", "`tint` applies to global and spot colours")),
|
||
Paint::Gradient(g) => Ok(Some(Paint::Gradient(Box::new(GradientPaint { swatch: Some(name.to_string()), ..(**g).clone() })))),
|
||
other => Ok(Some(other.clone())),
|
||
};
|
||
}
|
||
if let Some(g) = p.get("gradient") {
|
||
check_numbers(g, &["angle"], "paint")?;
|
||
// A stop naming a process swatch keeps the swatch's model.
|
||
let stored = super::gradient::stop_colors(s, p, g);
|
||
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
|
||
let mut gp = super::gradient::parse_gradient(&super::gradient::with_stored_stops(&g, &Paint::None, &stored)).map_err(|e| bad("paint", e))?;
|
||
if g.get("stops").is_none() {
|
||
// The default stops, white to black.
|
||
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
|
||
}
|
||
if let Some(f) = &mut gp.freeform {
|
||
f.points.iter_mut().for_each(|pt| pt.color = new_color(pt.color));
|
||
}
|
||
return Ok(Some(Paint::Gradient(Box::new(gp))));
|
||
}
|
||
if let Some(c) = p.get("color") {
|
||
let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?;
|
||
return Ok(Some(Paint::solid(new_color(c))));
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
/// The `tint` param (a percentage) as 0..1.
|
||
pub(crate) fn tint_param(p: &Value, cmd: &str) -> Result<Option<f32>> {
|
||
p.get("tint")
|
||
.filter(|v| !v.is_null())
|
||
.map(|v| v.as_f64().map(|t| (t / 100.0).clamp(0.0, 1.0) as f32).ok_or_else(|| bad(cmd, "`tint` must be a number (%)")))
|
||
.transpose()
|
||
}
|
||
|
||
/// The solid paint colour swatch `name` applies at `tint` (0..1): a global or spot colour links to
|
||
/// itself (default 100%), a tint swatch to its base (default: its own tint), a process colour is
|
||
/// just its colour (it has no tints).
|
||
pub(crate) fn swatch_solid(d: &Document, name: &str, tint: Option<f32>) -> std::result::Result<Paint, String> {
|
||
let sw = d.swatch(name).ok_or_else(|| format!("no swatch `{name}`"))?;
|
||
if sw.paint.color().is_none() {
|
||
return Err(format!("`{name}` isn't a colour swatch"));
|
||
}
|
||
match (d.swatch_link(name), tint) {
|
||
// A tint swatch whose base is gone keeps its colour.
|
||
(Some((base, own)), _) => Ok(d.tint_paint(&base, tint.unwrap_or(own)).unwrap_or_else(|| sw.paint.clone())),
|
||
(None, None) => Ok(sw.paint.clone()),
|
||
(None, Some(_)) => Err(format!("`{name}` is a process colour: tints apply to global and spot colours")),
|
||
}
|
||
}
|
||
|
||
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||
if p.get("tint").is_some() && p.get("swatch").is_none() {
|
||
return Err(bad(cmd, "`tint` goes with a global or spot `swatch`"));
|
||
}
|
||
check_keep_model(p, cmd)?;
|
||
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
|
||
// As `paint.editGradient`: a `dither` given applies to gradient fills only. A gradient swatch
|
||
// (named) keeps its own dither; `"swatch": null` names none.
|
||
if !fill && matches!(&paint, Paint::Gradient(g) if g.gradient.dither) && p.get("swatch").and_then(Value::as_str).is_none() {
|
||
return Err(bad(cmd, "`dither` applies to gradient fills (paint.setFill)"));
|
||
}
|
||
apply_paint(s, p, paint, fill)
|
||
}
|
||
|
||
/// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it.
|
||
fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result<Value> {
|
||
let fill = !stroke_param(s, p, cmd)?;
|
||
apply_paint(s, p, paint, fill)
|
||
}
|
||
|
||
/// Set the fill (or stroke) of the targets of `p` (the `item` and `ids` params, see
|
||
/// `paint.setFill`) and of the defaults for new art, focus its proxy unless `focus` is false, and
|
||
/// remember the paint. Replies `{objects}`: how many objects took the paint.
|
||
pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
|
||
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
|
||
let focus = switch_or(p, "focus", true, cmd)?;
|
||
let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?;
|
||
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
|
||
if p.get("ids").is_none()
|
||
&& p.get("item").is_none()
|
||
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
|
||
&& a != b
|
||
{
|
||
// After Select All in threaded text, every frame's text takes it.
|
||
let story = super::textedit::editing_story(s);
|
||
let ranges: Vec<(NodeId, usize, usize)> =
|
||
if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() };
|
||
let ids: Vec<NodeId> = ranges.iter().map(|(f, _, _)| *f).collect();
|
||
// When the type comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
// The graph labels whose whole text takes the paint: a graph keeps it for their kind of label.
|
||
let whole: Vec<NodeId> = ranges
|
||
.iter()
|
||
.filter(|(id, a, b)| {
|
||
*a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs))
|
||
})
|
||
.map(|(id, _, _)| *id)
|
||
.collect();
|
||
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
|
||
for &(id, a, b) in &ranges {
|
||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||
let (xf, lb) = (t.xf, t.local_bounds());
|
||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||
if fill {
|
||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||
} else {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(st);
|
||
}
|
||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||
}
|
||
});
|
||
super::typecmd::refresh_bounds(t);
|
||
}
|
||
let labels = super::graph::capture_label_styles(d, &whole, &|st| {
|
||
if fill {
|
||
st.fill = unplaced(&paint);
|
||
} else {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(st);
|
||
}
|
||
st.stroke = unplaced(&paint);
|
||
}
|
||
});
|
||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels))
|
||
})?;
|
||
if focus {
|
||
s.fill_active = fill;
|
||
}
|
||
s.remember_paint(&paint);
|
||
return Ok(json!({"objects": ids.len()}));
|
||
}
|
||
// An object of a dynamic symbol instance's art selected with Direct Selection: that object's
|
||
// fill or stroke changes for this instance only (solid, none, gradient or pattern).
|
||
if let Some((id, path)) = selected_part_target(s, p, cmd)? {
|
||
super::brushsym::edit_part(s, id, &path, if fill { "Fill Color" } else { "Stroke Color" }, |o| {
|
||
if fill {
|
||
o.fill = Some(paint.clone());
|
||
} else {
|
||
o.stroke = Some(paint.clone());
|
||
}
|
||
})?;
|
||
paint_new_art(s, &paint, fill);
|
||
if focus {
|
||
s.fill_active = fill;
|
||
}
|
||
s.remember_paint(&paint);
|
||
return Ok(json!({"objects": 1}));
|
||
}
|
||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||
let selected = item.targets(s, p)?;
|
||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid
|
||
// fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`).
|
||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||
// Without `item`, unless the Appearance panel's active fill item takes the paint.
|
||
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
|
||
colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))?
|
||
} else {
|
||
vec![]
|
||
};
|
||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||
// Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series
|
||
// (a series mark is a path, so it always has a row here).
|
||
let written = std::cell::RefCell::new(Vec::new());
|
||
// The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep
|
||
// for their characters; a row edit is kept with the label's rows.
|
||
let characters = std::cell::RefCell::new(Vec::new());
|
||
edit_items_then(
|
||
s,
|
||
&ids,
|
||
item,
|
||
cmd,
|
||
label,
|
||
fill,
|
||
|n, index| {
|
||
if index.is_none() && matches!(n.kind, NodeKind::Text(_)) {
|
||
characters.borrow_mut().push(n.id);
|
||
}
|
||
if index.is_none() && colorized.contains(&n.id) {
|
||
n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone()));
|
||
return Ok(());
|
||
}
|
||
if index.is_none()
|
||
&& let Paint::Solid { color, .. } = &paint
|
||
&& let Some((_, points)) = mesh_points.iter().find(|(id, _)| *id == n.id)
|
||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||
{
|
||
for i in points {
|
||
if let Some(q) = m.points.get_mut(*i) {
|
||
q.color = *color;
|
||
}
|
||
}
|
||
return Ok(());
|
||
}
|
||
if index.is_none()
|
||
&& let NodeKind::Text(t) = &mut n.kind
|
||
{
|
||
let (xf, lb) = (t.xf, t.local_bounds());
|
||
for r in &mut t.runs {
|
||
if !fill && !paint.is_none() {
|
||
run_stroke_weight(&mut r.style);
|
||
}
|
||
let (cur, b) = run_paint_mut(r, !fill, lb);
|
||
*cur = place_run_paint(&paint, p, xf, b);
|
||
}
|
||
return Ok(());
|
||
}
|
||
// A missing top fill or stroke is created first: a new stroke's weight sizes the box its
|
||
// gradient fits.
|
||
if n.appearance.paint_at(index, fill).is_none() {
|
||
n.appearance.set_paint_at(index, fill, Paint::None);
|
||
}
|
||
if !fill {
|
||
restore_unpainted_stroke(&mut n.appearance, index, &paint);
|
||
}
|
||
let new_paint = keep_pattern_xf(&paint, p, n.appearance.paint_at(index, fill));
|
||
let placed = place_paint(&new_paint, p, item_paint_bounds(n, index, fill));
|
||
n.appearance.set_paint_at(index, fill, placed);
|
||
if !fill
|
||
&& paint.is_none()
|
||
&& let Some(stroke) = n.appearance.stroke_at_mut(index)
|
||
{
|
||
// A stroke without paint has no weight; painting it again restores the 1 pt default.
|
||
stroke.width = 0.0;
|
||
}
|
||
if !fill {
|
||
super::object::snap_if_aligned(n);
|
||
}
|
||
let at = match index {
|
||
Some(_) => n.appearance.item_of_kind(index, fill),
|
||
None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill),
|
||
};
|
||
written.borrow_mut().extend(at.map(|at| (n.id, at)));
|
||
Ok(())
|
||
},
|
||
|before, d, changed| {
|
||
let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow());
|
||
let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| {
|
||
if fill {
|
||
st.fill = unplaced(&paint);
|
||
} else {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(st);
|
||
}
|
||
st.stroke = unplaced(&paint);
|
||
}
|
||
});
|
||
Ok(series || labels)
|
||
},
|
||
)?;
|
||
// Rejected edits leave the next shape's appearance and active proxy untouched.
|
||
paint_new_art(s, &paint, fill);
|
||
if focus {
|
||
s.fill_active = fill;
|
||
}
|
||
s.remember_paint(&paint);
|
||
Ok(json!({"objects": ids.iter().collect::<std::collections::BTreeSet<_>>().len()}))
|
||
}
|
||
|
||
/// The instance whose object is selected with Direct Selection, and that object's path
|
||
/// ([`super::brushsym::selected_part`]), when `p` names no `ids`, `id` or `item`: the paint commands
|
||
/// then change that object. A bad `target` or `aligned` is refused as it is for any other selection.
|
||
fn selected_part_target(s: &Session, p: &Value, cmd: &str) -> Result<Option<(NodeId, Vec<u32>)>> {
|
||
if p.get("ids").is_some() || p.get("id").is_some() || p.get("item").is_some() {
|
||
return Ok(None);
|
||
}
|
||
let part = super::brushsym::selected_part(s)?;
|
||
if part.is_some() {
|
||
item_target(s, p, cmd)?;
|
||
}
|
||
Ok(part)
|
||
}
|
||
|
||
/// Can Invert / Complement recolour `paint` on an object of a dynamic symbol instance's art selected
|
||
/// with Direct Selection? A solid color or none can; a gradient or pattern cannot (one override
|
||
/// holds one paint, and those commands recolour solid colours only).
|
||
pub(crate) fn part_takes(paint: &Paint) -> bool {
|
||
matches!(paint, Paint::Solid { .. } | Paint::None)
|
||
}
|
||
|
||
/// Make `paint` the fill or stroke of new art. New art takes an unplaced gradient so that it fits
|
||
/// the new object's own bounds, and a painted stroke on art that had none gets the 1 pt default.
|
||
fn paint_new_art(s: &mut Session, paint: &Paint, fill: bool) {
|
||
if fill {
|
||
s.paint.fill = unplaced(paint);
|
||
} else {
|
||
if !paint.is_none() && s.paint.stroke.is_none() && s.paint.stroke_width == 0.0 {
|
||
s.paint.stroke_width = 1.0;
|
||
}
|
||
s.paint.stroke = unplaced(paint);
|
||
}
|
||
}
|
||
|
||
/// Does `p` ask for `span: "selection"` (#2127)? Only a linear, radial, conic or diamond gradient the targets
|
||
/// would each fit to their own bounds can span them: one with its own vector (`start`/`end`),
|
||
/// a freeform one or a gradient swatch already has its placement.
|
||
fn spans_targets(p: &Value, paint: &Paint) -> std::result::Result<bool, String> {
|
||
match p.get("span") {
|
||
None | Some(Value::Null) => return Ok(false),
|
||
Some(Value::String(v)) if v == "object" => return Ok(false),
|
||
Some(Value::String(v)) if v == "selection" => {}
|
||
Some(v) => return Err(format!("span must be \"object\" or \"selection\", got {v}")),
|
||
}
|
||
let Paint::Gradient(g) = paint else { return Err("span: \"selection\" places a gradient: give `gradient`".into()) };
|
||
if p.get("swatch").is_some() {
|
||
return Err("span: \"selection\" places a `gradient`, not a swatch".into());
|
||
}
|
||
if g.gradient.kind == vectorcraft_color::GradientKind::Freeform || g.freeform.is_some() {
|
||
return Err("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one".into());
|
||
}
|
||
if g.geom.is_some() {
|
||
return Err(
|
||
"span: \"selection\" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates)".into()
|
||
);
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// `paint` placed once on the union of the bounds of `ids` (each one's fill box, or the box its
|
||
/// stroke paints), at the gradient's angle and aspect: every target gets the same vector in
|
||
/// document coordinates, so the ramp runs on across them (#2127).
|
||
fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bool) -> Paint {
|
||
let union = ids
|
||
.iter()
|
||
.filter_map(|id| d.node(*id))
|
||
.filter_map(|n| item_paint_bounds(n, None, fill).or_else(|| n.geometric_bounds()))
|
||
.filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))
|
||
.reduce(|a, b| a.union(b));
|
||
let (Some(union), Paint::Gradient(g)) = (union, &paint) else { return paint };
|
||
// An `aspect` places a radial gradient on the box already; otherwise fit it there.
|
||
match place_paint(&paint, p, Some(union)) {
|
||
Paint::Gradient(placed) if placed.geom.is_some() => Paint::Gradient(placed),
|
||
_ => {
|
||
let geom = vectorcraft_color::GradientGeom::fit(g.gradient.kind, union, g.angle);
|
||
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..(**g).clone() }))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid
|
||
/// color (or none on a colorized image, which takes the color off). The color may be black, white,
|
||
/// a process color or a spot color; on an image with an alpha channel, only a spot color, black or
|
||
/// white, and other colors are an error. A colorized image that shows a preview that isn't
|
||
/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint;
|
||
/// while its paint is one an alpha channel allows, a process color is an error there too.
|
||
fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result<Vec<NodeId>, String> {
|
||
let mut out = vec![];
|
||
for id in ids {
|
||
let Some(n) = d.node(*id) else { continue };
|
||
let NodeKind::Image(im) = &n.kind else { continue };
|
||
// `none` takes a color off whatever the image's pixels have become since.
|
||
if matches!(paint, Paint::None) && n.extras().colorize.is_some() {
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
// The Fill proxy shows such an image's colorize paint. Its pixels are not known until the
|
||
// file is relinked: a process color replaces only a process color, which the image's
|
||
// pixels already accepted, so a relink can't bring back an alpha channel under one.
|
||
if matches!(paint, Paint::Solid { .. })
|
||
&& let Some(old) = n.extras().colorize.as_deref()
|
||
&& d.image_blob(im).is_some_and(|b| !b.takes_colorize())
|
||
{
|
||
if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) {
|
||
return Err(PROCESS_ON_TRANSPARENCY.into());
|
||
}
|
||
out.push(*id);
|
||
continue;
|
||
}
|
||
// A color-masked image is colorized as it shows: its masked pixels, which have an alpha
|
||
// channel.
|
||
let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue };
|
||
match paint {
|
||
// Without a color, `none` goes to the image's fill item.
|
||
Paint::None => continue,
|
||
Paint::Solid { .. } => {
|
||
if gray.alpha && !colorizes_transparency(d, paint) {
|
||
return Err(PROCESS_ON_TRANSPARENCY.into());
|
||
}
|
||
}
|
||
_ => continue,
|
||
}
|
||
out.push(*id);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The message for a process color on a grayscale image with an alpha channel.
|
||
pub(super) const PROCESS_ON_TRANSPARENCY: &str =
|
||
"a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||
|
||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||
/// of `d`, black or white?
|
||
fn colorizes_transparency(d: &Document, paint: &Paint) -> bool {
|
||
match paint {
|
||
Paint::Solid { color, swatch, .. } => swatch.as_deref().and_then(|name| d.swatch(name)).is_some_and(|w| w.spot) || black_or_white(color),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// An error when an image with an alpha channel in `d` (its art, opacity masks, symbols, graph
|
||
/// designs and pattern tiles) is colorized with a color that [`colorizes_transparency`] refuses,
|
||
/// as a swatch edit that turns a spot color into a process color or recolors a process swatch
|
||
/// can leave one.
|
||
pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||
let mut refused = false;
|
||
let mut visit = |n: &Node| {
|
||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||
if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) {
|
||
refused = true;
|
||
}
|
||
})
|
||
};
|
||
d.layers
|
||
.iter()
|
||
.chain(d.symbols.iter().map(|s| &s.art))
|
||
.chain(d.graph_designs.iter().map(|g| &g.art))
|
||
.chain(d.patterns.iter().flat_map(|p| &p.art))
|
||
.for_each(|n| visit(n));
|
||
if refused { Err(PROCESS_ON_TRANSPARENCY.into()) } else { Ok(()) }
|
||
}
|
||
|
||
/// [`check_colorized`] after an edit, when the document passed it before the edit (`was_ok`), so
|
||
/// an edit doesn't fail over an image it didn't change.
|
||
pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::Result<(), String> {
|
||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||
}
|
||
|
||
/// Is `n` an image colorized with a color that [`colorizes_transparency`] refuses? When its pixels
|
||
/// are grayscale or 1-bit, they can't have an alpha channel.
|
||
pub(super) fn colorized_with_process(d: &Document, n: &Node) -> bool {
|
||
matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.as_deref().is_some_and(|paint| !colorizes_transparency(d, paint))
|
||
}
|
||
|
||
/// How the pixels `n` shows are gray, when `n` is a grayscale or 1-bit image colorized with a
|
||
/// color that [`colorizes_transparency`] refuses ([`colorized_with_process`]).
|
||
pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> {
|
||
let NodeKind::Image(im) = &n.kind else { return None };
|
||
if colorized_with_process(d, n) { d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) } else { None }
|
||
}
|
||
|
||
/// Is `e` the refusal of [`check_colorized`]?
|
||
pub(super) fn is_colorize_refusal(e: &EngineError) -> bool {
|
||
e.to_string().contains(PROCESS_ON_TRANSPARENCY)
|
||
}
|
||
|
||
/// Is `c` black or white (white, or black alone at 100%)?
|
||
fn black_or_white(c: &Color) -> bool {
|
||
let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0);
|
||
match *c {
|
||
Color::Gray { k } => none(k) || full(k),
|
||
Color::Cmyk { c, m, y, k } => none(c) && none(m) && none(y) && (none(k) || full(k)),
|
||
Color::Rgb { r, g, b } => [r, g, b].into_iter().all(none) || [r, g, b].into_iter().all(full),
|
||
Color::Lab { .. } => false,
|
||
}
|
||
}
|
||
|
||
/// The points the active tool has picked (its `meshPoints` option: the Mesh tool's, Direct
|
||
/// Selection's or the Lasso's) on the gradient meshes among `ids`, in range, each mesh with its points.
|
||
fn picked_mesh_points(s: &Session, ids: &[NodeId]) -> Vec<(NodeId, Vec<usize>)> {
|
||
let o = s.tool_options();
|
||
let (Some(picked), Ok(st)) = (o.get("meshPoints").and_then(Value::as_array), s.doc()) else { return vec![] };
|
||
let mut out: Vec<(NodeId, Vec<usize>)> = vec![];
|
||
for pt in picked {
|
||
let (Some(id), Some(index)) = (pt.get("id").and_then(Value::as_u64).map(NodeId), pt.get("index").and_then(Value::as_u64)) else { continue };
|
||
let Ok(index) = usize::try_from(index) else { continue };
|
||
if !ids.contains(&id) || !matches!(st.doc.node(id).map(|n| &n.kind), Some(NodeKind::Mesh(m)) if index < m.points.len()) {
|
||
continue;
|
||
}
|
||
match out.iter_mut().find(|(m, _)| *m == id) {
|
||
Some((_, v)) => v.push(index),
|
||
None => out.push((id, vec![index])),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The picked gradient mesh points (see [`picked_mesh_points`]) when each of `ids` is a gradient
|
||
/// mesh with points picked: the Transparency panel's opacity is theirs then.
|
||
pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(NodeId, Vec<usize>)>> {
|
||
let v = picked_mesh_points(s, ids);
|
||
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
|
||
}
|
||
|
||
/// The colors of the picked points of `n` (see [`picked_mesh_points`]) when it is a gradient mesh
|
||
/// among `points`, in the order they were picked; empty otherwise.
|
||
fn picked_colors(n: &Node, points: &[(NodeId, Vec<usize>)]) -> Vec<Color> {
|
||
match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) {
|
||
(NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
/// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or
|
||
/// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are
|
||
/// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's
|
||
/// active item is a fill row or an object of a symbol instance is selected, and a mesh with
|
||
/// points picked counts even when other objects are selected with it.
|
||
pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec<usize>)> {
|
||
let Some(st) = self.active() else { return vec![] };
|
||
// Only gradient meshes have points to pick: a selection with no mesh and no container that
|
||
// could hold one skips reading the tool's options.
|
||
let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container());
|
||
if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) {
|
||
return vec![];
|
||
}
|
||
let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] };
|
||
if item != ItemTarget::Top {
|
||
return vec![];
|
||
}
|
||
let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] };
|
||
picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected))
|
||
}
|
||
|
||
/// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set`
|
||
/// without `ids` or `item`) fades instead of their objects: `None` unless every target is a
|
||
/// gradient mesh with points picked, and while the Appearance panel's active item takes the
|
||
/// opacity (outside opacity mask editing).
|
||
pub fn mesh_points_picked(&self) -> Option<Vec<(NodeId, Vec<usize>)>> {
|
||
let st = self.active()?;
|
||
let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?;
|
||
// Only gradient meshes have points to pick: other targets skip reading the tool's options.
|
||
if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) {
|
||
return None;
|
||
}
|
||
if st.doc.mask_edit.is_none() && self.appearance_item().is_some() {
|
||
return None;
|
||
}
|
||
picked_mesh_points_of(self, &ids)
|
||
}
|
||
}
|
||
|
||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||
pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||
if !paint.is_none() {
|
||
run_stroke_weight(style);
|
||
}
|
||
style.stroke = paint;
|
||
}
|
||
|
||
/// `paint` as it replaces `old`: a pattern swatch picked over a pattern keeps the placement that
|
||
/// pattern already had, so only the pattern changes (#1970).
|
||
pub(super) fn keep_pattern_xf(paint: &Paint, p: &Value, old: Option<&Paint>) -> Paint {
|
||
match (paint, old) {
|
||
(Paint::Pattern { pattern, xf }, Some(Paint::Pattern { xf: kept, .. })) if p.get("swatch").is_some() && *xf == Affine::IDENTITY => {
|
||
Paint::Pattern { pattern: pattern.clone(), xf: *kept }
|
||
}
|
||
_ => paint.clone(),
|
||
}
|
||
}
|
||
|
||
/// Use the existing 1 pt default when adding paint to an unpainted zero-width stroke.
|
||
pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>, paint: &Paint) {
|
||
if !paint.is_none()
|
||
&& let Some(stroke) = ap.stroke_at_mut(index)
|
||
&& stroke.paint.is_none()
|
||
&& stroke.width == 0.0
|
||
{
|
||
stroke.width = 1.0;
|
||
}
|
||
}
|
||
|
||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = paint_targets(s, p)?;
|
||
if !ids.is_empty() {
|
||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, "Swap Fill and Stroke", |d| {
|
||
for id in &ids {
|
||
let Some(n) = d.node_mut(*id) else { continue };
|
||
if let NodeKind::Text(t) = &mut n.kind {
|
||
for r in &mut t.runs {
|
||
let fill = std::mem::take(&mut r.style.fill);
|
||
r.style.fill = std::mem::take(&mut r.style.stroke);
|
||
set_run_stroke(&mut r.style, fill);
|
||
}
|
||
continue;
|
||
}
|
||
let f = n.appearance.fill_paint();
|
||
let st = n.appearance.stroke_paint();
|
||
n.appearance.set_fill(st);
|
||
restore_unpainted_stroke(&mut n.appearance, None, &f);
|
||
n.appearance.set_stroke(f);
|
||
}
|
||
// The swap writes the paint of the topmost fill and stroke, not their opacity.
|
||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false);
|
||
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
|
||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
|
||
})?;
|
||
}
|
||
if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 {
|
||
s.paint.stroke_width = 1.0;
|
||
}
|
||
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
|
||
ok()
|
||
}
|
||
|
||
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = paint_targets(s, p)?;
|
||
if !ids.is_empty() {
|
||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||
let before = s.doc()?.doc.clone();
|
||
super::overprint::edit_counted(s, "Default Fill and Stroke", |d| {
|
||
for id in &ids {
|
||
let Some(n) = d.node_mut(*id) else { continue };
|
||
match &mut n.kind {
|
||
NodeKind::Text(t) => {
|
||
for r in &mut t.runs {
|
||
r.style.fill = Paint::solid(Color::BLACK);
|
||
r.style.stroke = Paint::None;
|
||
}
|
||
}
|
||
_ => n.appearance = Appearance::default_art().into(),
|
||
}
|
||
}
|
||
let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false);
|
||
let labels = super::graph::capture_label_changes(&before, d, &ids, false);
|
||
Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels))
|
||
})?;
|
||
}
|
||
super::newart::reset(s);
|
||
ok()
|
||
}
|
||
|
||
/// Invert / Complement (`cmd`, undone as `label`): recolour the active proxy of the targets (or the
|
||
/// default when there are none) and remember the first target's new paint. The Appearance panel's
|
||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||
let stroke = stroke_param(s, p, cmd)?;
|
||
// An object of a dynamic symbol instance's art selected with Direct Selection: the solid color
|
||
// it shows is recolored for this instance only. A gradient or pattern is refused, and so is type
|
||
// whose characters differ in that paint, since one change holds one paint.
|
||
if let Some((id, path)) = selected_part_target(s, p, cmd)? {
|
||
let part = s.doc()?.selected_symbol_part();
|
||
let mut paint = part.as_ref().map_or(Paint::None, |n| proxy_paint(n, stroke, None));
|
||
let runs_differ = match part.as_ref().map(|n| &n.kind) {
|
||
Some(NodeKind::Text(t)) => t.runs.iter().any(|r| !same_in_proxy(if stroke { &r.style.stroke } else { &r.style.fill }, &paint)),
|
||
_ => false,
|
||
};
|
||
if runs_differ || !part_takes(&paint) {
|
||
return Err(super::brushsym::part_only());
|
||
}
|
||
let changed = super::colorcmds::map_paint(&mut paint, f);
|
||
if changed {
|
||
super::brushsym::edit_part(s, id, &path, label, |o| {
|
||
if stroke {
|
||
o.stroke = Some(paint.clone());
|
||
} else {
|
||
o.fill = Some(paint.clone());
|
||
}
|
||
})?;
|
||
s.remember_paint(&paint);
|
||
}
|
||
return Ok(json!({ "changed": changed as usize }));
|
||
}
|
||
let ids = match super::given_objects(s, p, cmd)? {
|
||
Some(ids) => ids,
|
||
None => super::edit::selected_roots(s)?,
|
||
};
|
||
if ids.is_empty() {
|
||
let def = if stroke { &mut s.paint.stroke } else { &mut s.paint.fill };
|
||
let changed = super::colorcmds::map_paint(def, f);
|
||
let shown = def.clone();
|
||
s.remember_paint(&shown);
|
||
return Ok(json!({ "changed": changed as usize }));
|
||
}
|
||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||
let (r, shown_item) = match item {
|
||
ItemTarget::Item { index, .. } => {
|
||
let ids = item.targets(s, p)?;
|
||
let mut changed = 0;
|
||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||
let paint =
|
||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||
Ok(())
|
||
})?;
|
||
(json!({ "changed": changed }), Some(index))
|
||
}
|
||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||
ItemTarget::Top => {
|
||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
|
||
}
|
||
};
|
||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||
s.remember_paint(&shown);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "transparency.set";
|
||
// An object of a dynamic symbol instance's art selected with Direct Selection: opacity and
|
||
// blend change that object for this instance only. Isolate, knockout and Appearance `item` /
|
||
// `target` stay refused here (they act on the instance or a stack row).
|
||
if p.get("ids").is_none()
|
||
&& p.get("id").is_none()
|
||
&& p.get("item").is_none()
|
||
&& let Some((id, path)) = super::brushsym::selected_part(s)?
|
||
{
|
||
for key in ["isolate", "knockout", "knockoutShape", "aligned", "target"] {
|
||
if p.get(key).is_some() {
|
||
return Err(super::brushsym::part_only());
|
||
}
|
||
}
|
||
if p.get("opacity").is_none() && p.get("blend").is_none() {
|
||
return Err(bad(C, "give opacity or blend"));
|
||
}
|
||
check_numbers(p, &["opacity"], C)?;
|
||
let opacity = match p.get("opacity") {
|
||
None => None,
|
||
Some(Value::Null) => Some(None),
|
||
Some(v) => Some(Some(v.as_f64().map(super::opacitymask::percent).ok_or_else(|| bad(C, "opacity must be a number"))?)),
|
||
};
|
||
let blend = match p.get("blend") {
|
||
None => None,
|
||
Some(Value::Null) => Some(None),
|
||
Some(b) => {
|
||
let name = b.as_str().ok_or_else(|| bad(C, "blend must be a mode name"))?;
|
||
Some(Some(BlendMode::parse(name).ok_or_else(|| bad(C, format!("unknown blend mode {b}")))?))
|
||
}
|
||
};
|
||
super::brushsym::edit_part(s, id, &path, "Transparency", |o| {
|
||
if let Some(op) = opacity {
|
||
o.opacity = op;
|
||
}
|
||
if let Some(b) = blend {
|
||
o.blend = b;
|
||
}
|
||
})?;
|
||
return ok();
|
||
}
|
||
super::opacitymask::refuse_symbol_part_unnamed(s, p)?;
|
||
let ids = super::opacitymask::transparency_targets(s, p)?;
|
||
if ids.is_empty() {
|
||
return Err(bad(C, "select objects or give ids"));
|
||
}
|
||
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
|
||
// item (a row of that art) doesn't stand for the masked object.
|
||
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
|
||
let item = if editing_mask && p.get("item").is_none() { ItemTarget::Top } else { item_target(s, p, C)? };
|
||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||
q.remove("item");
|
||
q.remove("id");
|
||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||
// The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the
|
||
// opacity themselves, so only the mesh around them fades (#4206, #4170, #4208).
|
||
if matches!(item, ItemTarget::Top)
|
||
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
|
||
&& let Some(points) = picked_mesh_points_of(s, &ids)
|
||
{
|
||
q.remove("opacity");
|
||
let opacity = f64::from(super::opacitymask::percent(o));
|
||
let items: Vec<Value> = points.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect();
|
||
s.execute("object.mesh.setPointColor", &json!({"items": items, "opacity": opacity}))?;
|
||
if q.keys().all(|k| k == "ids") {
|
||
return ok();
|
||
}
|
||
}
|
||
let ItemTarget::Item { index, explicit, aligned } = item else {
|
||
return s.execute("object.setProps", &Value::Object(q));
|
||
};
|
||
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
|
||
let item_ids = if editing_mask { ids } else { row_targets(s, p)? };
|
||
if explicit {
|
||
let d = &s.doc()?.doc;
|
||
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
|
||
return Err(bad(C, format!("no appearance item {index}")));
|
||
}
|
||
}
|
||
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
|
||
if let Some(b) = &blend
|
||
&& b.as_str().and_then(BlendMode::parse).is_none()
|
||
{
|
||
return Err(bad(C, format!("unknown blend mode {b}")));
|
||
}
|
||
if let Some(k) = q.get("knockout")
|
||
&& vectorcraft_doc::Knockout::from_value(k).is_none()
|
||
{
|
||
return Err(bad(C, format!("unknown knockout state {k}")));
|
||
}
|
||
if opacity.is_some() || blend.is_some() {
|
||
let ids: Vec<u64> = item_ids.iter().map(|id| id.0).collect();
|
||
s.execute("appearance.setItem", &json!({ "index": index, "ids": ids, "opacity": opacity, "blend": blend, "aligned": aligned }))?;
|
||
}
|
||
if q.keys().all(|k| k == "ids") {
|
||
return ok();
|
||
}
|
||
s.execute("object.setProps", &Value::Object(q))
|
||
}
|