//! Slices image resource (id 1050), from the Adobe Photoshop File Format Specification. //! //! Version 6 is a fixed binary layout (bounding rect, group name, then per slice: ids, origin, //! optional layer id, name, type, rect, URL / target / message / alt strings, cell text and //! alignment, background colour), optionally followed by a versioned descriptor carrying extra //! per-slice data (layer-slice outsets). Versions 7 and 8 store everything as one versioned //! descriptor (`baseName`, `bounds`, `slices` list). //! //! [`SlicesResource::to_bytes`] writes version 6, the documented binary layout every Photoshop //! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip //! keep the raw resource while the slices are unchanged. use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt}; /// Resource id of the slices resource. pub const SLICES: u16 = 1050; /// One slice record. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SliceRecord { /// Slice id. pub id: u32, /// Group id. pub group_id: u32, /// Origin: 0 auto-generated, 1 layer-based, 2 user-drawn. pub origin: u32, /// Layer id of a layer-based slice (the layer record's `lyid`). pub layer_id: Option, /// Slice name. pub name: String, /// Type: 0 no image, 1 image, 2 table. pub kind: u32, /// Left, top, right, bottom in document pixels. pub rect: [i32; 4], /// URL. pub url: String, /// Target frame. pub target: String, /// Status-bar message. pub message: String, /// Alt tag. pub alt: String, /// Cell text is HTML. pub cell_text_is_html: bool, /// Cell text. pub cell_text: String, /// Horizontal alignment. pub horizontal_align: u32, /// Vertical alignment. pub vertical_align: u32, /// Background colour as alpha, red, green, blue. pub color: [u8; 4], /// Layer-based slice outsets: top, left, bottom, right. pub outsets: [i32; 4], } /// The whole slices resource. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct SlicesResource { /// Version read (6, 7 or 8); writing always produces 6. pub version: u32, /// Bounding rectangle of all slices: top, left, bottom, right. pub bounds: [i32; 4], /// Name of the slice group (the base name). pub group_name: String, /// Slices, including auto-generated ones as Photoshop saves them. pub slices: Vec, } fn ustr(r: &mut Reader<'_>) -> Result { Ok(UnicodeString::read(r)?.to_string_lossy()) } fn put_ustr(out: &mut Vec, s: &str) { UnicodeString::new(s).write(out); } // ---------- descriptor helpers (versions 7/8 and the v6 tail) ---------- /// A number as `i64`; float casts saturate (NaN reads as 0). fn d_int(d: &Descriptor, key: &str) -> Option { match d.get(key)? { Value::Integer(v) => Some(i64::from(*v)), Value::LargeInteger(v) => Some(*v), Value::Double(v) => Some(*v as i64), Value::UnitFloat { value, .. } => Some(*value as i64), _ => None, } } /// A coordinate, clamped to the `i32` range instead of wrapping. fn d_i32(d: &Descriptor, key: &str) -> Option { d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32) } /// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any /// other number outside the `u32` range is no id at all rather than a wrapped one. fn d_u32(d: &Descriptor, key: &str) -> Option { match d.get(key)? { Value::Integer(v) => Some(*v as u32), _ => u32::try_from(d_int(d, key)?).ok(), } } 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, } } fn d_bool(d: &Descriptor, key: &str) -> bool { matches!(d.get(key), Some(Value::Boolean(true))) } fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> { let Value::Descriptor(b) = d.get(key)? else { return None }; let g = |k: &str| d_i32(b, k).unwrap_or(0); // top, left, bottom, right Some([g("Top "), g("Left"), g("Btom"), g("Rght")]) } fn origin_from(s: &str) -> u32 { match s { "autoGenerated" => 0, "layerGenerated" | "layer" => 1, _ => 2, } } fn kind_from(s: &str) -> u32 { match s { "noImage" => 0, "Tbl " | "table" => 2, _ => 1, } } 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")) { (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 }; [a, g("Rd "), g("Grn "), g("Bl ")] } _ => [0; 4], }; 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)), layer_id: d_u32(d, "layerID"), name: d_text(d, "Nm "), kind: d_enum(d, "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"), cell_text_is_html: d_bool(d, "cellTextIsHTML"), cell_text: d_text(d, "cellText"), horizontal_align: 0, vertical_align: 0, color, outsets: [ d_i32(d, "topOutset").unwrap_or(0), d_i32(d, "leftOutset").unwrap_or(0), d_i32(d, "bottomOutset").unwrap_or(0), d_i32(d, "rightOutset").unwrap_or(0), ], } } fn bounds_descriptor(tlbr: [i32; 4]) -> Value { Value::Descriptor( Descriptor::new("Rct1") .with("Top ", Value::Integer(tlbr[0])) .with("Left", Value::Integer(tlbr[1])) .with("Btom", Value::Integer(tlbr[2])) .with("Rght", Value::Integer(tlbr[3])), ) } fn enumerated(ty: &str, v: &str) -> Value { Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) } } impl SlicesResource { /// Parses resource data (versions 6, 7 and 8). pub fn from_bytes(data: &[u8]) -> Result { let mut r = Reader::new(data); let version = r.u32()?; match version { 6 => Self::read_v6(&mut r), 7 | 8 => { let vd = VersionedDescriptor::read(&mut r)?; let d = &vd.descriptor; let slices = match d.get("slices") { Some(Value::List(items)) => items .iter() .filter_map(|v| match v { Value::Descriptor(s) => Some(slice_from_descriptor(s)), _ => None, }) .collect(), _ => Vec::new(), }; Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices }) } v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))), } } fn read_v6(r: &mut Reader<'_>) -> Result { let bounds = [r.i32()?, r.i32()?, r.i32()?, r.i32()?]; let group_name = ustr(r)?; let n = r.u32()? as usize; if n > r.remaining() / 16 { return Err(PsdError::invalid("slice count exceeds the resource size")); } let mut slices = Vec::with_capacity(n); for _ in 0..n { let id = r.u32()?; let group_id = r.u32()?; let origin = r.u32()?; let layer_id = if origin == 1 { Some(r.u32()?) } else { None }; let name = ustr(r)?; let kind = r.u32()?; let rect = [r.i32()?, r.i32()?, r.i32()?, r.i32()?]; let url = ustr(r)?; let target = ustr(r)?; let message = ustr(r)?; let alt = ustr(r)?; let cell_text_is_html = r.u8()? != 0; let cell_text = ustr(r)?; let horizontal_align = r.u32()?; let vertical_align = r.u32()?; let color = [r.u8()?, r.u8()?, r.u8()?, r.u8()?]; slices.push(SliceRecord { id, group_id, origin, layer_id, name, kind, rect, url, target, message, alt, cell_text_is_html, cell_text, horizontal_align, vertical_align, color, outsets: [0; 4], }); } // Optional trailing descriptor (Photoshop 7+): per-slice extras such as outsets. if r.remaining() >= 4 && let Ok(vd) = VersionedDescriptor::read(r) && let Some(Value::List(items)) = vd.descriptor.get("slices") { for v in items { let Value::Descriptor(d) = v else { continue }; // An entry without a usable id belongs to no slice (not to slice 0). let Some(id) = d_u32(d, "sliceID") else { continue }; let extra = slice_from_descriptor(d); if let Some(s) = slices.iter_mut().find(|s| s.id == id) { s.outsets = extra.outsets; } } } Ok(SlicesResource { version: 6, bounds, group_name, slices }) } /// Serializes as version 6. A trailing descriptor with the outsets is added when any slice /// has them, the way Photoshop extends the version 6 layout. pub fn to_bytes(&self) -> Vec { let mut out = Vec::new(); out.put_u32(6); for v in self.bounds { out.put_i32(v); } put_ustr(&mut out, &self.group_name); out.put_u32(self.slices.len() as u32); for s in &self.slices { out.put_u32(s.id); out.put_u32(s.group_id); out.put_u32(s.origin); if s.origin == 1 { out.put_u32(s.layer_id.unwrap_or(0)); } put_ustr(&mut out, &s.name); out.put_u32(s.kind); for v in s.rect { out.put_i32(v); } put_ustr(&mut out, &s.url); put_ustr(&mut out, &s.target); put_ustr(&mut out, &s.message); put_ustr(&mut out, &s.alt); out.put_u8(u8::from(s.cell_text_is_html)); put_ustr(&mut out, &s.cell_text); out.put_u32(s.horizontal_align); out.put_u32(s.vertical_align); out.put(&s.color); } if self.slices.iter().any(|s| s.outsets != [0; 4]) { let list = self .slices .iter() .map(|s| { Value::Descriptor( Descriptor::new("slice") .with("sliceID", Value::Integer(s.id as i32)) .with("topOutset", Value::Integer(s.outsets[0])) .with("leftOutset", Value::Integer(s.outsets[1])) .with("bottomOutset", Value::Integer(s.outsets[2])) .with("rightOutset", Value::Integer(s.outsets[3])), ) }) .collect(); VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(list))).write(&mut out); } out } /// Serializes in the version 7 descriptor form (what newer Photoshop versions write). pub fn to_descriptor_bytes(&self) -> Vec { let list = self .slices .iter() .map(|s| { let origin = match s.origin { 0 => "autoGenerated", 1 => "layerGenerated", _ => "userGenerated", }; let kind = match s.kind { 0 => "noImage", 2 => "Tbl ", _ => "Img ", }; let mut d = Descriptor::new("slice") .with("sliceID", Value::Integer(s.id as i32)) .with("groupID", Value::Integer(s.group_id as i32)) .with("origin", enumerated("ESliceOrigin", origin)); if let Some(l) = s.layer_id.filter(|_| s.origin == 1) { d = d.with("layerID", Value::Integer(l as i32)); } d.with("Nm ", Value::Text(UnicodeString::new(&s.name))) .with("Type", enumerated("ESliceType", kind)) .with("bounds", bounds_descriptor([s.rect[1], s.rect[0], s.rect[3], s.rect[2]])) .with("url", Value::Text(UnicodeString::new(&s.url))) .with("null", Value::Text(UnicodeString::new(&s.target))) .with("Msge", Value::Text(UnicodeString::new(&s.message))) .with("altTag", Value::Text(UnicodeString::new(&s.alt))) .with("cellTextIsHTML", Value::Boolean(s.cell_text_is_html)) .with("cellText", Value::Text(UnicodeString::new(&s.cell_text))) .with("horzAlign", enumerated("ESliceHorzAlign", "default")) .with("vertAlign", enumerated("ESliceVertAlign", "default")) .with("bgColorType", enumerated("ESliceBGColorType", "None")) .with("topOutset", Value::Integer(s.outsets[0])) .with("leftOutset", Value::Integer(s.outsets[1])) .with("bottomOutset", Value::Integer(s.outsets[2])) .with("rightOutset", Value::Integer(s.outsets[3])) }) .map(Value::Descriptor) .collect(); let d = Descriptor::new("null") .with("bounds", bounds_descriptor(self.bounds)) .with("slices", Value::List(list)) .with("baseName", Value::Text(UnicodeString::new(&self.group_name))); let mut out = Vec::new(); out.put_u32(7); VersionedDescriptor::new(d).write(&mut out); out } } #[cfg(test)] mod tests { use super::*; fn sample() -> SlicesResource { SlicesResource { version: 6, bounds: [0, 0, 80, 100], group_name: "site".into(), slices: vec![ SliceRecord { id: 0, origin: 0, kind: 1, rect: [0, 0, 100, 10], ..Default::default() }, SliceRecord { id: 1, group_id: 0, origin: 2, name: "logo".into(), kind: 1, rect: [20, 10, 60, 30], url: "https://example.org/".into(), target: "_blank".into(), message: "hi".into(), alt: "Logo".into(), cell_text_is_html: true, cell_text: "x".into(), horizontal_align: 1, vertical_align: 2, color: [255, 10, 20, 30], ..Default::default() }, SliceRecord { id: 2, origin: 1, layer_id: Some(7), kind: 0, rect: [5, 40, 25, 70], outsets: [1, 2, 3, 4], ..Default::default() }, ], } } #[test] fn v6_roundtrips() { let s = sample(); let bytes = s.to_bytes(); let back = SlicesResource::from_bytes(&bytes).unwrap(); assert_eq!(back, s); assert_eq!(back.to_bytes(), bytes, "byte-stable"); } #[test] fn v7_descriptor_parses() { let s = sample(); let back = SlicesResource::from_bytes(&s.to_descriptor_bytes()).unwrap(); assert_eq!(back.version, 7); assert_eq!(back.group_name, "site"); assert_eq!(back.bounds, s.bounds); assert_eq!(back.slices.len(), 3); assert_eq!(back.slices[1].rect, [20, 10, 60, 30]); assert_eq!(back.slices[1].alt, "Logo"); assert_eq!(back.slices[1].target, "_blank"); assert_eq!(back.slices[2].origin, 1); assert_eq!(back.slices[2].layer_id, Some(7)); assert_eq!(back.slices[2].kind, 0); assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]); } #[test] fn out_of_range_descriptor_numbers_do_not_wrap() { // sliceID 2^32+1 used to read as slice 1 and take that slice's outsets. let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9)); let mut plain = sample(); for s in &mut plain.slices { s.outsets = [0; 4]; } let mut bytes = plain.to_bytes(); VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes); let back = SlicesResource::from_bytes(&bytes).unwrap(); assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices); // Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp. let d = wide("leftOutset") .with("layerID", Value::LargeInteger(-1)) .with("bottomOutset", Value::LargeInteger(1 << 40)) .with("rightOutset", Value::Double(-1e12)) .with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5)))); let s = slice_from_descriptor(&d); assert_eq!((s.id, s.layer_id), (0, None)); assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]); assert_eq!(s.rect[1], i32::MAX); // A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX. assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX); } #[test] fn malformed_input_is_an_error() { let bytes = sample().to_bytes(); // Cuts inside the records; a damaged trailing descriptor is ignored on purpose. for cut in [0, 3, 10, 30, 120] { assert!(SlicesResource::from_bytes(&bytes[..cut]).is_err(), "cut at {cut}"); } let mut huge = 6u32.to_be_bytes().to_vec(); huge.extend([0u8; 16]); huge.extend(0u32.to_be_bytes()); huge.extend(u32::MAX.to_be_bytes()); assert!(SlicesResource::from_bytes(&huge).is_err()); assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err()); } }