mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 11:18:11 +02:00
fix(engine): reject numeric overflow before document edits
This commit is contained in:
1 parent
2a952f164c
commit
3f216a0cd1
13 files changed
+270
-51
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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, ¶ms)?;
|
||||
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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user