Files
photocraft/crates/engine/src/layer_style.rs
T
BFlatandClaude f82a55f056 Fix #1543: oversized Satin and Bevel & Emboss sizes render instead of panicking (#1563)
A Satin size or a Bevel & Emboss size or softness of 1e308 reached the
box blur unclamped: the kernel radius saturated to ~2^62 taps and the
render panicked with "capacity overflow"; 1e6 ran for minutes. Drop
Shadow survived because its reach is clamped in margin(). The blur width
is now capped at MAX_REACH (and NaN is 1) in one place, box_width, which
both the CPU tent and the GPU's tent_kernel go through, so values from
commands and from files are bounded alike.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:47:13 +07:00

1000 lines
43 KiB
Rust

//! `layer.layerStyle.*` commands: add/replace layer effects on a layer.
use photocraft_color::{BlendMode, Color};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx,
StrokePosition,
};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, blend_from_str};
use crate::presets::{always, bad};
use crate::{EngineError, Result, Session};
/// Parses a gradient style name (`linear`, `radial`, `angle`, `reflected`, `diamond`).
pub fn gradient_style(s: &str) -> GradientStyle {
match s.to_ascii_lowercase().as_str() {
"radial" => GradientStyle::Radial,
"angle" => GradientStyle::Angle,
"reflected" => GradientStyle::Reflected,
"diamond" => GradientStyle::Diamond,
_ => GradientStyle::Linear,
}
}
fn f(p: &Value, k: &str, d: f32) -> f32 {
p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32)
}
fn b(p: &Value, k: &str, d: bool) -> bool {
p.get(k).and_then(Value::as_bool).unwrap_or(d)
}
fn f_of(p: &Value, k: &str) -> Option<f32> {
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
}
fn b_of(p: &Value, k: &str) -> Option<bool> {
p.get(k).and_then(Value::as_bool)
}
fn color_of(p: &Value, k: &str) -> Option<Color> {
match p.get(k) {
Some(Value::Array(a)) if a.len() >= 3 => {
Some(Color::rgb(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32))
}
Some(Value::String(s)) => {
let s = s.trim_start_matches('#');
let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |v| f32::from(v) / 255.0);
(s.len() >= 6).then_some(Color::rgb(h(0), h(2), h(4)))
}
_ => None,
}
}
fn color(p: &Value, k: &str, d: [f32; 3]) -> Color {
let c = color_of(p, k).map_or(d, |c| c.to_rgb());
Color::rgb(c[0], c[1], c[2])
}
fn gradient(p: &Value) -> Gradient {
Gradient {
stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, color(p, "to", [1.0; 3]))],
style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")),
angle: f(p, "angle", 90.0),
scale: f(p, "scale", 100.0) / 100.0,
reverse: b(p, "reverse", false),
..Gradient::default()
}
}
/// Factory defaults for each effect kind, in the command's parameter units
/// (what the Layer Style dialog starts a new effect from).
pub fn effect_defaults(kind: &str) -> Value {
match kind {
"dropShadow" => {
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "spread": 0, "size": 5, "contour": "Linear", "noise": 0, "knocksOut": true})
}
"innerShadow" => {
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "noise": 0})
}
"outerGlow" => json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "noise": 0}),
"innerGlow" => json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "source": "edge", "choke": 0, "size": 5, "contour": "Linear", "noise": 0}),
"stroke" => json!({"size": 3, "position": "outside", "blend": "Normal", "opacity": 100, "color": "#000000"}),
"colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}),
"gradientOverlay" => {
json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100})
}
"patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}),
"bevelEmboss" => {
json!({"style": "inner", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30})
}
"satin" => json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "invert": true}),
_ => json!({}),
}
}
/// Built-in contour presets (our own transfer curves; imported PSD contours keep
/// their own points). Points are (input, output) in 0..=1, sorted by input.
pub const CONTOURS: &[(&str, &[(f32, f32)])] = &[
("Linear", &[(0.0, 0.0), (1.0, 1.0)]),
("Cone", &[(0.0, 0.0), (0.5, 1.0), (1.0, 1.0)]),
("Cone (Inverted)", &[(0.0, 1.0), (0.5, 0.0), (1.0, 0.0)]),
("Domed", &[(0.0, 0.0), (0.25, 1.0), (0.75, 1.0), (1.0, 0.0)]),
("Domed (Inverted)", &[(0.0, 1.0), (0.25, 0.0), (0.75, 0.0), (1.0, 1.0)]),
("Diagonal (Descending)", &[(0.0, 1.0), (1.0, 0.0)]),
];
/// The built-in contour called `name` (`Linear` is the identity); `None` for
/// names we don't ship, so imported contours are carried rather than clobbered.
pub fn builtin_contour(name: &str) -> Option<Contour> {
if name.eq_ignore_ascii_case("Linear") {
return Some(Contour::Linear);
}
CONTOURS.iter().skip(1).find(|(n, _)| *n == name).map(|(n, pts)| Contour::Custom {
name: (*n).to_string(),
points: pts.iter().map(|(input, output)| CurvePoint { input: *input, output: *output }).collect(),
})
}
/// The param naming a contour (`"Linear"` or a preset/custom contour's name).
pub fn contour_param(c: &Contour) -> Value {
match c {
Contour::Linear => json!("Linear"),
Contour::Custom { name, .. } => json!(name),
}
}
/// An effect of `kind` with its factory settings (before params are applied).
fn fresh_effect(kind: &str) -> Option<Effect> {
let shadow = |inner: bool| Shadow {
common: FxCommon::new(BlendMode::Multiply, 0.75),
color: Color::BLACK,
angle: 120.0,
use_global_light: true,
distance: 5.0,
spread: 0.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: !inner,
};
let glow = || Glow {
common: FxCommon::new(BlendMode::Screen, 0.75),
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 190.0 / 255.0)),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
Some(match kind {
"dropShadow" => Effect::DropShadow(shadow(false)),
"innerShadow" => Effect::InnerShadow(shadow(true)),
"outerGlow" => Effect::OuterGlow(glow()),
"innerGlow" => Effect::InnerGlow(glow()),
"stroke" => Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
}),
"colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) },
"gradientOverlay" => Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false },
"patternOverlay" => Effect::PatternOverlay {
common: FxCommon::new(BlendMode::Normal, 1.0),
name: String::new(),
id: String::new(),
scale: 1.0,
angle: 0.0,
link: true,
phase: (0.0, 0.0),
},
"satin" => Effect::Satin(Satin {
common: FxCommon::new(BlendMode::Multiply, 0.5),
color: Color::BLACK,
angle: 19.0,
distance: 11.0,
size: 14.0,
contour: Contour::Linear,
anti_alias: true,
invert: true,
}),
"bevelEmboss" => Effect::BevelEmboss(Bevel {
enabled: true,
style: BevelStyle::InnerBevel,
technique: BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 5.0,
soften: 0.0,
angle: 120.0,
altitude: 30.0,
use_global_light: true,
gloss_contour: Contour::Linear,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: None,
texture: None,
}),
_ => return None,
})
}
/// Edits an existing gradient in place: only the end colours and geometry.
/// Intermediate stops, opacity stops, alignment and offset survive a round trip.
fn overlay_gradient(g: &mut Gradient, p: &Value) {
if let Some(c) = color_of(p, "from")
&& let Some(first) = g.stops.first_mut()
{
first.1 = c;
}
if let Some(c) = color_of(p, "to")
&& let Some(last) = g.stops.last_mut()
{
last.1 = c;
}
if let Some(s) = p.get("style").and_then(Value::as_str) {
g.style = gradient_style(s);
}
if let Some(x) = f_of(p, "angle") {
g.angle = x;
}
if let Some(x) = f_of(p, "scale") {
g.scale = (x / 100.0).clamp(0.01, 10.0);
}
if let Some(x) = b_of(p, "reverse") {
g.reverse = x;
}
}
/// Applies the params present in `p` onto an existing effect, in the command's
/// parameter units. Absent (or unparseable) keys leave the effect unchanged, so
/// a dialog round trip never touches what it doesn't model: an imported
/// gradient stroke keeps its gradient while its opacity is edited.
pub fn overlay_effect(fx: &mut Effect, p: &Value) {
fn set_color(p: &Value, k: &str, c: &mut Color) {
if let Some(x) = color_of(p, k) {
*c = x;
}
}
fn set_contour(p: &Value, c: &mut Contour) {
if let Some(name) = p.get("contour").and_then(Value::as_str)
&& let Some(x) = builtin_contour(name)
{
*c = x;
}
}
fn common(c: &mut FxCommon, p: &Value) {
if let Some(x) = b_of(p, "enabled") {
c.enabled = x;
}
if let Some(x) = p.get("blend").and_then(Value::as_str).and_then(blend_from_str) {
c.blend = x;
}
if let Some(x) = f_of(p, "opacity") {
c.opacity = (x / 100.0).clamp(0.0, 1.0);
}
}
let inner_shadow = matches!(fx, Effect::InnerShadow(_));
let inner_glow = matches!(fx, Effect::InnerGlow(_));
match fx {
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
let inner = inner_shadow;
common(&mut s.common, p);
set_color(p, "color", &mut s.color);
if let Some(x) = f_of(p, "angle") {
s.angle = x;
}
if let Some(x) = b_of(p, "useGlobalLight") {
s.use_global_light = x;
}
if let Some(x) = f_of(p, "distance") {
s.distance = x;
}
if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) {
s.spread = (x / 100.0).clamp(0.0, 1.0);
}
if let Some(x) = f_of(p, "size") {
s.size = x;
}
set_contour(p, &mut s.contour);
if let Some(x) = f_of(p, "noise") {
s.noise = (x / 100.0).clamp(0.0, 1.0);
}
if !inner && let Some(x) = b_of(p, "knocksOut") {
s.knocks_out = x;
}
}
Effect::OuterGlow(g) | Effect::InnerGlow(g) => {
let inner = inner_glow;
common(&mut g.common, p);
if let Some(c) = color_of(p, "color") {
g.paint = FxPaint::Color(c);
}
if let Some(t) = p.get("technique").and_then(Value::as_str) {
g.technique = if t == "precise" { GlowTechnique::Precise } else { GlowTechnique::Softer };
}
if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) {
g.spread = (x / 100.0).clamp(0.0, 1.0);
}
if let Some(x) = f_of(p, "size") {
g.size = x;
}
set_contour(p, &mut g.contour);
if let Some(x) = f_of(p, "range") {
g.range = (x / 100.0).clamp(0.01, 1.0);
}
if let Some(x) = f_of(p, "noise") {
g.noise = (x / 100.0).clamp(0.0, 1.0);
}
if inner && let Some(s) = p.get("source").and_then(Value::as_str) {
g.source = if s == "center" { GlowSource::Center } else { GlowSource::Edge };
}
}
Effect::Stroke(s) => {
common(&mut s.common, p);
if let Some(x) = f_of(p, "size") {
s.size = x;
}
if let Some(pos) = p.get("position").and_then(Value::as_str) {
s.position = match pos {
"inside" => StrokePosition::Inside,
"center" => StrokePosition::Center,
_ => StrokePosition::Outside,
};
}
if p.get("from").is_some() {
s.paint = FxPaint::Gradient(gradient(p));
} else if let Some(c) = color_of(p, "color") {
s.paint = FxPaint::Color(c);
}
}
Effect::ColorOverlay { common: c, color } => {
common(c, p);
set_color(p, "color", color);
}
Effect::GradientOverlay { common: c, gradient: g, dither } => {
common(c, p);
overlay_gradient(g, p);
if let Some(x) = b_of(p, "dither") {
*dither = x;
}
}
Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => {
common(c, p);
if let Some(x) = p.get("pattern").and_then(Value::as_str) {
*name = x.to_string();
*id = String::new();
}
if let Some(x) = f_of(p, "scale") {
*scale = (x / 100.0).clamp(0.01, 10.0);
}
if let Some(x) = f_of(p, "angle") {
*angle = x;
}
if let Some(x) = b_of(p, "link") {
*link = x;
}
if let Some(x) = f_of(p, "phaseX") {
phase.0 = x;
}
if let Some(x) = f_of(p, "phaseY") {
phase.1 = x;
}
}
Effect::Satin(s) => {
common(&mut s.common, p);
set_color(p, "color", &mut s.color);
if let Some(x) = f_of(p, "angle") {
s.angle = x;
}
if let Some(x) = f_of(p, "distance") {
s.distance = x;
}
if let Some(x) = f_of(p, "size") {
s.size = x;
}
if let Some(x) = b_of(p, "invert") {
s.invert = x;
}
}
Effect::BevelEmboss(bv) => {
if let Some(x) = b_of(p, "enabled") {
bv.enabled = x;
}
if let Some(s) = p.get("style").and_then(Value::as_str) {
bv.style = match s {
"outer" => BevelStyle::OuterBevel,
"emboss" => BevelStyle::Emboss,
"pillow" => BevelStyle::PillowEmboss,
"stroke" => BevelStyle::StrokeEmboss,
_ => BevelStyle::InnerBevel,
};
}
if let Some(t) = p.get("technique").and_then(Value::as_str) {
bv.technique = match t {
"chiselHard" => BevelTechnique::ChiselHard,
"chiselSoft" => BevelTechnique::ChiselSoft,
_ => BevelTechnique::Smooth,
};
}
if let Some(x) = f_of(p, "depth") {
bv.depth = x / 100.0;
}
if let Some(d) = p.get("direction").and_then(Value::as_str) {
bv.up = d != "down";
}
if let Some(x) = f_of(p, "size") {
bv.size = x;
}
if let Some(x) = f_of(p, "soften") {
bv.soften = x;
}
if let Some(x) = f_of(p, "angle") {
bv.angle = x;
}
if let Some(x) = f_of(p, "altitude") {
bv.altitude = x;
}
if let Some(x) = b_of(p, "useGlobalLight") {
bv.use_global_light = x;
}
if let Some(x) = p.get("glossContour").and_then(Value::as_str)
&& let Some(c) = builtin_contour(x)
{
bv.gloss_contour = c;
}
if let Some(x) = b_of(p, "contour") {
bv.contour = x.then(|| photocraft_doc::BevelContour {
contour: builtin_contour(p.get("contourName").and_then(Value::as_str).unwrap_or("Linear")).unwrap_or(Contour::Linear),
range: (f(p, "contourRange", 50.0) / 100.0).clamp(0.01, 1.0),
anti_alias: false,
});
} else if let Some(c) = &mut bv.contour
&& let Some(x) = builtin_contour(p.get("contourName").and_then(Value::as_str).unwrap_or(""))
{
c.contour = x;
}
if let Some(t) = p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()) {
bv.texture = Some(photocraft_doc::BevelTexture {
name: t.to_string(),
id: t.to_string(),
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
invert: b(p, "textureInvert", false),
link: b(p, "textureLink", true),
phase: (0.0, 0.0),
});
}
}
}
}
/// Builds an effect of `kind` from JSON params (Photoshop units: opacity,
/// spread, range in percent; sizes in px; angles in degrees).
pub fn effect_from_params(kind: &str, p: &Value) -> Option<Effect> {
let mut fx = fresh_effect(kind)?;
overlay_effect(&mut fx, p);
Some(fx)
}
/// The effect's kind as the commands and dialog name it.
pub fn kind_of(e: &Effect) -> &'static str {
match e {
Effect::DropShadow(_) => "dropShadow",
Effect::InnerShadow(_) => "innerShadow",
Effect::OuterGlow(_) => "outerGlow",
Effect::InnerGlow(_) => "innerGlow",
Effect::Stroke(_) => "stroke",
Effect::ColorOverlay { .. } => "colorOverlay",
Effect::GradientOverlay { .. } => "gradientOverlay",
Effect::PatternOverlay { .. } => "patternOverlay",
Effect::Satin(_) => "satin",
Effect::BevelEmboss(_) => "bevelEmboss",
}
}
fn same_kind(a: &Effect, b: &Effect) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}
/// Upper bound on the effects one `layer.layerStyle.replace` call accepts, so an
/// automation mistake can't ask for an unbounded document change.
const MAX_EFFECTS: usize = 256;
/// Replaces a layer's whole effect list in one history step: the Layer Style dialog's
/// apply. Each entry builds an effect from `params`, or edits the snapshot it carried
/// (`fx`: the effect JSON as the dialog loaded it, kept verbatim for everything the
/// dialog doesn't model) with the params present. Unknown kinds, kind/snapshot
/// mismatches and malformed snapshots are errors, never panics.
fn replace_effects(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.replace";
let id = match p.get("layer").and_then(Value::as_u64) {
Some(id) => photocraft_doc::LayerId(id),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
};
let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?;
if entries.len() > MAX_EFFECTS {
return Err(bad(CMD, format!("at most {MAX_EFFECTS} effects")));
}
let mut list = Vec::with_capacity(entries.len());
let mut pats = Vec::new();
for e in entries {
let kind = e.get("kind").and_then(Value::as_str).ok_or_else(|| bad(CMD, "each effect needs a `kind` string"))?;
let params = match e.get("params") {
None | Some(Value::Null) => json!({}),
Some(Value::Object(_)) => e.get("params").cloned().unwrap_or_default(),
Some(_) => return Err(bad(CMD, "`params` must be an object")),
};
let fx = match e.get("fx") {
Some(fx_json) => {
let mut fx: Effect = serde_json::from_value(fx_json.clone()).map_err(|err| bad(CMD, format!("bad effect snapshot: {err}")))?;
if kind_of(&fx) != kind {
return Err(bad(CMD, format!("effect snapshot is a {}, not `{kind}`", kind_of(&fx))));
}
overlay_effect(&mut fx, &params);
fx
}
None => effect_from_params(kind, &params).ok_or_else(|| bad(CMD, format!("unknown effect {kind}")))?,
};
let (fx, pat) = crate::pattern_cmds::resolve_effect(s, fx)?;
if let Some(pat) = pat {
pats.push(pat);
}
list.push(fx);
}
let count = list.len();
let label = if list.is_empty() { "Clear Layer Style" } else { "Edit Layer Style" };
s.edit(label, |doc, _| {
for pat in &pats {
crate::pattern_cmds::ensure_in_doc(doc, pat);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects.items = list;
l.effects.enabled = true;
Ok(())
})?;
Ok(json!({ "layer": id.0, "effects": count }))
}
fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?;
let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?;
let label = format!("Layer Style: {}", fx.label());
let add = b(p, "add", false);
let id = match p.get("layer").and_then(Value::as_u64) {
Some(id) => photocraft_doc::LayerId(id),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
};
s.edit(&label, |doc, _| {
if let Some(pat) = &pattern {
crate::pattern_cmds::ensure_in_doc(doc, pat);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects.enabled = true;
match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) {
Some(slot) if !add => *slot = fx,
_ => l.effects.items.push(fx),
}
Ok(())
})?;
Ok(json!({ "layer": id.0 }))
}
/// Stores the user default for one effect kind ("Make Default" in the Layer Style dialog).
fn make_default(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.makeDefault";
let kind = req_str_kind(p, CMD)?;
let params = match p.get("params") {
Some(v) if v.is_object() => v.clone(),
_ => return Err(bad(CMD, "`params` must be an object")),
};
s.presets.layer_defaults.insert(kind.to_string(), params);
s.presets_changed();
Ok(json!({"kind": kind}))
}
/// The user default for one effect kind, else its factory defaults.
fn default_for(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.defaultFor";
let kind = req_str_kind(p, CMD)?;
match s.presets.layer_defaults.get(kind) {
Some(v) if v.is_object() => Ok(json!({"params": v, "user": true})),
_ => Ok(json!({"params": effect_defaults(kind), "user": false})),
}
}
fn req_str_kind<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
let kind = p.get("kind").and_then(Value::as_str).ok_or_else(|| bad(cmd, "need `kind`"))?;
if effect_from_params(kind, &Value::Null).is_none() {
return Err(bad(cmd, format!("unknown effect {kind}")));
}
Ok(kind)
}
fn has_layer(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into())
}
macro_rules! style_cmd {
($kind:literal, $label:literal, $params:literal) => {
CommandSpec {
id: concat!("layer.layerStyle.", $kind),
label: $label,
menu: &["Layer", "Layer Style"],
shortcut: None,
params: $params,
enabled: has_layer,
run: |s, p| set_effect(s, p, $kind),
journal: true,
}
};
}
/// The `layer.layerStyle.*` command specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
style_cmd!(
"dropShadow",
"Drop Shadow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="multiply","angle":deg=120,"useGlobalLight":bool,"distance":px=5,"spread":0..100,"size":px=5,"contour":"Linear|Cone|Cone (Inverted)|Domed|Domed (Inverted)|Diagonal (Descending)"="Linear","noise":0..100,"knocksOut":bool,"add":bool,"layer":id}"##
),
style_cmd!(
"innerShadow",
"Inner Shadow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str,"angle":deg,"distance":px,"choke":0..100,"size":px,"contour":name,"noise":0..100,"add":bool}"##
),
style_cmd!(
"outerGlow",
"Outer Glow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":0..100,"contour":name,"noise":0..100,"add":bool}"##
),
style_cmd!(
"innerGlow",
"Inner Glow…",
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"contour":name,"noise":0..100,"add":bool}"##
),
style_cmd!(
"stroke",
"Stroke…",
r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"##
),
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
style_cmd!(
"gradientOverlay",
"Gradient Overlay…",
r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##
),
style_cmd!(
"patternOverlay",
"Pattern Overlay…",
r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"##
),
style_cmd!(
"bevelEmboss",
"Bevel & Emboss…",
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourRange":1..100=50,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
),
style_cmd!("satin", "Satin…", r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"invert":bool,"add":bool}"##),
CommandSpec {
id: "layer.layerStyle.replace",
label: "Edit Layer Style",
menu: &[],
shortcut: None,
params: r##"{"layer":id,"effects":[{"kind":"dropShadow|innerShadow|outerGlow|innerGlow|stroke|colorOverlay|gradientOverlay|patternOverlay|satin|bevelEmboss","params":{…layer.layerStyle.<kind> params…},"fx":<effect snapshot carried through the Layer Style dialog, optional>}]} (replaces the layer's whole effect list; an entry with `fx` edits that effect with the params present, one without builds from params)"##,
enabled: has_layer,
run: replace_effects,
journal: true,
},
CommandSpec {
id: "layer.layerStyle.makeDefault",
label: "Make Layer Style Default",
menu: &[],
shortcut: None,
params: r##"{"kind":"stroke|dropShadow|innerShadow|outerGlow|innerGlow|colorOverlay|gradientOverlay|patternOverlay|satin|bevelEmboss","params":{…param set…}} (stores the user default the Layer Style dialog's Reset to Default restores)"##,
enabled: always,
run: make_default,
journal: false,
},
CommandSpec {
id: "layer.layerStyle.defaultFor",
label: "Layer Style Default",
menu: &[],
shortcut: None,
params: r##"{"kind":str} → {"params":{…},"user":bool} (the saved user default, else the factory defaults)"##,
enabled: always,
run: default_for,
journal: false,
},
CommandSpec {
id: "layer.layerStyle.clear",
label: "Clear Layer Style",
menu: &["Layer", "Layer Style"],
shortcut: None,
params: r##"{"layer":id}"##,
enabled: has_layer,
run: |s, p| {
let id = match p.get("layer").and_then(Value::as_u64) {
Some(id) => photocraft_doc::LayerId(id),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
};
s.edit("Clear Layer Style", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects = photocraft_doc::Effects { enabled: true, ..Default::default() };
Ok(())
})?;
Ok(Value::Null)
},
journal: true,
},
]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s
}
fn effects(s: &Session) -> Vec<Effect> {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone()
}
#[test]
fn bevel_command_sets_technique_contour_and_texture() {
let mut s = session();
s.execute("layer.layerStyle.bevelEmboss", json!({"style": "pillow", "technique": "chiselHard", "contour": true, "contourRange": 70, "texture": "Bubbles", "textureDepth": -200, "textureInvert": true})).unwrap();
let fx = effects(&s);
let Effect::BevelEmboss(b) = &fx[0] else { panic!("{fx:?}") };
assert_eq!((b.style, b.technique), (photocraft_doc::BevelStyle::PillowEmboss, BevelTechnique::ChiselHard));
assert!((b.contour.as_ref().unwrap().range - 0.7).abs() < 1e-6);
let t = b.texture.as_ref().unwrap();
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
}
#[test]
fn every_style_command_adds_its_effect() {
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
let mut s = session();
s.execute(&format!("layer.layerStyle.{kind}"), json!({})).unwrap();
let fx = effects(&s);
assert_eq!(fx.len(), 1, "{kind}");
assert!(fx[0].enabled());
}
}
/// #1543: a Satin or Bevel & Emboss size or softness far past Photoshop's range rendered a
/// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width
/// is capped at the effect reach, so the document still renders, quickly.
#[test]
fn oversized_satin_and_bevel_sizes_render() {
for (cmd, p) in [
("satin", json!({"size": 1e308})),
("satin", json!({"size": 1_000_000})),
("bevelEmboss", json!({"size": 1e308})),
("bevelEmboss", json!({"soften": 1e308})),
("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000})),
] {
let mut s = session();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap();
let start = std::time::Instant::now();
s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed());
}
}
#[test]
fn params_are_applied_and_replace_or_add() {
let mut s = session();
s.execute("layer.layerStyle.stroke", json!({"size": 7, "position": "inside", "color": "#00ff00", "opacity": 50})).unwrap();
match &effects(&s)[0] {
Effect::Stroke(st) => {
assert_eq!(st.size, 7.0);
assert_eq!(st.position, StrokePosition::Inside);
assert_eq!(st.common.opacity, 0.5);
assert_eq!(st.paint, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)));
}
other => panic!("{other:?}"),
}
s.execute("layer.layerStyle.stroke", json!({"size": 2})).unwrap();
assert_eq!(effects(&s).len(), 1, "replaces");
s.execute("layer.layerStyle.stroke", json!({"size": 4, "add": true})).unwrap();
assert_eq!(effects(&s).len(), 2, "adds a second instance");
s.execute("layer.layerStyle.dropShadow", json!({"blend": "normal", "distance": 12})).unwrap();
assert!(matches!(&effects(&s)[2], Effect::DropShadow(sh) if sh.common.blend == BlendMode::Normal && sh.distance == 12.0));
}
#[test]
fn clear_and_undo() {
let mut s = session();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
s.execute("layer.layerStyle.clear", json!({})).unwrap();
assert!(effects(&s).is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(effects(&s).len(), 1);
}
#[test]
fn color_overlay_renders() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
let px: Vec<f32> = serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap();
assert!(px[0] > 0.99 && px[1] < 0.01, "{px:?}");
}
#[test]
fn gradient_styles_parse() {
assert_eq!(gradient_style("Radial"), GradientStyle::Radial);
assert_eq!(gradient_style("diamond"), GradientStyle::Diamond);
assert_eq!(gradient_style("?"), GradientStyle::Linear);
}
fn snapshot(fx: &Effect) -> Value {
serde_json::to_value(fx).unwrap()
}
#[test]
fn every_kind_has_factory_defaults() {
for kind in
["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "satin", "bevelEmboss"]
{
assert!(effect_defaults(kind).as_object().is_some_and(|o| !o.is_empty()), "{kind}");
assert!(effect_from_params(kind, &json!({})).is_some(), "{kind}");
}
assert!(effect_from_params("nope", &json!({})).is_none());
}
#[test]
fn replace_builds_edits_and_preserves_instances() {
let mut s = session();
// Two strokes: the first edits a snapshot (params overlay), the second is fresh.
let first = Effect::Stroke(StrokeFx {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
size: 7.0,
position: StrokePosition::Outside,
paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }),
});
let r = s
.execute(
"layer.layerStyle.replace",
json!({"effects": [
{"kind": "stroke", "fx": snapshot(&first), "params": {"opacity": 50}},
{"kind": "stroke", "params": {"size": 2, "color": "#ffffff"}},
]}),
)
.unwrap();
assert_eq!(r["effects"], 2);
let fx = effects(&s);
assert_eq!(fx.len(), 2, "order preserved");
match (&fx[0], &fx[1]) {
(Effect::Stroke(a), Effect::Stroke(b)) => {
assert_eq!(a.common.opacity, 0.5);
// The overlay must not clobber the gradient paint it doesn't model.
assert!(matches!(&a.paint, FxPaint::Gradient(g) if g.stops.len() == 2 && g.stops[0].1 == Color::rgb(0.0, 0.0, 1.0)));
assert_eq!(b.size, 2.0);
assert_eq!(b.paint, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)));
}
other => panic!("{other:?}"),
}
// Replacing with an empty list clears the layer.
s.execute("layer.layerStyle.replace", json!({"effects": []})).unwrap();
assert!(effects(&s).is_empty());
}
#[test]
fn replace_rejects_bad_entries_gracefully() {
let mut s = session();
let stroke = Effect::Stroke(StrokeFx {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::BLACK),
});
for p in [
json!({}),
json!({"effects": "nope"}),
json!({"effects": [{"params": {}}]}),
json!({"effects": [{"kind": "nope"}]}),
json!({"effects": [{"kind": "stroke", "fx": {"Bogus": {}}}]}),
json!({"effects": [{"kind": "stroke", "fx": 3}]}),
json!({"effects": [{"kind": "dropShadow", "fx": snapshot(&stroke)}]}),
json!({"effects": [{"kind": "stroke", "fx": snapshot(&stroke), "params": "nope"}]}),
] {
assert!(s.execute("layer.layerStyle.replace", p).is_err());
}
// A long list is refused, not run.
let many: Vec<_> = (0..257).map(|_| json!({"kind": "stroke"})).collect();
assert!(s.execute("layer.layerStyle.replace", json!({"effects": many})).is_err());
assert!(effects(&s).is_empty(), "nothing was applied");
}
#[test]
fn contour_and_noise_params_edit_existing_effects() {
let mut s = session();
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
let before = effects(&s);
let Effect::DropShadow(_) = &before[0] else { panic!("{before:?}") };
let fx0 = snapshot(&before[0]);
// Unknown contour names are carried, not clobbered to Linear.
s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "fx": fx0, "params": {"contour": "Cone", "noise": 30}}]})).unwrap();
let fx = effects(&s);
let Effect::DropShadow(sh) = &fx[0] else { panic!("{fx:?}") };
assert_eq!(sh.contour, builtin_contour("Cone").unwrap(), "known preset applies");
assert!((sh.noise - 0.3).abs() < 1e-6, "noise in percent");
// An imported contour with an unknown name survives the same round trip.
let imported = Contour::Custom {
name: "Photoshop contour".into(),
points: vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 1.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }],
};
let mut carried = before[0].clone();
if let Effect::DropShadow(c) = &mut carried {
c.contour = imported.clone();
}
overlay_effect(&mut carried, &json!({"contour": "Photoshop contour"}));
let Effect::DropShadow(after) = &carried else { panic!() };
assert_eq!(after.contour, imported);
// Linear applies by name.
let mut back = carried;
overlay_effect(&mut back, &json!({"contour": "Linear"}));
let Effect::DropShadow(lin) = &back else { panic!() };
assert_eq!(lin.contour, Contour::Linear);
}
#[test]
fn make_default_then_reset_restores_it() {
let mut s = session();
// Factory default first: Reset without a saved default gives the factory set.
let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
assert_eq!(r["user"], json!(false));
assert_eq!(r["params"]["size"], json!(3));
s.execute(
"layer.layerStyle.makeDefault",
json!({"kind": "stroke", "params": {"size": 9, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}}),
)
.unwrap();
let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
assert_eq!(r["user"], json!(true));
assert_eq!(r["params"]["size"], json!(9));
// Bad kinds and params are graceful errors.
assert!(s.execute("layer.layerStyle.makeDefault", json!({"kind": "nope", "params": {}})).is_err());
assert!(s.execute("layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": "x"})).is_err());
assert!(s.execute("layer.layerStyle.defaultFor", json!({"kind": "nope"})).is_err());
}
#[test]
fn layer_style_defaults_persist_with_presets() {
let mut s = session();
s.execute("layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": {"size": 9}})).unwrap();
let blob = s.presets.to_json(&s);
let mut t = Session::new();
t.load_presets_json(blob);
let r = t.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
assert_eq!(r["user"], json!(true));
assert_eq!(r["params"]["size"], json!(9));
}
#[test]
fn double_stroke_bands_render_and_survive_an_edit() {
// The manga/typesetting case (#157): a 7 px white stroke under a 3 px black one.
let mut s = Session::new();
s.execute("file.new", json!({"width": 48, "height": 48})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap();
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#000000", "position": "outside"})).unwrap();
s.execute("layer.layerStyle.stroke", json!({"size": 7, "color": "#ffffff", "position": "outside", "add": true})).unwrap();
let px = |s: &mut Session, x: i32, y: i32| -> [f32; 4] {
let mut v: Vec<f32> = serde_json::from_value(s.execute("document.pixel", json!({"x": x, "y": y})).unwrap()).unwrap();
v.resize(4, 1.0);
[v[0], v[1], v[2], v[3]]
};
// Bands from the square edge outward: 0..3 px black (the first stroke renders on top),
// 3..7 px white, then the white background.
assert!(px(&mut s, 24, 24)[0] < 0.1, "the square is black");
assert!(px(&mut s, 24, 14)[0] < 0.1, "the first stroke covers the wider one");
assert!(px(&mut s, 24, 11)[0] > 0.9, "the second stroke shows outside it");
assert!(px(&mut s, 24, 5)[0] > 0.9, "background beyond both");
// A dialog round trip (replace with the same snapshots) keeps both bands.
let before = px(&mut s, 24, 14);
let st = s.active().unwrap();
let id = st.active_layer.unwrap();
let items = st.doc.layer(id).unwrap().effects.items.clone();
let entries: Vec<Value> = items.iter().map(|fx| json!({"kind": "stroke", "fx": serde_json::to_value(fx).unwrap(), "params": {}})).collect();
s.execute("layer.layerStyle.replace", json!({"layer": id.0, "effects": entries})).unwrap();
assert_eq!(px(&mut s, 24, 14), before, "both strokes render identically after the round trip");
}
}