Files
figcraft/crates/engine/src/cmd/props.rs
T

1280 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Property editing: `node.set` and the shorthand keys every surface uses.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, Point};
use serde_json::{Map, Value, json};
use crate::params::*;
use crate::{Result, bad};
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), sizingH / sizingV (fixed|hug|fill), \
absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
(widthAndHeight|height|none|truncate), textDecoration, textDecorationStyle (solid|dotted|wavy), textDecorationThickness / textDecorationOffset \
(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
pub(crate) fn normalize_line_endings(s: &str) -> String {
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
}
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(
m.iter()
.map(|(k, v)| {
let mut out = String::new();
for c in k.chars() {
if c.is_ascii_uppercase() {
out.push('_');
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
// Paint/effect type tags stay as they are.
let key = if matches!(k.as_str(), "type" | "kind") { k.clone() } else { out };
(key, if matches!(k.as_str(), "type" | "kind") { v.clone() } else { snake(v) })
})
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(snake).collect()),
other => other.clone(),
}
}
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
/// so a shadow given `offsetY` kept the default offset.
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
}
Ok(effects)
}
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
let Some(v) = get(p, key) else { return Ok(None) };
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
Ok(Some(effect))
}
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
let Some(fields) = given.as_object() else { return Ok(()) };
let parsed = parse(given);
let ignored: Vec<&str> = fields
.iter()
.filter(|(_, v)| !v.is_null())
.filter(|(k, _)| {
let mut probe = fields.clone();
probe.insert((*k).clone(), Value::Null);
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
})
.map(|(k, _)| k.as_str())
.collect();
if ignored.is_empty() {
return Ok(());
}
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
}
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
let norm: String = {
let mut out = String::new();
let mut up = false;
for (i, ch) in s.trim().chars().enumerate() {
if ch == '_' || ch == '-' || ch == ' ' {
up = true;
continue;
}
if i == 0 {
out.extend(ch.to_lowercase());
} else if up {
out.extend(ch.to_uppercase());
up = false;
} else if s.chars().all(|c| !c.is_lowercase() || !c.is_alphabetic()) {
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
out
};
serde_json::from_value(Value::String(norm)).ok()
}
pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
match v.as_str()?.to_ascii_lowercase().as_str() {
"fixed" => Some(Sizing::Fixed),
"hug" | "auto" | "hug contents" => Some(Sizing::Hug),
"fill" | "fill container" | "stretch" => Some(Sizing::Fill),
_ => None,
}
}
pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
match v {
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LineHeight::Pixels),
}
}
pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
match v {
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LetterSpacing::Pixels),
}
}
fn four(v: &Value) -> Option<[f64; 4]> {
match v {
Value::Array(a) => {
let f: Vec<f64> = a.iter().filter_map(f64_of).collect();
match f.as_slice() {
[x] => Some([*x; 4]),
[a, b] => Some([*a, *b, *a, *b]),
[a, b, c, d] => Some([*a, *b, *c, *d]),
_ => None,
}
}
other => f64_of(other).map(|x| [x; 4]),
}
}
const BOOL_OP_HELP: &str = "booleanOperation / operandOperation: union|subtract|intersect|exclude (null: the group's, for operands)";
/// An operand's own boolean operation (`null`: its group's).
fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Result<()> {
if crate::booleans::group_of(doc, id).is_none() {
return Err(bad(cmd, "not a boolean group or a layer in one"));
}
doc.get_mut(id)?.bool_op = if v.is_null() { None } else { Some(enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?) };
Ok(())
}
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)";
pub(crate) const BRUSH_HELP: &str = "brush: a preset (taper|ink|ribbon|bristle: stretched; dots|stars|confetti|leaves: scattered), {preset, spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?}, or null (a plain stroke)";
/// A brush from a preset id, `{preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}`
/// or null. `Some(None)` = no brush; `None` = not a brush (or an unknown preset).
pub fn brush_of(v: &Value) -> Option<Option<Brush>> {
let b = match v {
Value::Null => return Some(None),
Value::String(id) => Brush::new(id.as_str()),
Value::Object(_) => serde_json::from_value::<Brush>(snake(v)).ok()?,
_ => return None,
};
figcraft_pathops::brush::preset(&b.preset).map(|_| Some(b.sanitized()))
}
/// A width profile from a preset name, points (`[[t, scale] | [t, left, right], ...]` or
/// `[{t, scale} | {t, left, right}]`), `{preset}` / `{points}`, or null. `Some(None)` = uniform;
/// `None` = not a profile.
pub fn width_profile_of(v: &Value) -> Option<Option<WidthProfile>> {
match v {
Value::Null => Some(None),
Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile),
Value::Array(a) => {
let num = |x: Option<&Value>| x.and_then(f64_of);
let points = a
.iter()
.map(|p| match p {
Value::Array(xy) => match (num(xy.first()), num(xy.get(1)), num(xy.get(2))) {
(Some(t), Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
(Some(t), Some(s), None) => Some(WidthPoint::new(t, s)),
_ => None,
},
Value::Object(o) => {
let t = num(o.get("t"))?;
match (num(o.get("left")), num(o.get("right"))) {
(Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
_ => Some(WidthPoint::new(t, num(o.get("scale").or_else(|| o.get("width")))?)),
}
}
_ => None,
})
.collect::<Option<Vec<_>>>()?;
Some(Some(WidthProfile { points }.sanitized()).filter(|p| !p.points.is_empty()))
}
Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of),
_ => None,
}
}
/// A width point as [`width_profile_of`] reads it back: `[t, scale]`, or `[t, left, right]` when
/// its sides differ.
pub fn width_point_json(p: &WidthPoint) -> Value {
if p.symmetric() {
json!([p.t, p.scale])
} else {
let (l, r) = p.sides();
json!([p.t, l, r])
}
}
/// A profile's points as [`width_profile_of`] reads them back (`null` without points).
pub fn width_points_json(profile: Option<&WidthProfile>) -> Value {
match profile.filter(|p| !p.points.is_empty()) {
Some(p) => Value::Array(p.points.iter().map(width_point_json).collect()),
None => Value::Null,
}
}
const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
/// or `{sizing, value}`.
pub fn track_of(v: &Value) -> Option<GridTrack> {
match v {
Value::Number(_) => f64_of(v).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
Value::String(s) => {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"auto" | "hug" => Some(GridTrack::hug()),
"fill" => Some(GridTrack::fill(1.0)),
_ => match s.strip_suffix("fr") {
Some(fr) => fr.trim().parse::<f64>().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))),
None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
},
}
}
Value::Object(o) => {
let s = o.get("sizing").or_else(|| o.get("type")).and_then(sizing)?;
let val = o.get("value").or_else(|| o.get("size")).and_then(f64_of);
Some(match s {
Sizing::Fixed => GridTrack::fixed(val?.clamp(0.0, 1e6)),
Sizing::Hug => GridTrack::hug(),
Sizing::Fill => GridTrack::fill(val.unwrap_or(1.0).clamp(0.0, 1e4)),
})
}
_ => None,
}
}
/// Set one min/max limit (`None` removes it), keeping min ≤ max by moving the other limit.
pub fn set_limit(cl: &mut ChildLayout, key: &str, val: Option<f64>) {
let (target, other, is_min) = match key {
"minWidth" => (&mut cl.min_w, &mut cl.max_w, true),
"maxWidth" => (&mut cl.max_w, &mut cl.min_w, false),
"minHeight" => (&mut cl.min_h, &mut cl.max_h, true),
_ => (&mut cl.max_h, &mut cl.min_h, false),
};
*target = val;
if let (Some(v), Some(o)) = (val, *other)
&& (is_min && o < v || !is_min && o > v)
{
*other = Some(v);
}
}
fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> {
let id = n.id;
n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text")))
}
fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
}
/// The keys that set a frame's auto layout (through [`layout_mut`]).
pub const LAYOUT_KEYS: &[&str] = &[
"gap",
"itemSpacing",
"columnGap",
"crossGap",
"counterAxisSpacing",
"rowGap",
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"paddingHorizontal",
"paddingVertical",
"mainAlign",
"primaryAxisAlignItems",
"crossAlign",
"counterAxisAlignItems",
"contentAlign",
"counterAxisAlignContent",
"wrap",
"gridColumns",
"gridRows",
"strokesIncluded",
"reverseZ",
"columnTracks",
"gridColumnTracks",
"rowTracks",
"gridRowTracks",
];
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
/// re-applying them would bring it back.
pub fn drop_layout_bindings(n: &mut Node) {
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
}
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
/// (`node.set` on several layers skips them instead, see `edit::set`).
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
if !n.kind.is_frame_like() {
return Err(bad(
cmd,
format!(
"`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)",
n.id,
n.name,
n.kind.type_name()
),
));
}
Ok(ensure_layout(n))
}
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
}
}
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
/// links after the values so a link wins over values its style sets, variable bindings last so
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
}
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
Ok(())
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
let mut path_offset = (0.0, 0.0);
for (k, v) in keys {
let k = k.as_str();
match k {
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
// its auto layout axis.
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
// X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
// frame it is in (groups have no space of their own).
let n = doc.get(id)?;
let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children()));
let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g));
let c = (space * n.transform).as_coeffs();
let (mut tx, mut ty) = (c[4], c[5]);
if k == "x" {
tx = val;
} else {
ty = val;
}
doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
}
"width" | "height" | "w" | "h" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let is_w = k.starts_with('w');
// With the proportions locked (the Layout panel's link) and only one side set, the
// other side follows the node's ratio; both sides in one `node.set` stay as typed.
let (nw, nh) = {
let n = doc.get(id)?;
let (ow, oh) = (n.width, n.height);
let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") };
let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9;
let derived = if is_w { oh * val / ow } else { ow * val / oh };
match (is_w, linked && derived.is_finite()) {
(true, true) => (val, derived),
(false, true) => (derived, val),
(true, false) => (val, oh),
(false, false) => (ow, val),
}
};
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if is_w {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
}
fix_axis(doc, id, is_w)?;
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
"name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?,
"visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
"locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
"opacity" => {
let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
let o = if o > 1.0 { o / 100.0 } else { o };
doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
}
"blend" | "blendMode" => {
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
if paint_none(v) {
set_paints(doc.get_mut(id)?, true, vec![]);
} else {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
}
}
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
if paint_none(v) {
set_paints(doc.get_mut(id)?, false, vec![]);
} else {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
}
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
"strokeSides" => {
doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) }
}
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => {
let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0);
let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
let st = &mut doc.get_mut(id)?.stroke;
// One side set on its own turns on individual strokes, the others at the weight.
let mut sides = st.sides.unwrap_or([st.weight; 4]);
if let Some(slot) = sides.get_mut(i) {
*slot = w;
}
st.sides = Some(sides);
}
"strokeCap" => {
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
let n = doc.get_mut(id)?;
n.stroke.cap_start = c;
n.stroke.cap_end = c;
}
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
"miterAngle" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?;
doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
}
"dashes" => {
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
doc.get_mut(id)?.stroke.dashes = d;
}
"widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
"brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?,
"cornerRadius" => {
let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0));
let n = doc.get_mut(id)?;
n.corners.radii = r;
// A vector's corner radius rounds every point (edit mode then sets them one by one).
if let NodeKind::Vector { network } = &mut n.kind {
network.vertices.iter_mut().for_each(|p| p.radius = r[0]);
crate::vectoredit::refit(doc, id)?;
}
}
"topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => {
let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0);
let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) {
*slot = r;
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"devStatus" => {
let st = if v.is_null() {
Some(DevStatus::None)
} else {
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
};
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
}
let n = doc.get_mut(id)?;
n.dev_status = st;
// Marking a layer (again) starts from its current contents.
n.dev_changed = false;
}
"maskType" => {
let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?;
}
"layout" | "layoutMode" => {
let n = doc.get_mut(id)?;
if !n.kind.is_frame_like() {
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) if s.eq_ignore_ascii_case("none") => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")),
};
ensure_layout(n).mode = mode;
}
Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?),
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
}
}
"gap" | "itemSpacing" | "columnGap" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"crossGap" | "counterAxisSpacing" | "rowGap" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
*slot = val;
}
}
"paddingHorizontal" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[1] = val;
l.padding[3] = val;
}
"paddingVertical" => {
let val = f64_of(v).unwrap_or(0.0).max(0.0);
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
l.padding[0] = val;
l.padding[2] = val;
}
"mainAlign" | "primaryAxisAlignItems" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
}
"crossAlign" | "counterAxisAlignItems" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"contentAlign" | "counterAxisAlignContent" => {
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
}
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
"gridColumns" | "gridRows" => {
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
// Tracks past the new count go away (new tracks are 1fr).
let (n, tracks) =
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
*n = count;
tracks.truncate(count as usize);
}
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, true)?;
}
"sizingV" | "layoutSizingVertical" => {
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, false)?;
}
"absolute" | "ignoreAutoLayout" => {
let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?;
let cl = &mut doc.get_mut(id)?.child_layout;
cl.absolute = on;
// An absolute child can't fill: it keeps its current size.
if on {
for s in [&mut cl.sizing_h, &mut cl.sizing_v] {
if *s == Sizing::Fill {
*s = Sizing::Fixed;
}
}
}
}
"minWidth" | "maxWidth" | "minHeight" | "maxHeight" => {
let val = match v {
Value::Null => None,
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)),
};
set_limit(&mut doc.get_mut(id)?.child_layout, k, val);
}
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
if k.contains("olumn") {
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
l.column_tracks = tracks;
} else {
l.grid_rows = (tracks.len() as u32).clamp(1, 1000);
l.row_tracks = tracks;
}
}
"gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => {
let val = match v {
Value::Null => None,
Value::String(s) if s.eq_ignore_ascii_case("auto") => None,
other => {
Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0)
as u32)
}
};
let cl = &mut doc.get_mut(id)?.child_layout;
if k.starts_with("gridColumn") {
cl.grid_column = val;
} else {
cl.grid_row = val;
}
}
"gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => {
let val = match v {
Value::Null => None,
Value::String(s) if s.eq_ignore_ascii_case("auto") => None,
other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?),
};
let cl = &mut doc.get_mut(id)?.child_layout;
if k.contains('H') {
cl.grid_align_h = val;
} else {
cl.grid_align_v = val;
}
}
"gridColumnSpan" | "gridRowSpan" => {
let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
let cl = &mut doc.get_mut(id)?.child_layout;
if k == "gridColumnSpan" {
cl.grid_column_span = span;
} else {
cl.grid_row_span = span;
}
}
"constraints" => {
let n = doc.get_mut(id)?;
if let Some(h) = v.get("horizontal") {
n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?;
}
if let Some(vv) = v.get("vertical") {
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
}
}
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?,
"exports" | "exportSettings" => {
doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?
}
"interactions" => {
doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?
}
"overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?,
"scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?,
"constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false),
"characters" | "text" => {
let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?;
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &normalize_line_endings(&s));
n.overrides.insert("characters".into());
}
"fontFamily"
| "fontStyle"
| "fontWeight"
| "fontSize"
| "lineHeight"
| "letterSpacing"
| "textDecoration"
| "textCase"
| "hyperlink"
| "textFills"
| "fontVariations"
| "variationSettings"
| "textDecorationStyle"
| "textDecorationThickness"
| "textDecorationOffset"
| "textDecorationSkipInk"
| "textDecorationColor" => {
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let (start, end) = range_of(props, t.char_count());
apply_text_style(cmd, t, start, end, k, v)?;
}
"textAlign" | "textAlignHorizontal" => {
text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))?
}
"textAlignVertical" => {
text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))?
}
"textAutoResize" => {
text_mut(cmd, doc.get_mut(id)?)?.auto_resize =
enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))?
}
"paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0),
"paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0),
"listStyle" | "indentation" => {
let t = text_mut(cmd, doc.get_mut(id)?)?;
let (start, end) = range_of(props, t.char_count());
if k == "listStyle" {
let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?;
t.set_para_lists(start, end, |p| p.list = l);
} else {
let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?;
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
}
}
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
"hangingPunctuation" | "hangingList" => {
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
*if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on;
}
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
"features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => {
let f = features_of(cmd, v)?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
t.features.clear();
for (k, on) in f {
if let Some(on) = on {
t.features.insert(k, on);
}
}
}
"start" | "end" => {}
"pointCount" => {
let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round();
match &mut doc.get_mut(id)?.kind {
// Stars have 3 to 60 points; polygons keep the geometry's 120 (silent).
NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32,
NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32,
_ => return Err(bad(cmd, "pointCount applies to polygons and stars")),
}
}
"innerRadius" | "ratio" => {
let r = f64_of(v).unwrap_or(0.38);
let r = if r > 1.0 { r / 100.0 } else { r };
match &mut doc.get_mut(id)?.kind {
NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0),
_ => return Err(bad(cmd, "innerRadius applies to stars")),
}
}
"arcStart" | "arcSweep" | "arcInner" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Ellipse { start, sweep, inner } => match k {
"arcStart" => *start = val.to_radians(),
"arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(),
_ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
},
_ => return Err(bad(cmd, "arc applies to ellipses")),
}
}
"description" => match &mut doc.get_mut(id)?.kind {
NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(),
_ => return Err(bad(cmd, "description applies to components")),
},
"pluginData" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (pk, pv) in o {
if pv.is_null() {
n.plugin_data.remove(pk);
} else {
n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string()));
}
}
}
}
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => {
let sid = string_val(v);
crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?;
}
"boundVariables" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (field, var) in o {
match var.as_str() {
Some(vid) => {
if n.bound.get(field).map(String::as_str) != Some(vid) {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), vid.to_string());
}
None => {
if n.bound.remove(field).is_some() {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
}
}
"variableModes" => {
let n = doc.get_mut(id)?;
if let Some(o) = v.as_object() {
for (coll, mode) in o {
match mode.as_str() {
Some(m) => {
n.var_modes.insert(coll.clone(), m.to_string());
}
None => {
n.var_modes.remove(coll);
}
}
}
}
}
"booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?,
"booleanOperation" => {
if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind {
*op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?;
}
}
"operandOperation" => set_operand_op(doc, id, v, cmd)?,
"sectionContentsHidden" => {
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
*contents_hidden = v.as_bool().unwrap_or(false);
}
}
"background" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?;
match &mut doc.get_mut(id)?.kind {
NodeKind::Page { background, .. } => *background = c,
_ => return Err(bad(cmd, "background applies to pages")),
}
}
"componentProperties" => {
if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind
&& let Some(o) = v.as_object()
{
for (pk, pv) in o {
p.insert(pk.clone(), pv.clone());
}
}
}
other => unknown.push(other.to_string()),
}
}
if !unknown.is_empty() {
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
}
// Inside an instance, record which property groups were overridden (an instance's own
// resizing, export settings and interactions are its own, not overrides).
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
let n = doc.get_mut(id)?;
if n.source.is_some() {
for g in
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
{
n.overrides.insert(g.to_string());
}
}
Ok(())
}
/// Replace vector `id`'s geometry with SVG path data `v`, as `node.create` reads it: `width`/`height`
/// in the same call scale it, and the path's own box goes at the layer's origin, which keeps its
/// place, rotation and everything else. Returns where the path's own box was, which `x`/`y` in the
/// same call add (so `{path, x: 0, y: 0}` places it as `node.create` does). Layers inside an
/// instance are refused: their geometry comes from the main component, and a sync would undo it.
fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<String, Value>) -> Result<(f64, f64)> {
let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?;
let n = doc.get(id)?;
if !matches!(n.kind, NodeKind::Vector { .. }) {
return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name())));
}
if crate::components::inside_instance(doc, id) {
return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name)));
}
let size = |long: &str, short: &str| -> Result<Option<f64>> {
props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose()
};
let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?;
let n = doc.get_mut(id)?;
if let NodeKind::Vector { network } = &mut n.kind {
*network = shape.network;
}
n.width = shape.width;
n.height = shape.height;
Ok(shape.offset)
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle" | "widthProfile"
| "brush" | "strokeBrush" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
"grids" | "layoutGrids" | "gridStyle" => "grids",
"exports" | "exportSettings" => "exports",
"interactions" => "interactions",
k if k.starts_with("font") || k.starts_with("text") || k.starts_with("letter") || k.starts_with("line") || k.starts_with("paragraph") => {
"textStyle"
}
_ => "other",
}
}
fn string_val(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn range_of(props: &Map<String, Value>, len: usize) -> (usize, usize) {
let s = props.get("start").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(0);
let e = props.get("end").and_then(f64_of).map(|v| v.max(0.0) as usize).unwrap_or(len);
(s.min(len), e.min(len))
}
/// Set one text style property on chars `start..end`.
pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k: &str, v: &Value) -> Result<()> {
match k {
"fontFamily" => {
let fam = string_val(v).ok_or_else(|| bad(cmd, "`fontFamily` must be a string"))?;
// Keep the style if the new family has it, else the nearest.
let styles = figcraft_text::family_styles(&fam);
t.restyle(start, end, |s| {
s.font_family = fam.clone();
if !styles.is_empty() && !styles.contains(&s.font_style) {
let want = s.weight();
if let Some(best) = styles.iter().min_by_key(|st| (i32::from(style_weight(st)) - i32::from(want)).abs()) {
s.font_style = best.clone();
}
}
});
}
"fontStyle" => {
let st = string_val(v).ok_or_else(|| bad(cmd, "`fontStyle` must be a string"))?;
t.restyle(start, end, |s| s.set_font_style(st.clone()));
}
"fontWeight" => {
let w = f64_of(v).ok_or_else(|| bad(cmd, "`fontWeight` must be a number"))?;
let name = weight_name(w.clamp(1.0, 1000.0) as u16);
t.restyle(start, end, |s| {
let italic = s.italic();
s.set_font_style(styled_name(name, italic));
});
}
"fontVariations" | "variationSettings" => {
// Replaces the custom axis values (null or {} = back to what the style name implies).
let vars = variations_of(cmd, v)?;
t.restyle(start, end, |s| {
s.variations.clear();
for (k, val) in &vars {
if let Some(val) = val {
s.variations.insert(k.clone(), *val);
}
}
});
}
"fontSize" => {
let sz = f64_of(v).ok_or_else(|| bad(cmd, "`fontSize` must be a number"))?.clamp(1.0, 5000.0);
t.restyle(start, end, |s| s.font_size = sz);
}
"lineHeight" => {
let lh = line_height(v).ok_or_else(|| bad(cmd, "lineHeight: auto | px | \"150%\""))?;
t.restyle(start, end, |s| s.line_height = lh);
}
"letterSpacing" => {
let ls = letter_spacing(v).ok_or_else(|| bad(cmd, "letterSpacing: px | \"2%\""))?;
t.restyle(start, end, |s| s.letter_spacing = ls);
}
"textDecoration" => {
let d: TextDecoration = enum_of(v).ok_or_else(|| bad(cmd, "textDecoration: none|underline|strikethrough"))?;
t.restyle(start, end, |s| s.decoration = d);
}
"textDecorationStyle" => {
let d: DecorationStyle = enum_of(v).ok_or_else(|| bad(cmd, "textDecorationStyle: solid|dotted|wavy"))?;
t.restyle(start, end, |s| s.set_look(|l| l.style = d));
}
"textDecorationThickness" | "textDecorationOffset" => {
let m = line_height(v)
.filter(|m| !matches!(m, LineHeight::Pixels(x) | LineHeight::Percent(x) if !x.is_finite() || x.abs() > 10_000.0))
.ok_or_else(|| bad(cmd, format!("{k}: auto | px | \"10%\"")))?;
let thickness = k == "textDecorationThickness";
t.restyle(start, end, |s| s.set_look(|l| *if thickness { &mut l.thickness } else { &mut l.offset } = m));
}
"textDecorationSkipInk" => {
let on = v.as_bool().ok_or_else(|| bad(cmd, "textDecorationSkipInk must be true or false"))?;
t.restyle(start, end, |s| s.set_look(|l| l.skip_ink = on));
}
"textDecorationColor" => {
let auto = v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("auto"));
let c = if auto { None } else { Some(color_of(v).ok_or_else(|| bad(cmd, "textDecorationColor: a colour, or auto"))?) };
t.restyle(start, end, |s| s.set_look(|l| l.color = c));
}
"textCase" => {
let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title|smallCaps"))?;
t.restyle(start, end, |s| s.case = c);
}
"hyperlink" => {
let l = string_val(v);
t.restyle(start, end, |s| s.link = l.clone());
}
"textFills" => {
let p = if v.is_null() { None } else { Some(paints_of(v).ok_or_else(|| bad(cmd, "textFills: paints"))?) };
t.restyle(start, end, |s| s.fills = p.clone());
}
_ => {}
}
Ok(())
}
/// OpenType feature toggles from `{tag: bool | null}` (null = back to the font's default).
pub fn features_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<bool>)>> {
let Value::Object(m) = v else { return Err(bad(cmd, "features: {tag: bool}, e.g. {\"liga\": false}")) };
let mut out = Vec::with_capacity(m.len());
for (k, on) in m {
if !valid_feature_tag(k) {
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType feature tag")));
}
let on = match on {
Value::Null => None,
Value::Bool(b) => Some(*b),
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("feature `{k}` must be true, false or null")))? != 0.0),
};
out.push((k.clone(), on));
}
Ok(out)
}
/// Variable font axis values from `{tag: number | null}` (null = back to what the style implies).
/// `null` alone means "no custom values".
pub fn variations_of(cmd: &str, v: &Value) -> Result<Vec<(String, Option<f64>)>> {
let m = match v {
Value::Null => return Ok(Vec::new()),
Value::Object(m) => m,
_ => return Err(bad(cmd, "variations: {axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}")),
};
let mut out = Vec::with_capacity(m.len());
for (k, val) in m {
if !valid_axis_tag(k) {
return Err(bad(cmd, format!("`{k}` is not a 4-character OpenType axis tag")));
}
let val = match val {
Value::Null => None,
other => Some(f64_of(other).filter(|x| x.abs() <= 1e6).ok_or_else(|| bad(cmd, format!("axis `{k}` must be a number or null")))?),
};
out.push((k.clone(), val));
}
Ok(out)
}
/// "Bold" + italic → "Bold Italic"; "Regular" + italic → "Italic".
pub fn styled_name(weight: &str, italic: bool) -> String {
match (italic, weight) {
(false, w) => w.to_string(),
(true, "Regular") => "Italic".into(),
(true, w) => format!("{w} Italic"),
}
}
pub fn weight_name(w: u16) -> &'static str {
match w {
0..=149 => "Thin",
150..=249 => "Extra Light",
250..=349 => "Light",
350..=449 => "Regular",
450..=549 => "Medium",
550..=649 => "Semi Bold",
650..=749 => "Bold",
750..=849 => "Extra Bold",
_ => "Black",
}
}
fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal: bool) -> Result<()> {
let parent = doc.get(id)?.parent.filter(|p| doc.node(*p).is_some_and(|p| p.layout.is_some()));
match s {
Sizing::Fill if parent.is_none() => return Err(bad(cmd, "Fill needs an auto layout parent")),
Sizing::Hug if doc.get(id)?.layout.is_none() && !matches!(doc.get(id)?.kind, NodeKind::Text(_)) => {
return Err(bad(cmd, "Hug needs auto layout or text"));
}
_ => {}
}
// A child can't fill an axis its parent hugs: the parent becomes fixed at its current size.
// (The other way round, a parent that starts to hug fixes the children that filled it: see
// `figcraft_layout::coherent_sizing`, applied after every edit.)
if s == Sizing::Fill
&& let Some(p) = parent
{
let cl = &mut doc.get_mut(p)?.child_layout;
let ps = if horizontal { &mut cl.sizing_h } else { &mut cl.sizing_v };
if *ps == Sizing::Hug {
*ps = Sizing::Fixed;
}
}
// Start from the sizing the layer has in effect (an auto-width text hugs both axes), so the
// other axis keeps its meaning: an auto-width text set to fill its width wraps (auto height).
figcraft_layout::coherent_sizing(doc, id);
let n = doc.get_mut(id)?;
if horizontal {
n.child_layout.sizing_h = s;
} else {
n.child_layout.sizing_v = s;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
}
Ok(())
}
/// Resize a node's box from its top-left, applying constraints / group scaling to children.
/// The page transforms of `id`'s children, to put back with [`restore_worlds`] once a container
/// whose contents stay where they are on the page (a section; a frame resized with Cmd/Ctrl held)
/// has moved or changed size.
pub fn children_worlds(doc: &Document, id: NodeId) -> Vec<(NodeId, Affine)> {
doc.node(id).map(|n| n.children.iter().map(|c| (*c, doc.world_transform(*c))).collect()).unwrap_or_default()
}
pub fn restore_worlds(doc: &mut Document, worlds: &[(NodeId, Affine)]) -> Result<()> {
for (c, w) in worlds {
doc.set_world_transform(*c, *w)?;
}
Ok(())
}
pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()> {
let n = doc.get(id)?;
let (ow, oh) = (n.width, n.height);
let group = n.kind.bounds_follow_children();
let section = matches!(n.kind, NodeKind::Section { .. });
figcraft_layout::set_size(doc, id, w, h);
if section {
// Sections have no constraints: what's in them stays as it is.
} else if group {
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
figcraft_layout::scale_group_children(doc, id, kx, ky);
} else {
// In an auto layout frame this moves only the children that ignore auto layout.
figcraft_layout::apply_constraints(doc, id, ow, oh);
}
Ok(())
}
/// The design-panel view of a node (used by the UI and inspect).
pub fn describe(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let c = doc.frame_relative(id).as_coeffs();
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
"x": c[4], "y": c[5],
"width": n.width, "height": n.height,
"rotation": figcraft_geom::round_to(figcraft_geom::rotation_degrees(n.transform), 3),
"visible": n.visible, "locked": n.locked,
"opacity": n.opacity, "blend": n.blend,
});
if let Some(b) = doc.world_bounds(id) {
v["absoluteBounds"] = json!({"x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()});
}
let _ = Point::ZERO;
let _ = Color::BLACK;
v
}