From 130bcb2385e163a1b3b9b1638ca8b78e35a21fc1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:57:27 +0200 Subject: [PATCH] PSD: drop non-finite values from descriptors, EngineData and 32-bit samples; no panic in Warp::apply or Rect::new (#1130) Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/psd_import.rs | 55 ++++++++++++++++++++++++++++----- crates/psd/src/descriptor.rs | 6 ++-- crates/psd/src/engine_data.rs | 33 +++++++++++++++++--- crates/psd/src/lib.rs | 38 +++++++++++++++++++---- crates/psd/src/placed.rs | 32 ++++++++++++++----- crates/psd/src/read.rs | 5 +++ crates/psd/src/tests.rs | 33 ++++++++++++++++++++ 7 files changed, 174 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index b025a7a5..86cc6336 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -333,13 +333,21 @@ fn layer_origin(pl: &effectcraft_psd::Layer, retain: bool, layer: &Layer) -> [f6 [0.0, 0.0] } +/// Sets a scalar property; a non-finite value (from corrupt transforms) keeps the default. fn set_scalar(l: &mut Layer, path: &str, v: f64) { + if !v.is_finite() { + return; + } if let Some(p) = l.props.prop_mut(path) { p.value = Value::Scalar(v); } } +/// Sets a 2D property; a non-finite component (from corrupt transforms) keeps the default. fn set_vec2(l: &mut Layer, path: &str, v: [f64; 2]) { + if !v.iter().all(|c| c.is_finite()) { + return; + } if let Some(p) = l.props.prop_mut(path) { p.value = match p.value { Value::Vec3(o) => Value::Vec3([v[0], v[1], o[2]]), @@ -361,8 +369,10 @@ fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc { d.set_text(&t.text); let [xx, xy, yx, yy, _, _] = t.transform; let scale = (xx * yy - xy * yx).abs().sqrt().max(1e-6); - if let Some(s) = t.style.size { - d.size = s * scale; + // A transform too large to multiply (inf) falls back to 1×; products below stay finite. + let scale = if scale.is_finite() { scale } else { 1.0 }; + if let Some(s) = t.style.size.map(|s| s * scale).filter(|s| s.is_finite() && *s > 0.0) { + d.size = s; } if let Some(f) = &t.style.font { let (fam, style) = font_names(f); @@ -381,19 +391,20 @@ fn text_doc(t: &effectcraft_psd::TextInfo) -> TextDoc { Some(6) => Justify::JustifyAll, _ => Justify::Left, }; - if let Some(tr) = t.style.tracking { + if let Some(tr) = t.style.tracking.filter(|v| v.is_finite()) { d.tracking = tr; } - if let Some(l) = t.style.leading { - d.leading = Some(l * scale); - } + d.leading = t.style.leading.map(|l| l * scale).filter(|l| l.is_finite()); d.faux_bold = t.style.faux_bold; d.faux_italic = t.style.faux_italic; if t.style.box_text && let Some([l, top, r, b]) = t.bounds { - d.box_size = Some([(r - l) * scale, (b - top) * scale]); - d.box_pos = [l * scale, top * scale]; + let (size, pos) = ([(r - l) * scale, (b - top) * scale], [l * scale, top * scale]); + if size.iter().chain(&pos).all(|v| v.is_finite()) { + d.box_size = Some(size); + d.box_pos = pos; + } } d } @@ -716,4 +727,32 @@ mod tests { assert_eq!(desc_blend(Some("linearDodge")), Some(BlendMode::LinearDodge)); assert_eq!(font_names("Inter-Bold"), ("Inter".into(), "Bold".into())); } + + #[test] + fn non_finite_text_values_never_reach_the_layer() { + let style = effectcraft_psd::engine_data::TextStyle { + size: Some(f64::NAN), + tracking: Some(f64::INFINITY), + leading: Some(f64::NEG_INFINITY), + ..Default::default() + }; + let t = effectcraft_psd::TextInfo { + text: "x".into(), + transform: [f64::INFINITY, 0.0, 0.0, 1.0, f64::NAN, 1.0], + style, + bounds: Some([0.0, 0.0, f64::INFINITY, 1.0]), + }; + let d = text_doc(&t); + assert!(d.size.is_finite() && d.tracking.is_finite()); + assert!(d.leading.is_none_or(f64::is_finite)); + assert!(d.box_size.is_none_or(|b| b.iter().all(|v| v.is_finite()))); + let mut proj = Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_24, Tick::ZERO); + let l = text_layer(&mut proj, &comp, "t", &t); + match l.props.prop("transform/position").map(|p| &p.value) { + Some(Value::Vec2(v)) => assert!(v.iter().all(|c| c.is_finite())), + Some(Value::Vec3(v)) => assert!(v.iter().all(|c| c.is_finite())), + other => panic!("unexpected position {other:?}"), + } + } } diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index b28c3479..0d2c39bb 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -174,17 +174,17 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result { let n = r.u32()?; DValue::ObjectArray(n, read_descriptor_at(r, depth + 1)?) } - b"doub" => DValue::Double(r.f64()?), + b"doub" => DValue::Double(r.f64_finite()?), b"UntF" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); - DValue::UnitFloat(unit, r.f64()?) + DValue::UnitFloat(unit, r.f64_finite()?) } b"UnFl" => { let unit = String::from_utf8_lossy(&r.tag()?).into_owned(); let n = r.u32()? as usize; let mut v = Vec::with_capacity(n.min(1024)); for _ in 0..n { - v.push(r.f64()?); + v.push(r.f64_finite()?); } DValue::UnitFloats(unit, v) } diff --git a/crates/psd/src/engine_data.rs b/crates/psd/src/engine_data.rs index b0a177d0..83111813 100644 --- a/crates/psd/src/engine_data.rs +++ b/crates/psd/src/engine_data.rs @@ -132,7 +132,11 @@ impl P<'_> { Some(match tok { "true" => EValue::Bool(true), "false" => EValue::Bool(false), - t => t.parse::().map(EValue::Num).unwrap_or_else(|_| EValue::Name(t.to_string())), + // "nan", "inf" and overflowing literals parse as f64 but are not numbers here. + t => match t.parse::() { + Ok(n) if n.is_finite() => EValue::Num(n), + _ => EValue::Name(t.to_string()), + }, }) } } @@ -213,6 +217,11 @@ pub struct TextStyle { pub box_text: bool, } +/// Sane upper bound for a font size in points (Photoshop's own limit is 1296 pt). +const MAX_FONT_SIZE: f64 = 10_000.0; +/// Sane bound for tracking (1/1000 em) and leading (points). +const MAX_SPACING: f64 = 100_000.0; + pub fn text_style(ed: &EValue) -> TextStyle { let engine = ed.get("EngineDict").unwrap_or(ed); let resources = ed.get("ResourceDict").or_else(|| ed.get("DocumentResources")); @@ -233,11 +242,11 @@ pub fn text_style(ed: &EValue) -> TextStyle { let box_text = engine.path("Rendered/Shapes/Children/0/ShapeType").and_then(EValue::num) == Some(1.0); TextStyle { font, - size: attr("FontSize").and_then(EValue::num), + size: attr("FontSize").and_then(EValue::num).filter(|s| *s > 0.0).map(|s| s.min(MAX_FONT_SIZE)), color, justification, - tracking: attr("Tracking").and_then(EValue::num), - leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num) }, + tracking: attr("Tracking").and_then(EValue::num).map(|t| t.clamp(-MAX_SPACING, MAX_SPACING)), + leading: if auto_leading { None } else { attr("Leading").and_then(EValue::num).map(|l| l.clamp(-MAX_SPACING, MAX_SPACING)) }, faux_bold: attr("FauxBold") == Some(&EValue::Bool(true)), faux_italic: attr("FauxItalic") == Some(&EValue::Bool(true)), box_text, @@ -285,4 +294,20 @@ mod tests { let _ = parse(b"]]]>>>> /x"); let _ = parse(&[0xFF; 64]); } + + #[test] + fn non_finite_and_out_of_range_numbers_are_not_numbers() { + let b = b"<< /FontSize nan /Tracking inf /Leading -inf /Other 1e999 >>"; + let v = parse(b); + assert_eq!(v.get("FontSize").and_then(EValue::num), None); + assert_eq!(v.get("Other").and_then(EValue::num), None); + let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize nan /Tracking inf /Leading -inf /AutoLeading false >> >> >> ] >> >> >>"); + let st = text_style(&ed); + assert_eq!((st.size, st.tracking, st.leading), (None, None, None)); + let ed = parse(b"<< /EngineDict << /StyleRun << /RunArray [ << /StyleSheet << /StyleSheetData << /FontSize 1e300 /Tracking -1e300 /AutoLeading false /Leading 1e300 >> >> >> ] >> >> >>"); + let st = text_style(&ed); + assert_eq!(st.size, Some(MAX_FONT_SIZE)); + assert_eq!(st.tracking, Some(-MAX_SPACING)); + assert_eq!(st.leading, Some(MAX_SPACING)); + } } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4..2848f4e1 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -120,7 +120,7 @@ pub struct Rect { impl Rect { pub fn new(left: i32, top: i32, width: i32, height: i32) -> Rect { - Rect { top, left, bottom: top + height, right: left + width } + Rect { top, left, bottom: top.saturating_add(height), right: left.saturating_add(width) } } pub fn width(&self) -> u32 { (i64::from(self.right) - i64::from(self.left)).clamp(0, i64::from(u32::MAX)) as u32 @@ -597,6 +597,10 @@ impl Psd { for v in &mut t { *v = r.f64()?; } + // A transform with NaN/inf is unusable: fall back to the identity. + if t.iter().any(|v| !v.is_finite()) { + t = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + } let _text_ver = r.u16()?; let _desc_ver = r.u32()?; let d = descriptor::read_descriptor(&mut r)?; @@ -651,9 +655,13 @@ impl Psd { } b"expA" => { let _ver = r.u16()?; - let exposure = r.f32()? as f64; - let offset = r.f32()? as f64; - let gamma = r.f32()? as f64; + let mut finite = |default: f64| -> Result { + let v = f64::from(r.f32()?); + Ok(if v.is_finite() { v } else { default }) + }; + let exposure = finite(0.0)?; + let offset = finite(0.0)?; + let gamma = finite(1.0)?; l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma }); } b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" if l.adjustment.is_none() => { @@ -698,7 +706,7 @@ impl Psd { let _ty = r.u32()?; let mut t = [0.0; 8]; for v in &mut t { - *v = r.f64()?; + *v = r.f64_finite()?; } l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None, warp: None }); } @@ -877,7 +885,25 @@ impl Psd { out } 16 => raw.as_chunks::<2>().0.iter().take(w * h).map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(), - 32 => raw.as_chunks::<4>().0.iter().take(w * h).map(|c| f32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(), + // NaN reads as 0, +inf as white (1.0, like the integer depths' maximum), -inf as 0. + 32 => raw + .as_chunks::<4>() + .0 + .iter() + .take(w * h) + .map(|c| { + let v = f32::from_be_bytes([c[0], c[1], c[2], c[3]]); + if v.is_nan() { + 0.0 + } else if v == f32::INFINITY { + 1.0 + } else if v == f32::NEG_INFINITY { + 0.0 + } else { + v + } + }) + .collect(), _ => raw.iter().take(w * h).map(|&b| b as f32 / 255.0).collect(), } } diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs index 156a8e27..bd8a9a9b 100644 --- a/crates/psd/src/placed.rs +++ b/crates/psd/src/placed.rs @@ -75,8 +75,8 @@ impl Warp { if let Some(DValue::ObjectArray(_, pts)) = env.get("meshPoints") { let xs = floats(pts.get("Hrzn")); let ys = floats(pts.get("Vrtc")); - let n = (3 * cols + 1) * (3 * rows + 1); - if xs.len() == n && ys.len() == n { + let n = cols.checked_mul(3).zip(rows.checked_mul(3)).and_then(|(c, r)| (c + 1).checked_mul(r + 1)); + if n.is_some_and(|n| xs.len() == n && ys.len() == n) { w.cols = cols; w.rows = rows; w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect(); @@ -94,7 +94,8 @@ impl Warp { let (bw, bh) = (r - l, b - t); let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh); if self.style == "warpCustom" { - return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + // A mesh that does not match cols × rows (public fields) leaves the point where it is. + return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)).unwrap_or(p); } // Styles work in a centred frame (pixels), optionally rotated a quarter turn. let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) }; @@ -110,13 +111,16 @@ impl Warp { } /// A point of the custom quilt mesh at (u, v) ∈ [0, 1]². - fn mesh_point(&self, u: f64, v: f64) -> [f64; 2] { + fn mesh_point(&self, u: f64, v: f64) -> Option<[f64; 2]> { let (cols, rows) = (self.cols, self.rows); + if cols == 0 || rows == 0 { + return None; + } let pu = (u * cols as f64).min(cols as f64 - 1e-9); let pv = (v * rows as f64).min(rows as f64 - 1e-9); let (ci, cj) = (pu.floor() as usize, pv.floor() as usize); let (fu, fv) = (pu - ci as f64, pv - cj as f64); - let stride = 3 * cols + 1; + let stride = cols.checked_mul(3)?.checked_add(1)?; let bern = |t: f64| { let s = 1.0 - t; [s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t] @@ -125,13 +129,14 @@ impl Warp { let mut out = [0.0; 2]; for j in 0..4 { for i in 0..4 { - let q = self.mesh[(3 * cj + j) * stride + 3 * ci + i]; + let idx = cj.saturating_mul(3).saturating_add(j).saturating_mul(stride).saturating_add(ci.saturating_mul(3)).saturating_add(i); + let q = self.mesh.get(idx)?; let k = bu[i] * bv[j]; out[0] += q[0] * k; out[1] += q[1] * k; } } - out + Some(out) } } @@ -407,4 +412,17 @@ mod tests { assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10)); assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid"); } + + #[test] + fn custom_warp_with_a_missing_or_short_mesh_does_not_panic() { + let base = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, ..Default::default() }; + // Empty and too-short meshes leave the point where it is. + assert_eq!(base.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + let short = Warp { mesh: vec![[0.0, 0.0]; 5], ..base.clone() }; + assert_eq!(short.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + for (cols, rows) in [(0, 0), (0, 3), (usize::MAX, 1), (1, usize::MAX), (usize::MAX, usize::MAX)] { + let w = Warp { cols, rows, ..base.clone() }; + assert_eq!(w.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]); + } + } } diff --git a/crates/psd/src/read.rs b/crates/psd/src/read.rs index 97651b2b..9df7d8b6 100644 --- a/crates/psd/src/read.rs +++ b/crates/psd/src/read.rs @@ -58,6 +58,11 @@ impl<'a> Reader<'a> { pub fn f64(&mut self) -> Result { Ok(f64::from_bits(self.u64()?)) } + /// A big-endian double where NaN and ±infinity read as 0 (they are never meaningful in a file). + pub fn f64_finite(&mut self) -> Result { + let v = self.f64()?; + Ok(if v.is_finite() { v } else { 0.0 }) + } pub fn tag(&mut self) -> Result<[u8; 4]> { let b = self.bytes(4)?; Ok([b[0], b[1], b[2], b[3]]) diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 235163b6..10cb870d 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -252,3 +252,36 @@ fn huge_layer_bounds_error_instead_of_allocating() { let p = Psd::parse(bytes).unwrap(); assert!(matches!(p.layer_pixels(0, false), Err(Error::Unsupported(_)))); } + +#[test] +fn non_finite_transform_and_exposure_values_are_dropped() { + let mut d = WDoc::new(16, 16); + d.layers = vec![ + WLayer::empty("Title", vec![text_block("x", [f64::NAN, f64::INFINITY], "Inter", 12.0, [0.0; 3], 0)]), + WLayer::empty("Exp", vec![exposure_block(f32::NAN, f32::INFINITY, f32::NEG_INFINITY)]), + ]; + let p = Psd::parse(write(&d)).unwrap(); + let t = p.layers[0].text.as_ref().unwrap(); + assert!(t.transform.iter().all(|v| v.is_finite())); + assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + assert_eq!(p.layers[1].adjustment, Some(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 })); +} + +#[test] +fn non_finite_32_bit_samples_become_finite() { + let mut d = WDoc::new(4, 4); + d.depth = 32; + d.layers = vec![WLayer::solid("L", Rect::new(0, 0, 4, 4), [0.5, 0.5, 0.5, 1.0])]; + let p = Psd::parse(write(&d)).unwrap(); + let raw: Vec = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 0.25].iter().flat_map(|v| v.to_be_bytes()).collect(); + let s = p.samples(&raw, 4, 1); + assert_eq!(s, vec![0.0, 1.0, 0.0, 0.25]); + assert!(p.to_rgb(&[Some(s.clone()), Some(s.clone()), Some(s)], 1).iter().all(|v| v.is_finite())); +} + +#[test] +fn rect_new_near_i32_max_saturates() { + let r = Rect::new(i32::MAX, i32::MAX, 1, i32::MAX); + assert_eq!((r.right, r.bottom), (i32::MAX, i32::MAX)); + assert!(r.is_empty()); +}