//! Property editing: `node.set` and the shorthand keys every surface uses. use figcraft_color::{Color, Paint}; use figcraft_doc::{ AutoLayout, Brush, ChildLayout, DecorationStyle, DevStatus, Document, Effect, GridTrack, LayoutMode, LetterSpacing, LineHeight, ListStyle, MAX_LIST_INDENT, MaskType, Node, NodeId, NodeKind, Sizing, StrokeCap, StrokeProps, TextAutoResize, TextCase, TextData, TextDecoration, WidthPoint, WidthPreset, WidthProfile, style_weight, valid_axis_tag, valid_feature_tag, }; use figcraft_geom::{Affine, Point}; use serde_json::{Map, Value, json}; use crate::params::{bool_of, color_of, f64_of, fraction_of, get, paints_of}; 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 or \"50%\"), 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> { let effects: Vec = 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> { 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) -> 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(v: &Value) -> Option { 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 { 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 { 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().filter(|f: &f64| f.is_finite()).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 { match v { Value::String(s) if s.trim().ends_with('%') => { s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).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 = a.iter().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> { let b = match v { Value::Null => return Some(None), Value::String(id) => Brush::new(id.as_str()), Value::Object(_) => serde_json::from_value::(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> { 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::>>()?; 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 { 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::().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) { 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) { 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(()) } /// The `geometry` properties of `apply`; `false` when `k` is not one of them. fn apply_geometry( cmd: &str, doc: &mut Document, id: NodeId, props: &Map, k: &str, v: &Value, path_offset: &mut (f64, f64), ) -> Result { 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.fix_size(is_w, !is_w); } 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); } _ => return Ok(false), } Ok(true) } /// The `look` properties of `apply`; `false` when `k` is not one of them. fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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 = fraction_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; 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 = 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))?, _ => return Ok(false), } Ok(true) } /// The `corners` properties of `apply`; `false` when `k` is not one of them. fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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"))?; } _ => return Ok(false), } Ok(true) } /// The `layout` properties of `apply`; `false` when `k` is not one of them. fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.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).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.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), _ => return Ok(false), } Ok(true) } /// The `sizing` properties of `apply`; `false` when `k` is not one of them. fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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::>>().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"))?; } } _ => return Ok(false), } Ok(true) } /// The `meta` properties of `apply`; `false` when `k` is not one of them. fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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), _ => return Ok(false), } Ok(true) } /// The `text` properties of `apply`; `false` when `k` is not one of them. fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map, k: &str, v: &Value) -> Result { match k { "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" => {} _ => return Ok(false), } Ok(true) } /// The `shape` properties of `apply`; `false` when `k` is not one of them. fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { match k { "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 = fraction_of(v).unwrap_or(0.38); 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 = if k == "arcInner" { fraction_of(v) } else { f64_of(v) }; let val = val.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 = 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()); } } } _ => return Ok(false), } Ok(true) } /// 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) -> 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(); if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)? || apply_look(cmd, doc, id, k, v)? || apply_corners(cmd, doc, id, k, v)? || apply_layout(cmd, doc, id, k, v)? || apply_sizing(cmd, doc, id, k, v)? || apply_meta(cmd, doc, id, k, v)? || apply_text(cmd, doc, id, props, k, v)? || apply_shape(cmd, doc, id, k, v)? { continue; } unknown.push(k.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) -> 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> { 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 { match v { Value::String(s) => Some(s.clone()), Value::Number(n) => Some(n.to_string()), _ => None, } } fn range_of(props: &Map, 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)>> { 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)>> { 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, _ if t.truncates() => TextAutoResize::Truncate, _ => 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 } #[cfg(test)] mod tests { use super::*; #[test] fn non_finite_percent_strings_are_rejected() { for s in ["NaN%", "inf%", "-inf%"] { assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}"); assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}"); } assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0)); } #[test] fn four_rejects_non_numeric_entries() { assert_eq!(four(&json!([10, "abc"])), None); assert_eq!(four(&json!([10, "abc", null, 20])), None); assert_eq!(four(&json!([10, 20, 30])), None); assert_eq!(four(&json!([1, 2, 3, 4])), Some([1.0, 2.0, 3.0, 4.0])); assert_eq!(four(&json!([1, 2])), Some([1.0, 2.0, 1.0, 2.0])); } }