mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
The version-6 slices parser matched each trailing descriptor ID against the slice list with iter_mut().find(), an O(n*m) scan over two file-controlled collections. A crafted resource with many slices and many unmatched descriptor IDs could therefore cause quadratic CPU work during import. Build an ID-to-first-index map once and use it for descriptor lookups. or_insert preserves the existing first-match behavior for duplicate IDs. Finding: cf3f9ceb-d6df-4087-a2d6-dbe0f12657c3 (CWE-407)
532 lines
21 KiB
Rust
532 lines
21 KiB
Rust
//! 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 std::collections::HashMap;
|
|
|
|
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<u32>,
|
|
/// 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<SliceRecord>,
|
|
}
|
|
|
|
fn ustr(r: &mut Reader<'_>) -> Result<String> {
|
|
Ok(UnicodeString::read(r)?.to_string_lossy())
|
|
}
|
|
|
|
fn put_ustr(out: &mut Vec<u8>, 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<i64> {
|
|
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<i32> {
|
|
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<u32> {
|
|
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<String> {
|
|
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<Self> {
|
|
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<Self> {
|
|
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")
|
|
{
|
|
// Index each slice ID to its first occurrence once, so matching trailing
|
|
// descriptors against slices is linear rather than quadratic. Both the slice
|
|
// count and the descriptor-ID list are file-controlled, so a plain
|
|
// `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU.
|
|
// `or_insert` preserves the original first-match behavior for duplicate IDs.
|
|
let mut id_to_index: HashMap<u32, usize> = HashMap::with_capacity(slices.len());
|
|
for (i, s) in slices.iter().enumerate() {
|
|
id_to_index.entry(s.id).or_insert(i);
|
|
}
|
|
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(&i) = id_to_index.get(&id) {
|
|
slices[i].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<u8> {
|
|
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<u8> {
|
|
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: "<b>x</b>".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());
|
|
}
|
|
|
|
#[test]
|
|
fn trailing_descriptor_applies_outsets_to_first_matching_slice() {
|
|
// Two slices share id 1; the trailing descriptor must update the first one only,
|
|
// matching the pre-index behavior of `iter_mut().find()`.
|
|
let mut plain = SlicesResource {
|
|
version: 6,
|
|
bounds: [0, 0, 80, 100],
|
|
group_name: "site".into(),
|
|
slices: vec![
|
|
SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() },
|
|
SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() },
|
|
],
|
|
};
|
|
for s in &mut plain.slices {
|
|
s.outsets = [0; 4];
|
|
}
|
|
let mut bytes = plain.to_bytes();
|
|
let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7));
|
|
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes);
|
|
let back = SlicesResource::from_bytes(&bytes).unwrap();
|
|
assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset");
|
|
assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched");
|
|
}
|
|
}
|