diff --git a/crates/psd/src/adjust.rs b/crates/psd/src/adjust.rs new file mode 100644 index 00000000..f1f94dff --- /dev/null +++ b/crates/psd/src/adjust.rs @@ -0,0 +1,295 @@ +//! Adjustment and solid colour fill layers: the settings a reader needs to show them, as +//! read-only views of their blocks (`nvrt`, `brit`, `CgEd`, `hue2`, `hue `, `levl`, `expA`, +//! `SoCo`) and of the layer effects (`lfx2`). +//! +//! Only the composite (master) settings are read: the per-channel levels and the per-range +//! hue/saturation edits are not. The blocks stay raw in the model and write back unchanged. +//! Came from EffectCraft's `effectcraft-psd`. + +use crate::descriptor::Descriptor; +use crate::error::Result; +use crate::io::Reader; +use crate::layer::LayerRecord; + +/// The adjustment a layer applies. +#[derive(Clone, Debug, PartialEq)] +pub enum Adjustment { + /// Invert (`nvrt`). + Invert, + /// Brightness/Contrast (`brit`, or `CgEd` when the layer has one). + BrightnessContrast { + /// Brightness. + brightness: f64, + /// Contrast. + contrast: f64, + /// The legacy algorithm: always for a `brit` block alone, `useLegacy` for `CgEd`. + legacy: bool, + }, + /// Hue/Saturation (`hue2`, `hue `): the colorize values when colorizing, else the master edit. + HueSaturation { + /// Hue. + hue: f64, + /// Saturation. + saturation: f64, + /// Lightness. + lightness: f64, + /// Colorize. + colorize: bool, + }, + /// Levels (`levl`): the composite channel's record. + Levels { + /// Input black, 0..255. + in_black: f64, + /// Input white, 0..255. + in_white: f64, + /// Gamma (the file stores it times 100). + gamma: f64, + /// Output black, 0..255. + out_black: f64, + /// Output white, 0..255. + out_white: f64, + }, + /// Exposure (`expA`). A value that is not finite reads as its default (0, 0, 1). + Exposure { + /// Exposure. + exposure: f64, + /// Offset. + offset: f64, + /// Gamma correction. + gamma: f64, + }, + /// Another adjustment, by its block key (`curv`, `blnc`, `post`, `thrs`, `selc`, `mixr`, + /// `phfl`, `vibA`, `blwh`, `grdm`, `clrL`): its settings are in the layer's block. + Other([u8; 4]), +} + +/// Keys of the adjustment blocks that are reported as [`Adjustment::Other`]. +const OTHER_KEYS: [&[u8; 4]; 11] = [b"curv", b"blnc", b"post", b"thrs", b"selc", b"mixr", b"phfl", b"vibA", b"blwh", b"grdm", b"clrL"]; + +impl Adjustment { + /// Reads the data of the adjustment block `key`. `None` when `key` is not one of the typed + /// adjustment blocks. + pub fn from_block(key: &[u8; 4], data: &[u8]) -> Option> { + let mut r = Reader::new(data); + Some(match key { + b"nvrt" => Ok(Adjustment::Invert), + b"brit" => brightness(&mut r), + b"CgEd" => content_generator(&mut r), + b"hue2" | b"hue " => hue_saturation(&mut r), + b"levl" => levels(&mut r), + b"expA" => exposure(&mut r), + _ => return None, + }) + } +} + +fn brightness(r: &mut Reader<'_>) -> Result { + Ok(Adjustment::BrightnessContrast { brightness: f64::from(r.i16()?), contrast: f64::from(r.i16()?), legacy: true }) +} + +fn content_generator(r: &mut Reader<'_>) -> Result { + let _version = r.u32()?; + let d = Descriptor::read(r)?; + Ok(Adjustment::BrightnessContrast { + brightness: d.get_number("Brgh").unwrap_or(0.0), + contrast: d.get_number("Cntr").unwrap_or(0.0), + legacy: d.get_bool("useLegacy").unwrap_or(false), + }) +} + +fn hue_saturation(r: &mut Reader<'_>) -> Result { + let _version = r.u16()?; + let colorize = r.u8()? != 0; + r.skip(1)?; + let mut three = || -> Result<[f64; 3]> { Ok([f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)]) }; + let colorized = three()?; + let master = three()?; + let [hue, saturation, lightness] = if colorize { colorized } else { master }; + Ok(Adjustment::HueSaturation { hue, saturation, lightness, colorize }) +} + +fn levels(r: &mut Reader<'_>) -> Result { + let _version = r.u16()?; + let (in_black, in_white, out_black, out_white, gamma) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?, r.i16()?); + Ok(Adjustment::Levels { + in_black: f64::from(in_black), + in_white: f64::from(in_white), + gamma: f64::from(gamma) / 100.0, + out_black: f64::from(out_black), + out_white: f64::from(out_white), + }) +} + +fn exposure(r: &mut Reader<'_>) -> Result { + let _version = r.u16()?; + let mut finite = |default: f64| -> Result { + let v = f64::from(f32::from_be_bytes(r.array()?)); + Ok(if v.is_finite() { v } else { default }) + }; + Ok(Adjustment::Exposure { exposure: finite(0.0)?, offset: finite(0.0)?, gamma: finite(1.0)? }) +} + +/// Reads the data of a `SoCo` block: the fill colour as RGB in 0..1 (see +/// [`Descriptor::get_color`]), `None` for a colour model that is not RGB or gray. +pub fn solid_color(data: &[u8]) -> Result> { + let mut r = Reader::new(data); + let _version = r.u32()?; + Ok(Descriptor::read(&mut r)?.get_color("Clr ")) +} + +/// Reads the data of an `lfx2` block: the layer effects descriptor. +pub fn effects(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let _effects_version = r.u32()?; + let _descriptor_version = r.u32()?; + Descriptor::read(&mut r) +} + +impl LayerRecord { + /// The adjustment the layer applies, when it is an adjustment layer. Blocks are taken in + /// order: a typed block replaces what came before, except that a `brit` block or one of + /// the untyped adjustments ([`Adjustment::Other`]) only counts when nothing was found yet + /// (Photoshop writes `brit` beside `CgEd`). A malformed block is skipped. + pub fn adjustment(&self) -> Option { + let mut found = None; + for b in &self.blocks { + if OTHER_KEYS.contains(&&b.key) { + found = found.or(Some(Adjustment::Other(b.key))); + } else if let Some(Ok(a)) = Adjustment::from_block(&b.key, &b.data) + && (&b.key != b"brit" || found.is_none()) + { + found = Some(a); + } + } + found + } + + /// The colour of a solid colour fill layer (`SoCo`), RGB in 0..1. + pub fn solid_color(&self) -> Option<[f64; 3]> { + solid_color(&self.block(b"SoCo")?.data).ok()? + } + + /// The layer effects descriptor (`lfx2`). A malformed block counts as absent. + pub fn effects(&self) -> Option { + effects(&self.block(b"lfx2")?.data).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::Value; + use crate::io::WriteExt; + use crate::tagged::TaggedBlock; + + fn layer(blocks: Vec<([u8; 4], Vec)>) -> LayerRecord { + LayerRecord { blocks: blocks.into_iter().map(|(k, d)| TaggedBlock::new(k, d)).collect(), ..Default::default() } + } + + fn i16s(prefix: &[u8], v: &[i16]) -> Vec { + let mut out = prefix.to_vec(); + for x in v { + out.put_i16(*x); + } + out + } + + fn versioned(d: Descriptor) -> Vec { + let mut out = Vec::new(); + out.put_u32(16); + d.write(&mut out); + out + } + + #[test] + fn each_typed_adjustment_reads_its_settings() { + assert_eq!(layer(vec![(*b"nvrt", vec![])]).adjustment(), Some(Adjustment::Invert)); + let brit = i16s(&[], &[40, -15, 127, 0]); + assert_eq!( + layer(vec![(*b"brit", brit.clone())]).adjustment(), + Some(Adjustment::BrightnessContrast { brightness: 40.0, contrast: -15.0, legacy: true }) + ); + // Version 2, colorize off: the master edit is the second triple. + let hue = i16s(&[0, 2, 0, 0], &[10, 20, 30, -40, 50, -60]); + assert_eq!( + layer(vec![(*b"hue2", hue.clone())]).adjustment(), + Some(Adjustment::HueSaturation { hue: -40.0, saturation: 50.0, lightness: -60.0, colorize: false }) + ); + let mut colorized = hue; + colorized[2] = 1; + assert_eq!( + layer(vec![(*b"hue ", colorized)]).adjustment(), + Some(Adjustment::HueSaturation { hue: 10.0, saturation: 20.0, lightness: 30.0, colorize: true }) + ); + let levl = i16s(&[0, 2], &[12, 240, 5, 250, 150]); + assert_eq!( + layer(vec![(*b"levl", levl)]).adjustment(), + Some(Adjustment::Levels { in_black: 12.0, in_white: 240.0, gamma: 1.5, out_black: 5.0, out_white: 250.0 }) + ); + let mut exp = vec![0, 1]; + for v in [1.5f32, -0.25, 0.5] { + exp.extend_from_slice(&v.to_be_bytes()); + } + assert_eq!(layer(vec![(*b"expA", exp)]).adjustment(), Some(Adjustment::Exposure { exposure: 1.5, offset: -0.25, gamma: 0.5 })); + assert_eq!(layer(vec![(*b"curv", vec![1, 2, 3])]).adjustment(), Some(Adjustment::Other(*b"curv"))); + assert_eq!(layer(vec![(*b"luni", vec![0; 4])]).adjustment(), None); + assert!(Adjustment::from_block(b"luni", &[]).is_none()); + } + + #[test] + fn the_descriptor_form_of_brightness_wins_over_the_legacy_block_in_either_order() { + let cged = versioned( + Descriptor::new("null").with("Brgh", Value::Integer(25)).with("Cntr", Value::Integer(-5)).with("useLegacy", Value::Boolean(false)), + ); + let brit = i16s(&[], &[1, 2, 127, 0]); + let want = Some(Adjustment::BrightnessContrast { brightness: 25.0, contrast: -5.0, legacy: false }); + assert_eq!(layer(vec![(*b"brit", brit.clone()), (*b"CgEd", cged.clone())]).adjustment(), want); + assert_eq!(layer(vec![(*b"CgEd", cged), (*b"brit", brit)]).adjustment(), want); + } + + #[test] + fn exposure_values_that_are_not_finite_read_as_their_defaults() { + let mut exp = vec![0, 1]; + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + exp.extend_from_slice(&v.to_be_bytes()); + } + assert_eq!(layer(vec![(*b"expA", exp)]).adjustment(), Some(Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 })); + } + + #[test] + fn a_truncated_block_is_an_error_and_the_layer_skips_it() { + for (key, len) in [(b"brit", 4usize), (b"hue2", 16), (b"levl", 12), (b"expA", 14)] { + for cut in 0..len { + assert!(matches!(Adjustment::from_block(key, &vec![0; cut]), Some(Err(_))), "{} cut {cut}", String::from_utf8_lossy(key)); + } + assert!(matches!(Adjustment::from_block(key, &vec![0; len]), Some(Ok(_)))); + } + assert_eq!(layer(vec![(*b"levl", vec![0; 3])]).adjustment(), None); + // The damaged block does not hide a good one. + assert_eq!(layer(vec![(*b"nvrt", vec![]), (*b"levl", vec![0; 3])]).adjustment(), Some(Adjustment::Invert)); + } + + #[test] + fn solid_colour_and_effects_read_their_descriptors() { + let rgb = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(127.5)).with("Bl ", Value::Double(0.0)); + let soco = versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(rgb))); + let mut lfx = vec![0, 0, 0, 0]; + lfx.extend(versioned(Descriptor::new("null").with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }))); + let l = layer(vec![(*b"SoCo", soco.clone()), (*b"lfx2", lfx)]); + assert_eq!(l.solid_color(), Some([1.0, 0.5, 0.0])); + assert_eq!(l.effects().unwrap().get_number("Scl "), Some(100.0)); + let float = Descriptor::new("RGBC") + .with("redFloat", Value::Double(0.25)) + .with("greenFloat", Value::Double(0.5)) + .with("blueFloat", Value::Double(1.0)); + assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(float)))).unwrap(), Some([0.25, 0.5, 1.0])); + let gray = Descriptor::new("Grsc").with("Gry ", Value::Double(25.0)); + assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(gray)))).unwrap(), Some([0.75; 3])); + // A colour model that is not RGB or gray is no colour; a cut block is an error. + let lab = Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0)); + assert_eq!(solid_color(&versioned(Descriptor::new("null").with("Clr ", Value::Descriptor(lab)))).unwrap(), None); + assert!(solid_color(&soco[..soco.len() - 1]).is_err()); + assert_eq!(layer(vec![(*b"SoCo", vec![0; 5]), (*b"lfx2", vec![0; 5])]).solid_color(), None); + assert_eq!(layer(vec![(*b"lfx2", vec![0; 5])]).effects(), None); + } +} diff --git a/crates/psd/src/asl.rs b/crates/psd/src/asl.rs index c24be52e..4088bc1b 100644 --- a/crates/psd/src/asl.rs +++ b/crates/psd/src/asl.rs @@ -65,28 +65,24 @@ impl<'a> Rd<'a> { let b = self.take(2)?; Ok(u16::from_be_bytes([b[0], b[1]])) } + /// The next big-endian `u32`. fn u32(&mut self) -> Result { let b = self.take(4)?; Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) } + /// Bytes left to read. fn remaining(&self) -> usize { self.b.len().saturating_sub(self.p) } } -fn text(d: &Descriptor, key: &str) -> String { - match d.get(key) { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - } -} - +/// One style from an ASL body: its identity descriptor, then the style itself. fn read_style(body: &[u8]) -> Result { let (identity, used) = VersionedDescriptor::parse_prefix(body)?; let rest = body.get(used..).ok_or(PsdError::UnexpectedEof { offset: used, needed: 4 })?; let (style, _) = VersionedDescriptor::parse_prefix(rest)?; let id = &identity.descriptor; - Ok(AslStyle { name: text(id, "Nm "), id: text(id, "Idnt"), style: style.descriptor }) + Ok(AslStyle { name: id.get_text("Nm ").unwrap_or_default(), id: id.get_text("Idnt").unwrap_or_default(), style: style.descriptor }) } /// Parses an `.asl` style library. diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index 93dcb310..f3afae64 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -7,6 +7,8 @@ use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt, read_unicode_units, write_unicode_units}; +mod lookup; + /// Maximum nesting depth accepted when parsing (fuzz safety). pub const MAX_DEPTH: usize = 64; @@ -229,6 +231,18 @@ pub enum Value { ObjectArray(ObjectArray), } +impl Value { + /// The value as a number: a double, a unit float, an integer or a large integer. + pub fn as_number(&self) -> Option { + match self { + Value::Double(v) | Value::UnitFloat { value: v, .. } => Some(*v), + Value::Integer(v) => Some(f64::from(*v)), + Value::LargeInteger(v) => Some(*v as f64), + _ => None, + } + } +} + /// `ObAr` object array. Undocumented in the Adobe spec; the layout follows /// ag-psd (MIT): a u32 (observed as the element count / version 16) followed /// by a descriptor-shaped body whose values are usually `UnFl`. diff --git a/crates/psd/src/descriptor/lookup.rs b/crates/psd/src/descriptor/lookup.rs new file mode 100644 index 00000000..5922ffe2 --- /dev/null +++ b/crates/psd/src/descriptor/lookup.rs @@ -0,0 +1,123 @@ +use super::{Descriptor, Value}; + +impl Descriptor { + /// The boolean at `key`. + pub fn get_bool(&self, key: &str) -> Option { + match self.get(key)? { + Value::Boolean(b) => Some(*b), + _ => None, + } + } + + /// The number at `key`, from any numeric value type. + pub fn get_number(&self, key: &str) -> Option { + match self.get(key)? { + Value::Double(v) => Some(*v), + Value::UnitFloat { value, .. } => Some(*value), + Value::Integer(v) => Some(f64::from(*v)), + Value::LargeInteger(v) => Some(*v as f64), + _ => None, + } + } + + /// The text at `key`. + pub fn get_text(&self, key: &str) -> Option { + match self.get(key)? { + Value::Text(t) => Some(t.to_string_lossy()), + _ => None, + } + } + + /// The enumerated value at `key`. + pub fn get_enum(&self, key: &str) -> Option { + match self.get(key)? { + Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()), + _ => None, + } + } + + /// The object at `key`, plain or global. + pub fn get_object(&self, key: &str) -> Option<&Descriptor> { + match self.get(key)? { + Value::Descriptor(o) | Value::GlobalObject(o) => Some(o), + _ => None, + } + } + + /// The numbers at `key`: the values of a unit float array, or the numbers of a list (items + /// that are not numbers are left out). Empty when there is neither. + pub fn get_numbers(&self, key: &str) -> Vec { + match self.get(key) { + Some(Value::UnitFloats { values, .. }) => values.clone(), + Some(Value::List(l)) => l.iter().filter_map(Value::as_number).collect(), + _ => Vec::new(), + } + } + /// The raw data (`tdta`) at `key`. + pub fn get_raw_data(&self, key: &str) -> Option<&[u8]> { + match self.get(key)? { + Value::RawData(v) => Some(v), + _ => None, + } + } + /// The colour object at `key` as RGB in 0..1: an `RGBC` object with `Rd ` / `Grn ` / `Bl ` + /// in 0..255 or with `redFloat` / `greenFloat` / `blueFloat` in 0..1, or a gray (`Gry `, in + /// percent of ink). Other colour models are `None`. + pub fn get_color(&self, key: &str) -> Option<[f64; 3]> { + let c = self.get_object(key)?; + if let (Some(r), Some(g), Some(b)) = (c.get_number("Rd "), c.get_number("Grn "), c.get_number("Bl ")) { + return Some([r / 255.0, g / 255.0, b / 255.0]); + } + if let (Some(r), Some(g), Some(b)) = (c.get_number("redFloat"), c.get_number("greenFloat"), c.get_number("blueFloat")) { + return Some([r, g, b]); + } + c.get_number("Gry ").map(|g| [1.0 - g / 100.0; 3]) + } +} + +#[cfg(test)] +mod tests { + use super::super::{Id, UnicodeString}; + use super::*; + + fn sample() -> Descriptor { + Descriptor::new("null") + .with("enab", Value::Boolean(true)) + .with("doub", Value::Double(0.25)) + .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.5 }) + .with("long", Value::Integer(-5)) + .with("comp", Value::LargeInteger(1 << 40)) + .with("Nm ", Value::Text(UnicodeString::new("Café"))) + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Nrml") }) + .with("Objc", Value::Descriptor(Descriptor::new("Clr ").with("Rd ", Value::Double(1.0)))) + .with("GlbO", Value::GlobalObject(Descriptor::new("Grsc"))) + } + + #[test] + fn each_lookup_reads_its_own_value_type() { + let d = sample(); + assert_eq!(d.get_bool("enab"), Some(true)); + assert_eq!(d.get_number("doub"), Some(0.25)); + assert_eq!(d.get_number("Opct"), Some(75.5)); + assert_eq!(d.get_number("long"), Some(-5.0)); + assert_eq!(d.get_number("comp"), Some((1u64 << 40) as f64)); + assert_eq!(d.get_text("Nm ").as_deref(), Some("Café")); + assert_eq!(d.get_enum("Md ").as_deref(), Some("Nrml")); + assert_eq!(d.get_object("Objc").and_then(|o| o.get_number("Rd ")), Some(1.0)); + assert!(d.get_object("GlbO").is_some()); + } + + #[test] + fn lookups_of_other_types_or_missing_keys_are_none() { + let d = sample(); + assert_eq!(d.get_bool("doub"), None); + assert_eq!(d.get_number("enab"), None); + assert_eq!(d.get_text("long"), None); + assert_eq!(d.get_enum("Nm "), None); + assert!(d.get_object("Md ").is_none()); + for key in ["none", " "] { + assert_eq!((d.get_bool(key), d.get_number(key), d.get_text(key), d.get_enum(key)), (None, None, None, None)); + assert!(d.get_object(key).is_none()); + } + } +} diff --git a/crates/psd/src/grd.rs b/crates/psd/src/grd.rs index bfa2cd91..ada61130 100644 --- a/crates/psd/src/grd.rs +++ b/crates/psd/src/grd.rs @@ -58,18 +58,6 @@ pub struct GrdGradient { pub opacity: Vec<(f32, f32, f32)>, } -/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result -/// through `finite`, so filtering here as well would give two guards with two different answers to -/// the same input, and mutation testing showed the second one was unreachable. -fn num(d: &Descriptor, k: &str) -> Option { - match d.get(k)? { - Value::Double(v) => Some(*v), - Value::UnitFloat { value, .. } => Some(*value), - Value::Integer(v) => Some(f64::from(*v)), - Value::LargeInteger(v) => Some(*v as f64), - _ => None, - } -} /// The single guard every number passes through on its way to an `f32`. Only a NaN is truly /// without a value, so only a NaN returns `None`; the caller then falls back to the default its /// own field documents (opacity opaque, a midpoint centred, a channel zero). @@ -85,9 +73,11 @@ fn finite(v: f64) -> Option { } Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32) } +/// The finite `f32` at `k`, or 0. fn f(d: &Descriptor, k: &str) -> f32 { - finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0) + finite(d.get_number(k).unwrap_or(0.0)).unwrap_or(0.0) } +/// `v` clamped to 0..=1. fn clamp01(v: f32) -> f32 { v.clamp(0.0, 1.0) } @@ -99,12 +89,7 @@ fn hue(h: f32) -> f32 { if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) } } -fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { - match d.get(k)? { - Value::Descriptor(o) | Value::GlobalObject(o) => Some(o), - _ => None, - } -} +/// The list at `k`, empty when absent. fn list<'a>(d: &'a Descriptor, k: &str) -> &'a [Value] { match d.get(k) { Some(Value::List(v)) => v, @@ -115,6 +100,7 @@ fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool { matches!(d.get(k), Some(Value::Enumerated { value, .. }) if value.is(v)) } +/// A gradient stop colour in its recorded colour model. fn color(c: &Descriptor) -> Option { let id = c.class_id.as_bytes(); Some(match id { @@ -135,13 +121,16 @@ fn color(c: &Descriptor) -> Option { }) } +/// A stop's location as a 0..1 fraction. fn location(d: &Descriptor) -> f32 { (f(d, "Lctn") / 4096.0).clamp(0.0, 1.0) } +/// A stop's midpoint as a 0..1 fraction, 0.5 when absent. fn midpoint(d: &Descriptor) -> f32 { - finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0) + finite(d.get_number("Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0) } +/// One gradient from its descriptor. fn gradient(g: &Descriptor) -> GrdGradient { let name = match g.get("Nm ") { Some(Value::Text(t)) => t.to_string_lossy(), @@ -155,7 +144,7 @@ fn gradient(g: &Descriptor) -> GrdGradient { } else if enum_is(s, "Type", "BckC") { Some(GrdColor::Background) } else { - obj(s, "Clr ").and_then(color) + s.get_object("Clr ").and_then(color) }; if let Some(color) = c { out.stops.push(GrdStop { location: location(s), midpoint: midpoint(s), color }); @@ -163,7 +152,7 @@ fn gradient(g: &Descriptor) -> GrdGradient { } for v in list(g, "Trns").iter().take(MAX_STOPS) { let Value::Descriptor(s) = v else { continue }; - out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s))); + out.opacity.push((location(s), finite(s.get_number("Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s))); } out } @@ -185,7 +174,7 @@ pub fn parse(data: &[u8]) -> Result> { .iter() .take(MAX_GRADIENTS) .filter_map(|v| match v { - Value::Descriptor(d) => obj(d, "Grad").map(gradient), + Value::Descriptor(d) => d.get_object("Grad").map(gradient), _ => None, }) .collect(); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 0aee070e..de8f0d07 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -16,6 +16,9 @@ //! EXIF, XMP) from the start of a file, without the layers or pixels. //! * [`descriptor`] parses and writes ActionDescriptors. //! * [`engine_data`] parses and writes the text engine document of type layers. +//! * [`text`], [`smart`] and [`adjust`] are read-only views of the blocks a viewer needs: a type +//! layer's text and style, a smart object's placement, warp and embedded file, an adjustment +//! layer's settings, a fill layer's colour and the layer effects. //! //! Implemented clean-room from Adobe's public "Photoshop File Formats //! Specification". Undocumented details that follow MIT-licensed psd-tools @@ -46,6 +49,7 @@ pub mod abr; pub mod aco; pub mod act; +pub mod adjust; pub mod animation; pub mod ase; pub mod asl; @@ -78,12 +82,14 @@ pub mod resources; pub mod rows; pub mod shc; pub mod slices; +pub mod smart; pub mod source; pub mod tagged; #[cfg(any(test, feature = "testgen"))] // Test-data generator (tests and fuzz seeds only): failing loudly is the point. #[allow(clippy::expect_used)] pub mod testgen; +pub mod text; pub mod tiff; pub mod tree; diff --git a/crates/psd/src/slices.rs b/crates/psd/src/slices.rs index 5d9cd50b..85a72b96 100644 --- a/crates/psd/src/slices.rs +++ b/crates/psd/src/slices.rs @@ -108,20 +108,7 @@ fn d_u32(d: &Descriptor, key: &str) -> Option { } } -fn d_text(d: &Descriptor, key: &str) -> String { - match d.get(key) { - Some(Value::Text(t)) => t.to_string_lossy(), - _ => String::new(), - } -} - -fn d_enum(d: &Descriptor, key: &str) -> Option { - match d.get(key)? { - Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()), - _ => None, - } -} - +/// Whether the boolean at `key` is true. fn d_bool(d: &Descriptor, key: &str) -> bool { matches!(d.get(key), Some(Value::Boolean(true))) } @@ -141,6 +128,7 @@ fn origin_from(s: &str) -> u32 { } } +/// The slice type code a slice type name stands for. fn kind_from(s: &str) -> u32 { match s { "noImage" => 0, @@ -149,9 +137,10 @@ fn kind_from(s: &str) -> u32 { } } +/// A slice record from its descriptor. fn slice_from_descriptor(d: &Descriptor) -> SliceRecord { let b = d_bounds(d, "bounds").unwrap_or_default(); - let color = match (d_enum(d, "bgColorType").as_deref(), d.get("bgColor")) { + let color = match (d.get_enum("bgColorType").as_deref(), d.get("bgColor")) { (Some("Clr "), Some(Value::Descriptor(c))) => { let g = |k: &str| d_int(c, k).unwrap_or(0).clamp(0, 255) as u8; let a = if c.get("alpha").is_some() { g("alpha") } else { 255 }; @@ -162,18 +151,18 @@ fn slice_from_descriptor(d: &Descriptor) -> SliceRecord { SliceRecord { id: d_u32(d, "sliceID").unwrap_or(0), group_id: d_u32(d, "groupID").unwrap_or(0), - origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)), + origin: d.get_enum("origin").map_or(2, |s| origin_from(&s)), layer_id: d_u32(d, "layerID"), - name: d_text(d, "Nm "), - kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)), + name: d.get_text("Nm ").unwrap_or_default(), + kind: d.get_enum("Type").map_or(1, |s| kind_from(&s)), // Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom. rect: [b[1], b[0], b[3], b[2]], - url: d_text(d, "url"), - target: d_text(d, "null"), - message: d_text(d, "Msge"), - alt: d_text(d, "altTag"), + url: d.get_text("url").unwrap_or_default(), + target: d.get_text("null").unwrap_or_default(), + message: d.get_text("Msge").unwrap_or_default(), + alt: d.get_text("altTag").unwrap_or_default(), cell_text_is_html: d_bool(d, "cellTextIsHTML"), - cell_text: d_text(d, "cellText"), + cell_text: d.get_text("cellText").unwrap_or_default(), horizontal_align: 0, vertical_align: 0, color, @@ -220,7 +209,12 @@ impl SlicesResource { .collect(), _ => Vec::new(), }; - Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices }) + Ok(SlicesResource { + version, + bounds: d_bounds(d, "bounds").unwrap_or_default(), + group_name: d.get_text("baseName").unwrap_or_default(), + slices, + }) } v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))), } diff --git a/crates/psd/src/smart.rs b/crates/psd/src/smart.rs new file mode 100644 index 00000000..64f5a23c --- /dev/null +++ b/crates/psd/src/smart.rs @@ -0,0 +1,748 @@ +//! Smart objects: the placed-layer blocks (`SoLd`, `SoLE`, `PlLd`), the files they show +//! (`lnk2`, `lnkD`, `lnk3`, `lnkE`), and the geometry of a placement. +//! +//! A placed layer names a linked file by its unique id and says where the file's corners sit in +//! the document: a quad, which is a projective map of the content rectangle when it is not a +//! parallelogram, and optionally a warp (`warp` in `SoLd`): one of the named styles with bend +//! and horizontal / vertical distortion, or a custom envelope whose quilt mesh is a grid of +//! bicubic Bézier patches. +//! +//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by +//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles; +//! the styles here are clean-room geometric definitions of what each name describes (Arc, +//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by +//! behaviour, not numerically. +//! +//! The blocks themselves stay raw in the model ([`crate::TaggedBlock`]) and write back +//! unchanged; these are read-only views. Came from EffectCraft's `effectcraft-psd`. + +use std::ops::Range; + +use crate::descriptor::{Descriptor, UnicodeString, Value}; +use crate::error::{PsdError, Result}; +use crate::file::PsdFile; +use crate::io::{Reader, read_pascal}; +use crate::layer::LayerRecord; +use crate::tagged::TaggedBlock; + +/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes +/// from the file's transform, which is untrusted. +pub const MAX_BAKE_PIXELS: u64 = 1 << 24; + +/// A placed layer's warp. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Warp { + /// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`, + /// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`, + /// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`. + pub style: String, + /// Bend, −100..100 (%). + pub bend: f64, + /// Horizontal distortion, −100..100 (%). + pub horizontal: f64, + /// Vertical distortion, −100..100 (%). + pub vertical: f64, + /// The style is applied rotated a quarter turn (`warpRotate` = vertical). + pub rotate_vertical: bool, + /// The warped area in content pixels: left, top, right, bottom. + pub bounds: [f64; 4], + /// Custom envelope: patch columns. + pub cols: usize, + /// Custom envelope: patch rows. + pub rows: usize, + /// Custom envelope: the (3·cols+1)×(3·rows+1) control points (content pixels, row-major). + pub mesh: Vec<[f64; 2]>, +} + +/// The eight numbers of a transform entry (`Trnf`, `nonAffineTransform`), when it has exactly eight. +fn eight(d: &Descriptor, key: &str) -> Option<[f64; 8]> { + d.get_numbers(key).try_into().ok() +} + +/// The corner quad (top-left, top-right, bottom-right, bottom-left) from x0 y0 … x3 y3. +fn quad_from(t: [f64; 8]) -> [[f64; 2]; 4] { + [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]] +} + +impl Warp { + /// Reads a `warp` descriptor; `None` for no warp (the identity, or a custom warp without a + /// usable mesh). + pub fn from_descriptor(d: &Descriptor) -> Option { + let style = d.get_enum("warpStyle").unwrap_or_else(|| "warpNone".to_string()); + let side = |b: &Descriptor, k: &str| b.get_number(k).unwrap_or(0.0); + let bounds = d.get_object("bounds").map_or([0.0; 4], |b| [side(b, "Left"), side(b, "Top "), side(b, "Rght"), side(b, "Btom")]); + let mut w = Warp { + bend: d.get_number("warpValue").unwrap_or(0.0), + horizontal: d.get_number("warpPerspective").unwrap_or(0.0), + vertical: d.get_number("warpPerspectiveOther").unwrap_or(0.0), + rotate_vertical: d.get_enum("warpRotate").as_deref() == Some("Vrtc"), + bounds, + cols: 1, + rows: 1, + mesh: vec![], + style, + }; + if let Some(env) = d.get_object("customEnvelopeWarp") { + let cols = env.get_numbers("quiltSliceX").len().saturating_sub(1).max(1); + let rows = env.get_numbers("quiltSliceY").len().saturating_sub(1).max(1); + if let Some(Value::ObjectArray(pts)) = env.get("meshPoints") { + let xs = pts.body.get_numbers("Hrzn"); + let ys = pts.body.get_numbers("Vrtc"); + 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(); + } + } + } + let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0); + let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty(); + (!identity && custom_ok).then_some(w) + } + + /// The warp of a content point (content pixels → content pixels). + pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] }; + let (bw, bh) = (r - l, b - t); + let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh); + if self.style == "warpCustom" { + // 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) }; + let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh); + // Distortions: a perspective-like taper across the width (horizontal) or height. + let (hd, vd) = + if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) }; + let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5); + let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5); + y2 *= 1.0 + hd * s; + x2 *= 1.0 + vd * tt; + let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) }; + [l + bw / 2.0 + ox, t + bh / 2.0 + oy] + } + + /// A point of the custom quilt mesh at (u, v) ∈ [0, 1]². + 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 = 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] + }; + let (bu, bv) = (bern(fu), bern(fv)); + let mut out = [0.0; 2]; + for (j, bvj) in bv.iter().enumerate() { + for (i, bui) in bu.iter().enumerate() { + 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 = bui * bvj; + out[0] += q[0] * k; + out[1] += q[1] * k; + } + } + Some(out) + } +} + +/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1]. +fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] { + use std::f64::consts::PI; + let (hw, hh) = (fw / 2.0, fh / 2.0); + let s = x / hw; // −1..1 across + let t = y / hh; // −1..1 down + // Concentric arcs: the midline bends through an angle of b·π, rows keep their distance. + let arc = |x: f64, y: f64| -> [f64; 2] { + let theta = b * PI; + if theta.abs() < 1e-9 { + return [x, y]; + } + let r = fw / theta; + let phi = x / hw * theta / 2.0; + let rr = r - y; + // Arc centre below the shape for upward bends; keep the midline's centre fixed. + [rr * phi.sin(), r - rr * phi.cos()] + }; + let bump = 1.0 - s * s; + match style { + "warpArc" => arc(x, y), + "warpArcLower" => { + let a = arc(x, y); + let k = (t + 1.0) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArcUpper" => { + let a = arc(x, y); + let k = (1.0 - t) / 2.0; + [x + (a[0] - x) * k, y + (a[1] - y) * k] + } + "warpArch" => [x, y - b * hh * bump], + "warpBulge" => [x, y + t * b * hh * bump], + "warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump], + "warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump], + "warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()], + "warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)], + "warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)], + "warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()], + "warpFisheye" | "warpInflate" => { + let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 }; + [x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)] + } + "warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)], + "warpTwist" => { + let r = ((s * s + t * t) / 2.0).sqrt().min(1.0); + let a = b * PI / 2.0 * (1.0 - r); + let (sn, cs) = a.sin_cos(); + [x * cs - y * sn, x * sn + y * cs] + } + _ => [x, y], + } +} + +/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` +/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). +pub fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + let [p0, p1, p2, p3] = q; + let sx = p0[0] - p1[0] + p2[0] - p3[0]; + let sy = p0[1] - p1[1] + p2[1] - p3[1]; + if sx.abs() < 1e-12 && sy.abs() < 1e-12 { + return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]]; + } + let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]); + let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]); + let den = dx1 * dy2 - dx2 * dy1; + let g = (sx * dy2 - dx2 * sy) / den; + let h = (dx1 * sy - sx * dy1) / den; + [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] +} + +/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel. +pub(crate) fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) { + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + +/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SmartObject { + /// Unique id of the linked file ([`LinkedFile::uuid`]) the layer shows. + pub uuid: String, + /// Where the content's corners sit in document pixels: top-left, top-right, bottom-right, + /// bottom-left. The non-affine transform when the block has one. + pub quad: [[f64; 2]; 4], + /// The content's size in pixels (`Sz `), when recorded. + pub size: Option<[f64; 2]>, + /// The placed layer's warp (`warp`), when it has one. + pub warp: Option, +} + +impl SmartObject { + /// Reads the data of a `SoLd` / `SoLE` block. + pub fn from_placed_layer_data(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let _kind = r.array::<4>()?; + let _version = r.u32()?; + let _descriptor_version = r.u32()?; + let d = Descriptor::read(&mut r)?; + let uuid = d.get_text("Idnt").unwrap_or_default(); + let t = eight(&d, "Trnf").unwrap_or([0.0; 8]); + // The non-affine transform when present (perspective), else the transform. + let t = eight(&d, "nonAffineTransform").unwrap_or(t); + let size = d.get_object("Sz ").and_then(|s| Some([s.get_number("Wdth")?, s.get_number("Hght")?])); + let warp = d.get_object("warp").and_then(Warp::from_descriptor); + Ok(SmartObject { uuid, quad: quad_from(t), size, warp }) + } + + /// Reads the data of a `PlLd` block. A transform that is not finite is an error. + pub fn from_placed_layer(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let _kind = r.array::<4>()?; + let _version = r.u32()?; + let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned(); + // Page, total pages, anti-alias policy, layer type. + r.skip(16)?; + let mut t = [0.0; 8]; + for v in &mut t { + *v = r.f64()?; + if !v.is_finite() { + return Err(PsdError::invalid("PlLd: transform is not finite")); + } + } + Ok(SmartObject { uuid, quad: quad_from(t), size: None, warp: None }) + } + + /// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale + /// y, rotation in degrees). Perspective and skew are dropped. + pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) { + let q = self.quad; + let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0]; + let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]]; + let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]]; + let sx = top[0].hypot(top[1]) / w.max(1e-9); + let sy = left[0].hypot(left[1]) / h.max(1e-9); + // Mirrored content (left edge turning the other way) keeps a negative y scale. + let cross = top[0] * left[1] - top[1] * left[0]; + (centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees()) + } + + /// Whether the corner quad is a parallelogram (an affine placement). + pub fn is_affine(&self) -> bool { + let q = self.quad; + let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0]; + let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1]; + ex.abs() < 1e-3 && ey.abs() < 1e-3 + } + + /// Whether placing needs baking (a perspective quad or a warp). + pub fn needs_bake(&self) -> bool { + !self.is_affine() || self.warp.is_some() + } + + /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. + pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + self.map_with(&square_to_quad(self.quad), p, w, h) + } + + /// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`). + pub(crate) fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &self.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] + } + + /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. + pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + self.bounds_with(&square_to_quad(self.quad), w, h) + } + + pub(crate) fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] + } + + /// The baked size for a `w`×`h` content at `k` pixels per document pixel. + pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { + size_for(self.placed_bounds(w, h), k) + } + + /// The scale and size to bake a `w`×`h` content at, wanting `k` pixels per document pixel but + /// never more than [`MAX_BAKE_PIXELS`] pixels, nor more than 4× the `doc` size across either + /// way: a quad corner far outside the document lowers `k` (possibly below 1) instead of asking + /// for a gigantic image. Returns the scale actually used and the size in pixels. + pub fn bake_scale(&self, w: f64, h: f64, k: f64, doc: (u32, u32)) -> (f64, (u32, u32)) { + let bb = self.placed_bounds(w, h); + let (bw, bh) = ((bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0)); + if !(bw.is_finite() && bh.is_finite() && k.is_finite() && k > 0.0) { + return (1.0, (1, 1)); + } + let (max_w, max_h) = ((f64::from(doc.0) * 4.0).max(1.0), (f64::from(doc.1) * 4.0).max(1.0)); + let capped = k.min(max_w / bw).min(max_h / bh).min((MAX_BAKE_PIXELS as f64 / (bw * bh)).sqrt()); + // Rounding up could step over a cap, so a lowered scale rounds down. + let side = |len: f64| if capped < k { (len * capped).floor() } else { (len * capped).ceil() }.clamp(1.0, 16384.0) as u32; + (capped, (side(bw), side(bh))) + } +} + +impl LayerRecord { + /// The layer's smart object placement: `SoLd` or `SoLE` when one reads, else `PlLd`. A + /// malformed block counts as absent. + pub fn smart_object(&self) -> Option { + let read = |key: &[u8; 4], f: fn(&[u8]) -> Result| self.block(key).and_then(|b| f(&b.data).ok()); + read(b"SoLd", SmartObject::from_placed_layer_data) + .or_else(|| read(b"SoLE", SmartObject::from_placed_layer_data)) + .or_else(|| read(b"PlLd", SmartObject::from_placed_layer)) + } +} + +/// A file embedded in (or linked from) the document: one record of a linked layer block +/// (`lnk2`, `lnkD`, `lnk3`, `lnkE`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LinkedFile { + /// `liFD` (embedded data), `liFE` (external file) or `liFA` (alias). + pub kind: [u8; 4], + /// Unique id, which a [`SmartObject::uuid`] names. + pub uuid: String, + /// Original file name. + pub name: String, + /// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank. + pub file_type: String, + /// Where the embedded bytes are in the block's data (absent for externally linked files). + pub data: Option>, +} + +/// `true` for the keys of the linked layer blocks. +pub fn is_linked_layer_key(key: &[u8; 4]) -> bool { + matches!(key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE") +} + +/// The records of a linked layer block's data (spec: "Linked Layer"): length, kind, version, +/// unique id, original file name, type, creator, data length, optional descriptors, then the +/// data. A damaged record ends the list; the records before it are returned. +pub fn linked_files(data: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut r = Reader::new(data); + while r.remaining() >= 8 { + let Ok(len) = r.u64() else { break }; + let start = r.pos(); + let Ok(item) = r.bytes_u64(len) else { break }; + match linked_file(item, start) { + Ok(f) => out.push(f), + Err(_) => break, + } + // Records are padded to a multiple of 4. + let pad = ((4 - len % 4) % 4).min(r.remaining() as u64) as usize; + if r.skip(pad).is_err() { + break; + } + } + out +} + +/// One record; `at` is where it starts in the block's data. +fn linked_file(item: &[u8], at: usize) -> Result { + let mut r = Reader::new(item); + let kind = r.array::<4>()?; + let version = r.u32()?; + let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned(); + let name = UnicodeString::read(&mut r)?.to_string_lossy(); + let file_type = String::from_utf8_lossy(&r.array::<4>()?).trim().to_string(); + let _creator = r.array::<4>()?; + let data_len = r.u64()?; + if r.u8()? != 0 { + let _version = r.u32()?; + Descriptor::read(&mut r)?; + } + let mut out = LinkedFile { kind, uuid, name, file_type, data: None }; + let embedded = |r: &mut Reader<'_>| -> Result> { + let s = at + r.pos(); + let n = r.bytes_u64(data_len)?.len(); + Ok(s..s + n) + }; + match &kind { + b"liFD" => out.data = Some(embedded(&mut r)?), + b"liFE" => { + let _version = r.u32()?; + Descriptor::read(&mut r)?; + if version > 3 { + // Year, month / day / hour / minute, seconds. + r.skip(4 + 4 + 8)?; + } + let _file_size = r.u64()?; + if version > 2 && data_len > 0 && r.remaining() as u64 >= data_len { + out.data = Some(embedded(&mut r)?); + } + } + _ => {} + } + Ok(out) +} + +impl PsdFile { + /// Every linked file of the document's linked layer blocks, each with the block that holds + /// it ([`LinkedFile::data`] is a range of that block's data). + pub fn linked_files(&self) -> Vec<(&TaggedBlock, LinkedFile)> { + self.global_blocks + .iter() + .filter(|b| is_linked_layer_key(&b.key)) + .flat_map(|b| linked_files(&b.data).into_iter().map(move |f| (b, f))) + .collect() + } + + /// The embedded bytes of the linked file `uuid`, when the document embeds it. + pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> { + self.linked_files().into_iter().find_map(|(b, f)| if f.uuid == uuid { b.data.get(f.data?) } else { None }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::{Id, ObjectArray}; + use crate::io::{WriteExt, write_pascal}; + + fn so(quad: [[f64; 2]; 4], warp: Option) -> SmartObject { + SmartObject { uuid: "x".into(), quad, size: None, warp } + } + + fn list(v: &[f64]) -> Value { + Value::List(v.iter().map(|&x| Value::Double(x)).collect()) + } + + /// The data of a `SoLd` block placing `uuid` at `quad`. + fn placed_layer_data(uuid: &str, quad: [f64; 8], extra: impl FnOnce(Descriptor) -> Descriptor) -> Vec { + let d = Descriptor::new("null").with("Idnt", Value::Text(UnicodeString::new_nul(uuid))).with("Trnf", list(&quad)); + let mut out = b"soLD".to_vec(); + out.put_u32(4); + out.put_u32(16); + extra(d).write(&mut out); + out + } + + /// One linked layer record, with its length field and padding. + fn linked_record(kind: &[u8; 4], uuid: &str, name: &str, file_type: &[u8; 4], data: &[u8]) -> Vec { + let mut item = kind.to_vec(); + item.put_u32(2); + write_pascal(&mut item, uuid.as_bytes(), 1); + UnicodeString::new(name).write(&mut item); + item.put(file_type); + item.put(b" "); + item.put_u64(data.len() as u64); + item.put_u8(0); + if kind == b"liFD" { + item.put(data); + } else { + item.put_u32(16); + Descriptor::new("ExternalFileLink").write(&mut item); + item.put_u64(0); + } + let mut out = Vec::new(); + out.put_u64(item.len() as u64); + out.put(&item); + out.resize(out.len().div_ceil(4) * 4, 0); + out + } + + #[test] + fn placed_layer_data_gives_the_id_quad_size_and_warp() { + let quad = [20.0, 4.0, 40.0, 4.0, 40.0, 44.0, 20.0, 44.0]; + let data = placed_layer_data("uuid-1", quad, |d| { + let size = Descriptor::new("Pnt ").with("Wdth", Value::Double(20.0)).with("Hght", Value::Double(10.0)); + let bounds = ["Top ", "Left", "Btom", "Rght"] + .iter() + .zip([0.0, 0.0, 10.0, 20.0]) + .fold(Descriptor::new("classFloatRect"), |b, (k, v)| b.with(k, Value::UnitFloat { unit: *b"#Pxl", value: v })); + let warp = Descriptor::new("warp") + .with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpArc") }) + .with("warpValue", Value::Double(25.0)) + .with("warpRotate", Value::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Vrtc") }) + .with("bounds", Value::Descriptor(bounds)); + d.with("Sz ", Value::Descriptor(size)).with("warp", Value::Descriptor(warp)) + }); + let s = SmartObject::from_placed_layer_data(&data).unwrap(); + assert_eq!(s.uuid, "uuid-1"); + assert_eq!(s.quad, [[20.0, 4.0], [40.0, 4.0], [40.0, 44.0], [20.0, 44.0]]); + assert_eq!(s.size, Some([20.0, 10.0])); + let w = s.warp.clone().unwrap(); + assert_eq!((w.style.as_str(), w.bend, w.rotate_vertical, w.bounds), ("warpArc", 25.0, true, [0.0, 0.0, 20.0, 10.0])); + assert!(s.is_affine() && s.needs_bake()); + let (centre, sx, sy, rotation) = s.placement(20.0, 10.0); + assert_eq!((centre, sx, sy, rotation), ([30.0, 24.0], 1.0, 4.0, 0.0)); + // Every truncation is an error, never a panic. + for cut in 0..data.len() { + assert!(SmartObject::from_placed_layer_data(&data[..cut]).is_err(), "cut {cut}"); + } + } + + #[test] + fn the_non_affine_transform_wins_and_an_identity_warp_is_none() { + let data = placed_layer_data("p", [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0], |d| { + let none = Descriptor::new("warp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpNone") }); + d.with("nonAffineTransform", list(&[10.0, 10.0, 50.0, 0.0, 50.0, 40.0, 10.0, 30.0])).with("warp", Value::Descriptor(none)) + }); + let s = SmartObject::from_placed_layer_data(&data).unwrap(); + assert_eq!(s.quad, [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]); + assert!(s.warp.is_none() && !s.is_affine()); + } + + #[test] + fn a_custom_envelope_reads_its_quilt_mesh_and_a_wrong_sized_one_is_no_warp() { + let envelope = |points: usize| { + let unfl = |v: Vec| Value::UnitFloats { unit: *b"#Pxl", values: v }; + let body = + Descriptor::new("rationalPoint").with("Hrzn", unfl((0..points).map(|i| i as f64).collect())).with("Vrtc", unfl(vec![1.0; points])); + let env = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", unfl(vec![0.0, 10.0, 20.0])) + .with("quiltSliceY", unfl(vec![0.0, 10.0])) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body })); + Descriptor::new("warp") + .with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpCustom") }) + .with("customEnvelopeWarp", Value::Descriptor(env)) + }; + // Two columns and one row of patches: 7 × 4 control points. + let w = Warp::from_descriptor(&envelope(28)).unwrap(); + assert_eq!((w.cols, w.rows, w.mesh.len()), (2, 1, 28)); + assert_eq!(w.mesh[5], [5.0, 1.0]); + assert_eq!(Warp::from_descriptor(&envelope(27)), None); + } + + #[test] + fn the_older_placed_layer_block_gives_the_id_and_quad_and_refuses_a_transform_that_is_not_finite() { + let block = |t: [f64; 8]| { + let mut out = b"plcL".to_vec(); + out.put_u32(3); + write_pascal(&mut out, b"old-id", 1); + for v in [1u32, 1, 16, 2] { + out.put_u32(v); + } + for v in t { + out.put(&v.to_be_bytes()); + } + out + }; + let s = SmartObject::from_placed_layer(&block([1.0, 2.0, 3.0, 2.0, 3.0, 4.0, 1.0, 4.0])).unwrap(); + assert_eq!((s.uuid.as_str(), s.quad), ("old-id", [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]])); + assert!(SmartObject::from_placed_layer(&block([f64::NAN; 8])).is_err()); + assert!(SmartObject::from_placed_layer(&block([f64::INFINITY; 8])).is_err()); + } + + #[test] + fn a_layer_prefers_placed_layer_data_and_ignores_a_malformed_block() { + let good = placed_layer_data("new", [0.0; 8], |d| d); + let mut old = b"plcL".to_vec(); + old.put_u32(3); + write_pascal(&mut old, b"old", 1); + old.extend_from_slice(&[0; 16 + 64]); + let mut layer = LayerRecord { blocks: vec![TaggedBlock::new(*b"PlLd", old), TaggedBlock::new(*b"SoLd", good)], ..Default::default() }; + assert_eq!(layer.smart_object().unwrap().uuid, "new"); + layer.blocks[1].data.truncate(9); + assert_eq!(layer.smart_object().unwrap().uuid, "old"); + layer.blocks.clear(); + assert!(layer.smart_object().is_none()); + } + + #[test] + fn linked_files_lists_embedded_and_external_records_and_stops_at_damage() { + let mut data = linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA"); + let first = data.len(); + data.extend(linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b"")); + let files = linked_files(&data); + assert_eq!(files.len(), 2); + assert_eq!( + (files[0].kind, files[0].uuid.as_str(), files[0].name.as_str(), files[0].file_type.as_str()), + (*b"liFD", "uuid-1", "a.png", "PNGf") + ); + assert_eq!(&data[files[0].data.clone().unwrap()], b"PNGDATA"); + assert_eq!((files[1].kind, files[1].uuid.as_str(), files[1].data.clone()), (*b"liFE", "uuid-2", None)); + // A record whose length runs past the block, or that is cut short, ends the list. + let second_end = first + 8 + u64::from_be_bytes(data[first..first + 8].try_into().unwrap()) as usize; + for cut in first..second_end { + assert_eq!(linked_files(&data[..cut]).len(), 1, "cut {cut}"); + } + let mut hostile = data.clone(); + hostile[..8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(linked_files(&hostile).is_empty()); + // An embedded length larger than the record is damage, not an out-of-range slice. + let mut long = linked_record(b"liFD", "u", "n", b"JPEG", b"abc"); + let at = long.windows(8).rposition(|w| w == 3u64.to_be_bytes()).unwrap(); + long[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(linked_files(&long).is_empty()); + } + + #[test] + fn a_document_finds_the_embedded_bytes_of_a_smart_object() { + let mut file = crate::testgen::small(crate::Version::Psd, crate::Compression::Raw); + file.global_blocks.push(TaggedBlock::new(*b"lnk2", linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA"))); + file.global_blocks.push(TaggedBlock::new(*b"lnkE", linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b""))); + assert_eq!(file.linked_files().len(), 2); + assert_eq!(file.linked_data("uuid-1"), Some(b"PNGDATA".as_slice())); + assert_eq!(file.linked_data("uuid-2"), None); + assert_eq!(file.linked_data("nope"), None); + // The blocks are untouched: the file still writes and reads back. + let again = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap(); + assert_eq!(again.linked_data("uuid-1"), Some(b"PNGDATA".as_slice())); + } + + #[test] + fn perspective_quad_maps_corners_and_is_projective() { + let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]; + let s = so(q, None); + assert!(!s.is_affine() && s.needs_bake()); + for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] { + let p = s.map(c, 20.0, 10.0); + assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}"); + } + // Projective, not bilinear: the content centre is where the diagonals cross. + let c = s.map([10.0, 5.0], 20.0, 10.0); + assert!((c[0] - 30.0).abs() > 0.5, "{c:?}"); + let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None); + assert!(affine.is_affine() && !affine.needs_bake()); + } + + #[test] + fn custom_mesh_and_styles_warp() { + // An identity 4×4 mesh shifted 3 px right in its interior row: interior points move. + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }; + let p = w.apply([10.0, 5.0], 20.0, 10.0); + assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}"); + assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]); + // Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up, + // bottom down; Flag keeps the centre. + let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0); + let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0); + assert!(top[1] < corner[1], "arc {top:?} {corner:?}"); + let b = style("warpBulge"); + assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0); + assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9); + // Vertical rotation swaps the roles of the axes. + let mut v = style("warpBulge"); + v.rotate_vertical = true; + let l = v.apply([0.0, 50.0], 200.0, 100.0); + assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}"); + // Horizontal distortion: the right side grows taller. + let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() }; + assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]); + } + + #[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]); + } + } + + #[test] + fn an_absurd_quad_corner_bakes_within_four_times_the_document_and_2_pow_24_pixels() { + let doc = (1920u32, 1080u32); + for far in [1e4, 1e6, 1e12] { + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [far, 5.0], [far, far], [0.0, 20.0]], size: None, warp: None }; + assert!(s.needs_bake()); + // What the importer asks for: the unbounded size is the 16384² clamp, 4 GiB as float RGBA. + assert_eq!(s.placed_size(100.0, 100.0, 4.0), (16384, 16384)); + let (k, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(k > 0.0 && k <= 4.0, "{k}"); + assert!(w <= doc.0 * 4 && h <= doc.1 * 4, "{w}x{h}"); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS, "{w}x{h}"); + } + // A quad inside the document keeps the scale and the size it always had. + let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [400.0, 50.0], [400.0, 150.0], [0.0, 200.0]], size: None, warp: None }; + let (k, size) = s.bake_scale(100.0, 100.0, 2.0, doc); + assert_eq!((k, size), (2.0, s.placed_size(100.0, 100.0, 2.0))); + // Not-a-number corners stay small too. + let s = SmartObject { uuid: "x".into(), quad: [[f64::NAN; 2]; 4], size: None, warp: None }; + let (_, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc); + assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS); + } +} diff --git a/crates/psd/src/text.rs b/crates/psd/src/text.rs new file mode 100644 index 00000000..6ba299bc --- /dev/null +++ b/crates/psd/src/text.rs @@ -0,0 +1,152 @@ +//! Type layers: the `TySh` block as a read-only view. +//! +//! Layout (Adobe's specification, "Type tool object setting"): a version, six doubles of +//! transform, a text version, a descriptor version and the text descriptor, whose `Txt ` is the +//! text, `EngineData` the text engine document ([`crate::engine_data`]) and `bounds` / +//! `boundingBox` the text's extent. The warp descriptor and the four bounds that follow are not +//! read here. The block stays raw in the model and writes back unchanged. Came from EffectCraft's +//! `effectcraft-psd`. + +use crate::descriptor::Descriptor; +use crate::engine_data::{self, TextStyle}; +use crate::error::Result; +use crate::io::Reader; +use crate::layer::LayerRecord; + +/// What a type layer says: its text, where it sits and how its first style run looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TypeLayer { + /// The text, with `\n` line breaks and without the line break that ends it. + pub text: String, + /// Affine transform (xx, xy, yx, yy, tx, ty): the text origin sits at (tx, ty). The identity + /// when the file's transform is not finite. + pub transform: [f64; 6], + /// The first style run, read best effort from the text engine document. + pub style: TextStyle, + /// Text bounds (left, top, right, bottom) relative to the origin, when stored. + pub bounds: Option<[f64; 4]>, + /// The text descriptor, for everything else. + pub descriptor: Descriptor, +} + +impl TypeLayer { + /// Reads the data of a `TySh` block. + pub fn from_bytes(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let _version = r.u16()?; + let mut transform = [0.0; 6]; + for v in &mut transform { + *v = r.f64()?; + } + // A transform with NaN or infinity is unusable: fall back to the identity. + if transform.iter().any(|v| !v.is_finite()) { + transform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + } + let _text_version = r.u16()?; + let _descriptor_version = r.u32()?; + let descriptor = Descriptor::read(&mut r)?; + let text = descriptor.get_text("Txt ").unwrap_or_default().replace("\r\n", "\n").replace('\r', "\n"); + let text = text.strip_suffix('\n').map(str::to_string).unwrap_or(text); + let style = descriptor.get_raw_data("EngineData").map(|e| engine_data::first_style(&engine_data::parse_lossy(e))).unwrap_or_default(); + let bounds = descriptor + .get_object("bounds") + .or_else(|| descriptor.get_object("boundingBox")) + .and_then(|o| Some([o.get_number("Left")?, o.get_number("Top ")?, o.get_number("Rght")?, o.get_number("Btom")?])); + Ok(TypeLayer { text, transform, style, bounds, descriptor }) + } +} + +impl LayerRecord { + /// The layer's type (`TySh`), when it is a type layer. A malformed block counts as absent. + pub fn type_layer(&self) -> Option { + TypeLayer::from_bytes(&self.block(b"TySh")?.data).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::{UnicodeString, Value}; + use crate::io::WriteExt; + use crate::tagged::TaggedBlock; + + /// The data of a `TySh` block. + fn type_block(transform: [f64; 6], text: &str, engine: Option>, bounds_key: Option<&str>) -> Vec { + let mut d = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul(text))); + if let Some(e) = engine { + d = d.with("EngineData", Value::RawData(e)); + } + if let Some(key) = bounds_key { + let b = ["Left", "Top ", "Rght", "Btom"] + .iter() + .zip([-1.0, -20.0, 80.0, 5.0]) + .fold(Descriptor::new("bounds"), |b, (k, v)| b.with(k, Value::UnitFloat { unit: *b"#Pnt", value: v })); + d = d.with(key, Value::Descriptor(b)); + } + let mut out = Vec::new(); + out.put_u16(1); + for v in transform { + out.put_f64(v); + } + out.put_u16(50); + out.put_u32(16); + d.write(&mut out); + // The warp version, descriptor version and descriptor, and the four bounds that follow. + out.put_u16(1); + out.put_u32(16); + Descriptor::new("warp").write(&mut out); + out.extend_from_slice(&[0; 32]); + out + } + + #[test] + fn a_type_block_gives_text_transform_style_and_bounds() { + let engine = engine_data::minimal("Hello\rWorld", "Arial-BoldMT", 36.0, [1.0, 0.5, 0.0], 2); + let data = type_block([1.0, 0.0, 0.0, 1.0, 40.0, 60.0], "Hello\rWorld\r", Some(engine), Some("bounds")); + let t = TypeLayer::from_bytes(&data).unwrap(); + assert_eq!(t.text, "Hello\nWorld"); + assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 60.0]); + assert_eq!( + (t.style.font.as_deref(), t.style.size, t.style.color, t.style.justification), + (Some("Arial-BoldMT"), Some(36.0), Some([1.0, 0.5, 0.0]), Some(2)) + ); + assert_eq!(t.bounds, Some([-1.0, -20.0, 80.0, 5.0])); + assert!(t.descriptor.get("EngineData").is_some()); + let layer = LayerRecord { blocks: vec![TaggedBlock::new(*b"TySh", data.clone())], ..Default::default() }; + assert_eq!(layer.type_layer(), Some(t)); + // Cut anywhere inside the text descriptor: an error, and the layer reads as no type layer. + let descriptor_end = data.len() - 32 - 6 - Descriptor::new("warp").to_bytes().len(); + for cut in 0..descriptor_end { + assert!(TypeLayer::from_bytes(&data[..cut]).is_err(), "cut {cut}"); + } + let cut = LayerRecord { blocks: vec![TaggedBlock::new(*b"TySh", data[..40].to_vec())], ..Default::default() }; + assert_eq!(cut.type_layer(), None); + assert_eq!(LayerRecord::default().type_layer(), None); + } + + #[test] + fn windows_line_breaks_a_bounding_box_and_missing_engine_data_read() { + let t = TypeLayer::from_bytes(&type_block([2.0, 0.0, 0.0, 2.0, 0.0, 0.0], "a\r\nb", None, Some("boundingBox"))).unwrap(); + assert_eq!(t.text, "a\nb"); + assert_eq!(t.style, TextStyle::default()); + assert_eq!(t.bounds, Some([-1.0, -20.0, 80.0, 5.0])); + assert_eq!(TypeLayer::from_bytes(&type_block([1.0; 6], "x", None, None)).unwrap().bounds, None); + } + + #[test] + fn a_transform_that_is_not_finite_becomes_the_identity() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let t = TypeLayer::from_bytes(&type_block([1.0, 0.0, bad, 1.0, 5.0, 5.0], "x", None, None)).unwrap(); + assert_eq!(t.transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + } + } + + #[test] + fn damaged_engine_data_still_gives_the_style_it_holds() { + let mut engine = engine_data::minimal("x", "Courier", 12.0, [0.0, 0.0, 0.0], 0); + let keep = engine.windows(8).position(|w| w == b"/FontSet").unwrap() + 40; + engine.truncate(keep.min(engine.len() - 4)); + let t = TypeLayer::from_bytes(&type_block([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "x", Some(engine), None)).unwrap(); + assert_eq!(t.style.size, Some(12.0)); + } +}