diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index d6b92e840c..cd4ca7a27b 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -89,6 +89,8 @@ pub const TYPE_SIZE_MIN_PT: f64 = 0.1; pub const TYPE_SIZE_MAX_PT: f64 = 1296.0; pub const TRACKING_MIN: f64 = -1000.0; pub const TRACKING_MAX: f64 = 10_000.0; +/// Photoshop's horizontal and vertical scale range, in percent. +pub const SCALE_MAX_PERCENT: f64 = 1000.0; /// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or /// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`). @@ -104,7 +106,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> { } } -/// Validates text metrics that can otherwise exceed the rasterizer's supported range. +/// Validates text metrics (size, tracking, leading, baseline shift, scales) that can otherwise +/// exceed the rasterizer's supported range or overflow `f32`, which a .pcraft manifest cannot hold. pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { if let Some(v) = p.get("size") { match v.as_f64() { @@ -118,6 +121,30 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { _ => return Err(format!("tracking must be between {TRACKING_MIN} and {TRACKING_MAX} (1/1000 em)")), } } + if let Some(v) = p.get("leading") { + let (min, max) = (f64::from(LEADING_MIN_PT), f64::from(LEADING_MAX_PT)); + match v { + Value::Null => {} + Value::String(a) if a == "auto" => {} + Value::Number(n) if n.as_f64().is_some_and(|x| x.is_finite() && (min..=max).contains(&x)) => {} + _ => return Err(format!("leading must be between {min} and {max} pt, or \"auto\"")), + } + } + if let Some(v) = p.get("baselineShift") { + let max = f64::from(BASELINE_SHIFT_MAX_PT); + match v.as_f64() { + Some(shift) if shift.is_finite() && shift.abs() <= max => {} + _ => return Err(format!("baselineShift must be between -{max} and {max} pt")), + } + } + for key in ["horizontalScale", "verticalScale"] { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(scale) if scale.is_finite() && (0.0..=SCALE_MAX_PERCENT).contains(&scale) => {} + _ => return Err(format!("{key} must be between 0 and {SCALE_MAX_PERCENT} %")), + } + } + } Ok(()) } diff --git a/crates/engine/tests/nonfinite_style.rs b/crates/engine/tests/nonfinite_style.rs new file mode 100644 index 0000000000..994f3dbe6e --- /dev/null +++ b/crates/engine/tests/nonfinite_style.rs @@ -0,0 +1,56 @@ +//! Character style numbers that overflow `f32` are refused, so the document stays saveable. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn fixture() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + let id = s.execute("type.create", json!({"text": "Hi", "size": 24})).unwrap()["layer"].as_u64().unwrap(); + (s, id) +} + +#[test] +fn out_of_range_style_numbers_are_refused() { + for key in ["leading", "baselineShift", "horizontalScale", "verticalScale"] { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [1e39, -1e39, 1e12] { + let r = s.execute("type.setStyle", json!({"layer": id, key: value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{key}={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "{key}: no history step"); + let r = s.execute("type.create", json!({"text": "x", key: 1e39})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "create {key}: {r:?}"); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_{key}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("{key}: save failed: {e:?}")); + } +} + +#[test] +fn in_range_style_numbers_still_apply_and_save() { + let (mut s, id) = fixture(); + s.execute("type.setStyle", json!({"layer": id, "leading": 30, "baselineShift": -4, "horizontalScale": 120, "verticalScale": 80})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "leading": "auto"})).unwrap(); + let dir = std::env::temp_dir().join(format!("pc_nonfinite_style_ok_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.pcraft"); + let saved = s.execute("file.saveACopy", json!({"path": path.to_string_lossy()})); + std::fs::remove_dir_all(&dir).ok(); + saved.unwrap_or_else(|e| panic!("save failed: {e:?}")); +} + +#[test] +fn non_numeric_leading_is_refused() { + let (mut s, id) = fixture(); + let past = s.active().unwrap().history.past_len(); + for value in [json!("18pt"), json!("Auto "), json!(true), json!([12])] { + let r = s.execute("type.setStyle", json!({"layer": id, "leading": value})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "leading={value}: {r:?}"); + } + assert_eq!(s.active().unwrap().history.past_len(), past, "no history step"); +}