fix(engine): reject numeric overflow before document edits

This commit is contained in:
PerchAnton committed 2026-10-16 10:28:57 +03:00
1 parent 2a952f164c
commit 3f216a0cd1
13 files changed
+270 -51

No files matched your search

+1 -1
View File
@@ -335,7 +335,7 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.adjustments.replaceColor";
let c = crate::commands::color_param(p, "color", s.tools.foreground);
let c = crate::commands::color_param(CMD, p, "color", s.tools.foreground)?;
let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0);
let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0));
// Localized Color Clusters, as in Select › Color Range: the colour only matches within Range
+8 -6
View File
@@ -722,10 +722,12 @@ fn group_index(d: &Document, p: &Value) -> Option<usize> {
(i < d.measurement.count_groups.len()).then_some(i)
}
fn color_of(p: &Value, key: &str) -> Option<Color> {
p.get(key)?;
let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]);
Some(Color::rgb(c[0], c[1], c[2]))
fn color_of(cmd: &str, p: &Value, key: &str) -> Result<Option<Color>> {
if p.get(key).is_none() {
return Ok(None);
}
let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?;
Ok(Some(Color::rgb(c[0], c[1], c[2])))
}
/// Photoshop's colours for new count groups, in order.
@@ -840,7 +842,7 @@ fn count_clear(s: &mut Session, p: &Value) -> Result<Value> {
fn count_new_group(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let color = color_of(p, "color");
let color = color_of("count.newGroup", p, "color")?;
s.edit("New Count Group", |d, _| {
let g = new_group(d, name, color);
d.measurement.count_groups.push(g);
@@ -887,7 +889,7 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = p.get("name").and_then(Value::as_str) {
grp.name = n.to_string();
}
if let Some(c) = color_of(p, "color") {
if let Some(c) = color_of(CMD, p, "color")? {
grp.color = c;
}
if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? {
+6 -4
View File
@@ -92,10 +92,12 @@ fn preset_size(name: &str) -> Option<(u32, u32)> {
}
fn background_param(p: &Value, cmd: &str) -> Result<Option<ArtboardBackground>> {
let custom = p.get("color").map(|_| {
let c = color_param(p, "color", [1.0; 4]);
ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3]))
});
let custom = if p.get("color").is_some() {
let c = color_param(cmd, p, "color", [1.0; 4])?;
Some(ArtboardBackground::Custom(Color::rgba(c[0], c[1], c[2], c[3])))
} else {
None
};
Ok(match p.get("background").and_then(Value::as_str) {
None => custom,
Some("white") => Some(ArtboardBackground::White),
+9 -7
View File
@@ -1086,17 +1086,19 @@ fn indicates_param(v: Option<&Value>) -> Option<ColorIndicates> {
}
}
fn color_value(p: &Value, key: &str) -> Option<Color> {
p.get(key)?;
let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]);
Some(Color::rgb(c[0], c[1], c[2]))
fn color_value(cmd: &str, p: &Value, key: &str) -> Result<Option<Color>> {
if p.get(key).is_none() {
return Ok(None);
}
let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?;
Ok(Some(Color::rgb(c[0], c[1], c[2])))
}
fn new_channel(s: &mut Session, p: &Value) -> Result<Value> {
let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string();
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let indicates = indicates_param(p.get("indicates"));
let color = color_value(p, "color");
let color = color_value("channel.new", p, "color")?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let i = s.edit("New Channel", |doc, _| {
let area = doc.bounds();
@@ -1125,7 +1127,7 @@ fn new_channel(s: &mut Session, p: &Value) -> Result<Value> {
}
fn new_spot(s: &mut Session, p: &Value) -> Result<Value> {
let color = color_value(p, "color").unwrap_or(Color::rgb(1.0, 0.0, 0.0));
let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0));
let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0;
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
@@ -1421,7 +1423,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?;
let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let color = color_value(p, "color");
let color = color_value(cmd, p, "color")?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string);
+5 -4
View File
@@ -34,7 +34,7 @@ fn unit(v: f32, default: f32) -> f32 {
/// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to
/// the defaults). [`run`] validates strictly before it gets here.
pub(crate) fn params(p: &Value) -> FilterParams {
let c = crate::commands::color_param(p, "color", WHITE);
let c = crate::commands::color_param(ID, p, "color", WHITE).unwrap_or(WHITE);
let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d));
FilterParams::ColorToAlpha {
color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0],
@@ -65,9 +65,10 @@ fn validate(p: &Value) -> Result<()> {
crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?;
}
Some(Value::Array(a)) if (3..=4).contains(&a.len()) => {
if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) {
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
}
a.iter()
.all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x)))
.then_some(())
.ok_or_else(|| bad("`color` as an array must hold 3 or 4 numbers in 0..1"))?;
}
Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")),
}
+50 -10
View File
@@ -232,15 +232,53 @@ pub(crate) fn u32_id_param(cmd: &str, p: &Value, key: &str) -> Result<Option<u32
fn f32_or(p: &Value, key: &str, default: f32) -> f32 {
p.get(key).and_then(Value::as_f64).map(|v| v as f32).unwrap_or(default)
}
pub(crate) fn color_param(p: &Value, key: &str, default: [f32; 4]) -> [f32; 4] {
match p.get(key) {
/// JSON numbers may be finite f64 values but overflow when stored in a document's f32 field.
pub(crate) fn finite_f32(cmd: &str, key: &str, value: f64) -> Result<f32> {
let narrowed = value as f32;
if narrowed.is_finite() { Ok(narrowed) } else { Err(bad(cmd, format!("`{key}` must fit a finite 32-bit number"))) }
}
/// Effect/type parameters contain nested colour arrays and snapshots as well as scalars.
/// Validate before parsing or editing, including numbers hidden in an effect snapshot.
pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> {
// Bound the call stack, without allocating a work list proportional to an input array.
fn check(cmd: &str, value: &Value, depth: usize) -> Result<()> {
if depth > 64 {
return Err(bad(cmd, "parameters nested deeper than 64 levels"));
}
match value {
Value::Number(n) => {
finite_f32(cmd, "number", n.as_f64().ok_or_else(|| bad(cmd, "invalid number"))?)?;
}
Value::Array(a) => {
for v in a {
check(cmd, v, depth + 1)?;
}
}
Value::Object(o) => {
for v in o.values() {
check(cmd, v, depth + 1)?;
}
}
_ => {}
}
Ok(())
}
check(cmd, value, 0)
}
pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> {
Ok(match p.get(key) {
Some(Value::Array(a)) if a.len() >= 3 => {
let v: Vec<f32> = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
[v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]
let mut out = [0.0, 0.0, 0.0, 1.0];
for (slot, value) in out.iter_mut().zip(a) {
*slot = finite_f32(cmd, key, value.as_f64().unwrap_or(0.0))?;
}
out
}
Some(Value::String(s)) => parse_hex(s).unwrap_or(default),
_ => default,
}
})
}
pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
let s = s.trim_start_matches('#');
@@ -968,7 +1006,7 @@ fn build() -> Vec<CommandSpec> {
r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
has_doc,
|s, p| {
let c = color_param(p, "color", s.tools.foreground);
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
let id = s.edit("New Color Fill Layer", |doc, active| {
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
@@ -988,8 +1026,8 @@ fn build() -> Vec<CommandSpec> {
r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
has_doc,
|s, p| {
let a = color_param(p, "from", s.tools.foreground);
let b = color_param(p, "to", s.tools.background);
let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?;
let b = color_param("layer.newFillLayer.gradient", p, "to", s.tools.background)?;
let angle = f32_or(p, "angle", 90.0);
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
@@ -1120,8 +1158,10 @@ fn build() -> Vec<CommandSpec> {
}
};
let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?);
s.tools.foreground = color_param(p, "foreground", s.tools.foreground);
s.tools.background = color_param(p, "background", s.tools.background);
let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?;
let background = color_param("tools.setColors", p, "background", s.tools.background)?;
s.tools.foreground = foreground;
s.tools.background = background;
if let Some(k) = fg_cmyk {
let [r, g, b, _] = s.tools.foreground;
s.tools.remember_cmyk([r, g, b], k);
+1 -1
View File
@@ -135,7 +135,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
// Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px).
let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize;
let color = color_param(p, "color", s.tools.foreground);
let color = color_param("edit.stroke", p, "color", s.tools.foreground)?;
// A colour picked as CMYK strokes a CMYK document with the picked inks (#4196).
let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]);
let mode = match p.get("mode") {
+1 -1
View File
@@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let source = match contents {
"foreground" => color_source(s, s.tools.foreground),
"background" => color_source(s, s.tools.background),
"color" => color_source(s, color_param(p, "color", s.tools.foreground)),
"color" => color_source(s, color_param(CMD, p, "color", s.tools.foreground)?),
"black" => Source::Color([0.0, 0.0, 0.0, 1.0]),
"gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
"white" => Source::Color([1.0; 4]),
+23 -8
View File
@@ -594,6 +594,9 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) {
/// 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) {
if crate::commands::check_f32_values("layer.layerStyle", p).is_err() {
return;
}
fn set_color(p: &Value, k: &str, c: &mut Color) {
if let Some(x) = color_of(p, k) {
*c = x;
@@ -821,6 +824,7 @@ pub fn overlay_effect(fx: &mut Effect, p: &Value) {
/// 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> {
crate::commands::check_f32_values("layer.layerStyle", p).ok()?;
let mut fx = fresh_effect(kind)?;
overlay_effect(&mut fx, p);
Some(fx)
@@ -856,6 +860,7 @@ const MAX_EFFECTS: usize = 256;
/// mismatches and malformed snapshots are errors, never panics.
fn replace_effects(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.replace";
crate::commands::check_f32_values(CMD, p)?;
let id = crate::commands::layer_param(s, p)?;
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 {
@@ -905,6 +910,7 @@ fn replace_effects(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?;
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());
@@ -936,6 +942,7 @@ fn make_default(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) if v.is_object() => v.clone(),
_ => return Err(bad(CMD, "`params` must be an object")),
};
crate::commands::check_f32_values(CMD, &params)?;
s.presets.layer_defaults.insert(kind.to_string(), params);
s.presets_changed();
Ok(json!({"kind": kind}))
@@ -1152,17 +1159,25 @@ mod tests {
/// 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})),
fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() {
for (cmd, p, accepted) in [
("satin", json!({"size": 1e308}), false),
("satin", json!({"size": 1e30}), true),
("satin", json!({"size": 1_000_000}), true),
("bevelEmboss", json!({"size": 1e308}), false),
("bevelEmboss", json!({"soften": 1e308}), false),
("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true),
("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true),
] {
let mut s = session();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap();
let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone());
if accepted {
result.unwrap();
} else {
assert!(matches!(result, Err(EngineError::BadParams { .. })));
}
photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).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());
+1 -1
View File
@@ -62,7 +62,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera
"edgeExtension" | "extension" => EdgeMode::Extension,
"black" => EdgeMode::Color([0.0, 0.0, 0.0, 1.0]),
"white" => EdgeMode::Color([1.0, 1.0, 1.0, 1.0]),
"background" | "color" => EdgeMode::Color(crate::commands::color_param(p, "edgeColor", [0.0, 0.0, 0.0, 1.0])),
"background" | "color" => EdgeMode::Color(crate::commands::color_param(cmd, p, "edgeColor", [0.0, 0.0, 0.0, 1.0])?),
other => return Err(bad(cmd, format!("unknown edge `{other}` (transparency|edgeExtension|black|white|color)"))),
};
let focal = num(p, "focalLength", 0.0);
+8 -6
View File
@@ -40,10 +40,12 @@ fn list_json(d: &Document) -> Value {
json!({"notes": d.notes.iter().enumerate().map(|(i, n)| note_json(i, n)).collect::<Vec<_>>()})
}
fn color_of(p: &Value) -> Option<Color> {
p.get("color")?;
let c = crate::commands::color_param(p, "color", [1.0, 1.0, 0.51, 1.0]);
Some(Color::rgb(c[0], c[1], c[2]))
fn color_of(cmd: &str, p: &Value) -> Result<Option<Color>> {
if p.get("color").is_none() {
return Ok(None);
}
let c = crate::commands::color_param(cmd, p, "color", [1.0, 1.0, 0.51, 1.0])?;
Ok(Some(Color::rgb(c[0], c[1], c[2])))
}
fn index_of(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
@@ -68,7 +70,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
modified: pdf_date(),
..Default::default()
};
if let Some(c) = color_of(p) {
if let Some(c) = color_of(CMD, p)? {
n.color = c;
}
let i = s.edit("New Note", |d, _| {
@@ -90,7 +92,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = p.get("author").and_then(Value::as_str) {
n.author = a.to_string();
}
if let Some(c) = color_of(p) {
if let Some(c) = color_of(CMD, p)? {
n.color = c;
}
if let Some(o) = p.get("open").and_then(Value::as_bool) {
+3 -2
View File
@@ -34,14 +34,14 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
}
fn f32p(p: &Value, k: &str) -> Option<f32> {
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite())
}
fn color(v: &Value) -> Option<Color> {
match v {
Value::Array(a) if a.len() >= 3 => {
let c: Vec<f32> = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0)))
c.iter().take(4).all(|v| v.is_finite()).then(|| Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0)))
}
Value::String(s) => {
let s = s.trim_start_matches('#');
@@ -743,6 +743,7 @@ fn info(s: &Session, p: &Value) -> Result<Value> {
/// paragraph `box`, `orientation`, character and paragraph keys, starting from the default type
/// styles in the foreground colour. Shared with the Type Mask tools' `type.createSelection`.
pub(crate) fn new_text_layer(s: &Session, p: &Value, cmd: &str) -> Result<TextLayer> {
crate::commands::check_f32_values(cmd, p)?;
let orientation = match p.get("orientation") {
None => Orientation::Horizontal,
Some(Value::String(value)) if value == "horizontal" => Orientation::Horizontal,
@@ -0,0 +1,154 @@
//! #6424: rejecting an overflowing f32 parameter must leave a saveable document and history.
use photocraft_engine::{EngineError, Session};
use serde_json::{Value, json};
fn fresh(mode: &str, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 12, "mode": mode, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s
}
fn saved(s: &Session) -> Vec<u8> {
photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap()
}
fn rejected(cases: Vec<(&str, Value)>) {
let mut failures = Vec::new();
for (id, p) in cases {
let mut s = fresh("rgb", 8);
let before = saved(&s);
let undo = s.active().unwrap().history.undo_label().map(str::to_owned);
let result = s.execute(id, p.clone());
let save = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default());
if !matches!(result, Err(EngineError::BadParams { .. })) {
failures.push(format!("{id} {p}: result={result:?}, save error={:?}", save.as_ref().err()));
continue;
}
assert_eq!(save.unwrap(), before, "rejected {id} must not change the document");
assert_eq!(s.active().unwrap().history.undo_label(), undo.as_deref(), "rejected {id} must not add history");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn overflowing_layer_styles_are_rejected_before_mutation() {
let mut cases = Vec::new();
for value in [1e39, -1e39] {
for (kind, key) in [
("dropShadow", "angle"),
("innerShadow", "angle"),
("outerGlow", "size"),
("innerGlow", "size"),
("stroke", "size"),
("satin", "angle"),
("bevelEmboss", "depth"),
("gradientOverlay", "angle"),
("patternOverlay", "phaseX"),
] {
let id = match kind {
"dropShadow" => "layer.layerStyle.dropShadow",
"innerShadow" => "layer.layerStyle.innerShadow",
"outerGlow" => "layer.layerStyle.outerGlow",
"innerGlow" => "layer.layerStyle.innerGlow",
"stroke" => "layer.layerStyle.stroke",
"satin" => "layer.layerStyle.satin",
"bevelEmboss" => "layer.layerStyle.bevelEmboss",
"gradientOverlay" => "layer.layerStyle.gradientOverlay",
_ => "layer.layerStyle.patternOverlay",
};
let params = json!({key: value});
cases.push((id, params.clone()));
cases.push(("layer.layerStyle.replace", json!({"effects": [{"kind": kind, "params": params}]})));
}
cases.push(("layer.layerStyle.colorOverlay", json!({"color": [value, 0, 0]})));
}
rejected(cases);
}
#[test]
fn overflowing_document_colors_are_rejected_before_mutation() {
let mut cases = Vec::new();
for id in ["layer.newFillLayer.solidColor", "channel.new", "channel.newSpot", "layer.new.artboard", "count.newGroup"] {
for value in [1e39, -1e39] {
for component in 0..4 {
let mut color = [0.5; 4];
color[component] = value;
cases.push((id, json!({"color": color})));
}
}
}
rejected(cases);
}
#[test]
fn overflowing_type_positions_and_colors_are_rejected_before_mutation() {
rejected(vec![
("type.create", json!({"text": "hello", "x": 1e39})),
("type.create", json!({"text": "hello", "y": -1e39})),
("type.create", json!({"text": "hello", "color": [1e39, 0, 0]})),
]);
}
#[test]
fn valid_color_and_style_edits_still_save_and_undo_at_each_depth() {
for mode in ["rgb", "gray", "cmyk", "lab"] {
for depth in [8, 16, 32] {
let mut s = fresh(mode, depth);
let before = saved(&s);
s.execute("layer.layerStyle.dropShadow", json!({"angle": -45, "color": [0.1, 0.3, 0.8]})).unwrap();
let edited = saved(&s);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(saved(&s), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(saved(&s), edited);
s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap();
let bytes = saved(&s);
photocraft_format::load_from_bytes(&bytes).unwrap();
}
}
}
#[test]
fn effect_snapshots_cannot_bypass_numeric_validation() {
let mut fx = serde_json::to_value(photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap()).unwrap();
fx["DropShadow"]["angle"] = json!(1e39);
rejected(vec![(
"layer.layerStyle.replace",
json!({"effects": [
{"kind": "colorOverlay", "params": {"color": "#00ff00"}},
{"kind": "dropShadow", "fx": fx, "params": {}}
]}),
)]);
}
#[test]
fn direct_effect_helpers_do_not_introduce_infinity() {
let original = photocraft_engine::layer_style::effect_from_params("dropShadow", &json!({})).unwrap();
for p in [json!({"angle": 1e39}), json!({"color": [0, -1e39, 0]})] {
assert!(photocraft_engine::layer_style::effect_from_params("dropShadow", &p).is_none());
let mut edited = original.clone();
photocraft_engine::layer_style::overlay_effect(&mut edited, &p);
assert_eq!(edited, original);
}
}
#[test]
fn bad_background_color_does_not_partially_change_tool_colors() {
let mut s = fresh("rgb", 32);
let before = (s.tools.foreground, s.tools.background);
let result = s.execute("tools.setColors", json!({"foreground": "#40a0c0", "background": [0, 1e39, 0]}));
assert!(matches!(result, Err(EngineError::BadParams { .. })));
assert_eq!((s.tools.foreground, s.tools.background), before);
}
#[test]
fn finite_hdr_colors_are_preserved_without_unit_clamping() {
let mut s = fresh("rgb", 32);
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": [4.0, -0.5, 0.25]})).unwrap()["layer"].as_u64().unwrap();
let bytes = saved(&s);
let reopened = photocraft_format::load_from_bytes(&bytes).unwrap();
let layer = reopened.layer(photocraft_doc::LayerId(id)).unwrap();
let photocraft_doc::LayerContent::Fill(photocraft_doc::Fill::Solid(c)) = &layer.content else { panic!("expected a solid fill") };
assert_eq!(c.to_rgb(), [4.0, -0.5, 0.25]);
}