Reject out-of-range leading, baseline shift and scales in type styles (#6196)

Found while testing crates/engine/src/type_cmds.rs:287

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 03:33:09 -04:00
committed by GitHub
co-authored by Claude Haiku 5.5
parent 16774eea6f
commit 275d4cbcf7
2 changed files with 84 additions and 1 deletions
+28 -1
View File
@@ -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(())
}
+56
View File
@@ -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");
}