Merge pull request #1131 from vangel76/fix-psd-nonfinite-and-panic-paths

# Conflicts:
#	crates/psd/src/tests.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:28:31 -04:00
commit e02c60c08a
7 files changed
+174 -28

No files matched your search

+47 -8
View File
@@ -337,13 +337,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]]),
@@ -365,8 +373,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);
@@ -385,19 +395,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
}
@@ -720,4 +731,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:?}"),
}
}
}
+3 -3
View File
@@ -174,17 +174,17 @@ fn read_value(r: &mut Reader, ty: &[u8; 4], depth: usize) -> Result<DValue> {
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)
}
+29 -4
View File
@@ -132,7 +132,11 @@ impl P<'_> {
Some(match tok {
"true" => EValue::Bool(true),
"false" => EValue::Bool(false),
t => t.parse::<f64>().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::<f64>() {
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));
}
}
+32 -6
View File
@@ -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
@@ -601,6 +601,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)?;
@@ -655,9 +659,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<f64> {
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() => {
@@ -702,7 +710,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 });
}
@@ -908,7 +916,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(),
}
}
+25 -7
View File
@@ -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]);
}
}
}
+5
View File
@@ -58,6 +58,11 @@ impl<'a> Reader<'a> {
pub fn f64(&mut self) -> Result<f64> {
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<f64> {
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]])
+33
View File
@@ -318,3 +318,36 @@ fn thousands_of_nested_groups_do_not_overflow_a_small_stack() {
.unwrap();
assert_eq!(h.join().unwrap(), 5000 - MAX_GROUP_DEPTH);
}
#[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<u8> = [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());
}