Merge pull request #1425 from Silvertree2010/fix/h3041-node-set-opacity-1-gives-full-op

Treat a % suffix on node.set opacity as a percentage
This commit is contained in:
BFlat
2026-10-29 11:41:54 +07:00
committed by GitHub
5 changed files with 46 additions and 14 deletions
+7
View File
@@ -20,6 +20,13 @@ pub fn f64_of(v: &Value) -> Option<f64> {
}
}
/// A 0-1 fraction: a `%` suffix or a number above 1 is a percentage ("1%" is 0.01, 50 is 0.5).
pub fn fraction_of(v: &Value) -> Option<f64> {
let f = f64_of(v)?;
let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%'));
Some(if percent || f > 1.0 { f / 100.0 } else { f })
}
pub fn num(p: &Value, key: &str) -> Option<f64> {
get(p, key).and_then(f64_of)
}
+2 -2
View File
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::{
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
targets,
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
string, targets,
};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
+4 -4
View File
@@ -1,6 +1,6 @@
use super::{
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index,
paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, fraction_of, get, json, num, ok,
opt_index, paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
};
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -312,8 +312,8 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
slot.color_alpha = c.a;
}
}
if let Some(o) = num(p, "opacity") {
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
if let Some(o) = get(p, "opacity").and_then(fraction_of) {
slot.opacity = o.clamp(0.0, 1.0) as f32;
slot.color_alpha = 1.0;
}
if let Some(v) = boolean(p, "visible") {
+7 -8
View File
@@ -9,14 +9,14 @@ use figcraft_doc::{
use figcraft_geom::{Affine, Point};
use serde_json::{Map, Value, json};
use crate::params::{bool_of, color_of, f64_of, get, paints_of};
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), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
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, \
@@ -487,8 +487,7 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
"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 };
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" => {
@@ -888,20 +887,20 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
}
}
"innerRadius" | "ratio" => {
let r = f64_of(v).unwrap_or(0.38);
let r = if r > 1.0 { r / 100.0 } else { r };
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 = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
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 = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99),
_ => *inner = val.clamp(0.0, 0.99),
},
_ => return Err(bad(cmd, "arc applies to ellipses")),
}
@@ -475,3 +475,29 @@ fn page_navigation_skips_dividers() {
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
}
#[test]
fn opacity_percent_strings_always_divide_by_100() {
let mut s = Session::new();
let r = rect(&mut s, "R", 0.0, 0.0, None);
for (v, want) in [(json!("1%"), 0.01), (json!("0.5%"), 0.005), (json!("50%"), 0.5), (json!(1), 1.0), (json!(50), 0.5), (json!(0.25), 0.25)] {
ex(&mut s, "node.set", json!({"id": r, "props": {"opacity": v.clone()}}));
let o = s.doc().unwrap().node(r).unwrap().opacity;
assert!((o - want).abs() < 1e-6, "opacity {v} gave {o}, want {want}");
}
}
#[test]
fn fill_opacity_star_ratio_and_arc_ratio_read_a_percent_suffix() {
let mut s = Session::new();
let r = rect(&mut s, "R", 0.0, 0.0, None);
ex(&mut s, "fill.set", json!({"ids": [r], "color": "#ff0000", "opacity": "1%"}));
let fill = s.doc().unwrap().node(r).unwrap().fills[0].opacity;
assert!((fill - 0.01).abs() < 1e-6, "fill opacity \"1%\" gave {fill}");
let star = id(&ex(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"innerRadius": "1%"}})));
let figcraft_doc::NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
assert!((inner_ratio - 0.01).abs() < 1e-9, "innerRadius \"1%\" gave {inner_ratio}");
let arc = id(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"arcInner": "1%"}})));
let figcraft_doc::NodeKind::Ellipse { inner, .. } = s.doc().unwrap().node(arc).unwrap().kind else { panic!("ellipse") };
assert!((inner - 0.01).abs() < 1e-9, "arcInner \"1%\" gave {inner}");
}