Files
craft-libs/crates/psd/tests/it/animation.rs
Dylan M. Taylor ee78a9bd46 craft-psd: Photoshop PSD/PSB reader and writer, moved from PhotoCraft (#875)
PhotoCraft's `photocraft-psd` crate (crates/psd at storytold/photocraft
63b641b55e54bf8f5c83efd977fdb16a2c255431) as `craft-psd`: the lossless
PSD/PSB model, reader, writer and builder, descriptors, the Photoshop
resource files that share its primitives (.abr, .asl, .aco, .act, .ase,
.grd, .pat, .atn, .csh, .shc) and the Photoshop blocks of a layered TIFF.
The crate had no dependency on any other PhotoCraft crate.

Code is unchanged apart from the crate name, craft-libs' rustfmt width,
comments that named other PhotoCraft crates, issue references (now
`photocraft#N`), and the corpus tests' directory (`CRAFT_PSD_CORPUS` or
`corpus/` at the workspace root). Tests, proptests, fuzz targets and
fixtures move with it.

Part of #778, #786.
2026-11-11 17:00:33 -05:00

266 lines
12 KiB
Rust

//! Synthetic only: no personal PSDs, paths, or copied descriptor payloads.
use craft_psd::animation::{AnimationRecord, AnimationResource, frame_info, frame_sets, layer_frame_states, parse_descriptor};
use craft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
use craft_psd::metadata::{MetadataItem, parse_shmd, write_shmd};
use craft_psd::{ImageResource, PsdBuilder, PsdFile, ResourceData, TaggedBlock, Version};
use proptest::prelude::*;
fn descriptor() -> VersionedDescriptor {
VersionedDescriptor::new(
Descriptor::default()
.with("AFSt", Value::Integer(7))
.with(
"FrIn",
Value::List(vec![
Value::Descriptor(
Descriptor::default().with("FrID", Value::Integer(-3)).with("FrDl", Value::Integer(3)).with("future", Value::Boolean(true)),
),
Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(21)).with("FrDl", Value::Integer(53))),
]),
)
.with(
"FSts",
Value::List(vec![Value::Descriptor(
Descriptor::default()
.with("FsID", Value::Integer(7))
.with("AFrm", Value::Integer(0))
.with("FsFr", Value::List(vec![Value::Integer(21), Value::Integer(-3)]))
.with("future", Value::RawData(vec![1, 2, 3])),
)]),
),
)
}
fn resource(data: &[u8]) -> AnimationResource<'_> {
AnimationResource {
records: vec![
AnimationRecord { key: *b"AnDs", data },
AnimationRecord { key: *b"Roll", data: &[0; 8] },
AnimationRecord { key: *b"next", data: &[9, 8, 7] },
],
}
}
#[test]
fn raw_roundtrip_and_typed_views_preserve_order_and_unknown_data() {
let data = descriptor().to_bytes();
let bytes = resource(&data).to_bytes().unwrap();
let parsed = AnimationResource::from_bytes(&bytes).unwrap();
assert_eq!(parsed, resource(&data));
let d = parsed.records[0].descriptor().unwrap().unwrap();
assert_eq!(d, descriptor());
let frames = frame_info(&d.descriptor).unwrap();
assert_eq!(frames.iter().map(|f| (f.id, f.delay_centiseconds)).collect::<Vec<_>>(), [(-3, Some(3)), (21, Some(53))]);
assert_eq!(frames[0].descriptor.get("future"), Some(&Value::Boolean(true)));
let sets = frame_sets(&d.descriptor).unwrap();
assert_eq!(sets[0].frame_ids, [21, -3], "playback order is not FrIn order or sorted id order");
assert_eq!(sets[0].id, 7);
assert_eq!(sets[0].active_frame, Some(0));
assert_eq!(sets[0].descriptor.get("future"), Some(&Value::RawData(vec![1, 2, 3])));
assert!(parsed.records[1].descriptor().is_none());
assert_eq!(parsed.to_bytes().unwrap(), bytes);
}
#[test]
fn plugin_signature_not_a_single_resource_id_selects_the_parser() {
let data = descriptor().to_bytes();
let bytes = resource(&data).to_bytes().unwrap();
for id in [4000, 4003, 4999] {
let r = ImageResource::new(id, bytes.clone());
assert!(matches!(r.parsed(), Some(Ok(ResourceData::Animation(_)))));
}
for id in [3999, 5000] {
assert!(ImageResource::new(id, bytes.clone()).parsed().is_none());
}
assert!(ImageResource::new(4003, b"different plugin".to_vec()).parsed().is_none());
assert!(matches!(ImageResource::new(4003, b"mani".to_vec()).parsed(), Some(Err(_))));
assert!(matches!(
ImageResource::new(1075, VersionedDescriptor::new(Descriptor::default().with("Vrsn", Value::Integer(1))).to_bytes()).parsed(),
Some(Ok(ResourceData::TimelineInfo(_)))
));
}
#[test]
fn metadata_views_do_not_touch_xmp_or_other_items() {
let d = VersionedDescriptor::new(Descriptor::default().with("LaID", Value::Integer(42)).with(
"LaSt",
Value::List(vec![Value::Descriptor(
Descriptor::default()
.with("FrLs", Value::List(vec![Value::Integer(-3)]))
.with("blendOptions", Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5)))),
)]),
));
let items = vec![
MetadataItem::new(*b"cust", vec![7, 9, 11]),
MetadataItem::new(*b"mlst", d.to_bytes()),
MetadataItem::new(*b"tmln", VersionedDescriptor::new(Descriptor::default().with("trackList", Value::List(vec![]))).to_bytes()),
MetadataItem::new(*b"next", vec![255; 4]),
];
let raw = write_shmd(&items);
let parsed = parse_shmd(&raw).unwrap();
assert!(parsed[0].animation_descriptor().is_none());
let layer_descriptor = parsed[1].animation_descriptor().unwrap().unwrap();
assert_eq!(layer_descriptor, d);
let states = layer_frame_states(&layer_descriptor.descriptor).unwrap();
assert_eq!(states.layer_id, 42);
assert_eq!(states.states[0].frame_ids, [-3]);
assert_eq!(states.states[0].descriptor.get("enab"), None, "missing visibility is not false");
assert_eq!(states.states[0].descriptor.get("blendOptions"), Some(&Value::Descriptor(Descriptor::default().with("Opct", Value::Double(12.5)))));
assert!(parsed[2].animation_descriptor().unwrap().is_ok());
assert!(parsed[3].animation_descriptor().is_none());
assert_eq!(write_shmd(&parsed), raw);
let mut bad = parsed[1].clone();
bad.signature = *b"nope";
assert!(bad.animation_descriptor().unwrap().is_err());
}
#[test]
fn psd_and_psb_keep_animation_resources_and_metadata_byte_stable() {
let data = descriptor().to_bytes();
let raw = resource(&data).to_bytes().unwrap();
for version in [Version::Psd, Version::Psb] {
let mut file = PsdBuilder::new(1, 1).version(version).build().unwrap();
file.resources.push(ImageResource::new(4003, raw.clone()));
file.global_blocks.push(TaggedBlock::new(*b"shmd", write_shmd(&[MetadataItem::new(*b"mlst", data.clone())])));
let bytes = file.to_bytes().unwrap();
let back = PsdFile::from_bytes(&bytes).unwrap();
assert_eq!(back.to_bytes().unwrap(), bytes);
let animation = back.resource(4003).unwrap();
assert!(matches!(animation.parsed(), Some(Ok(ResourceData::Animation(_)))));
}
}
#[test]
fn every_truncation_and_hostile_envelope_length_is_rejected() {
let data = descriptor().to_bytes();
let bytes = resource(&data).to_bytes().unwrap();
for end in 0..bytes.len() {
assert!(AnimationResource::from_bytes(&bytes[..end]).is_err(), "prefix {end}");
}
for at in [8, 20] {
for value in [0, 1, u32::MAX] {
let mut bad = bytes.clone();
bad[at..at + 4].copy_from_slice(&value.to_be_bytes());
assert!(AnimationResource::from_bytes(&bad).is_err(), "length {at} = {value}");
}
}
for at in [0, 4, 12] {
let mut bad = bytes.clone();
bad[at] = b'?';
assert!(AnimationResource::from_bytes(&bad).is_err());
}
let mut trailing = bytes.clone();
trailing.push(0);
assert!(AnimationResource::from_bytes(&trailing).is_err());
}
#[test]
fn descriptor_padding_truncation_and_unknown_versions() {
let raw = descriptor().to_bytes();
for end in 0..raw.len() {
assert!(parse_descriptor(&raw[..end]).is_err(), "descriptor prefix {end}");
}
for padding in 0..=3 {
let mut padded = raw.clone();
padded.extend(vec![0; padding]);
assert_eq!(parse_descriptor(&padded).unwrap(), descriptor());
}
for tail in [vec![0; 4], vec![1], vec![0, 2]] {
let mut bad = raw.clone();
bad.extend(tail);
assert!(parse_descriptor(&bad).is_err());
}
let mut future = raw.clone();
future[..4].copy_from_slice(&17u32.to_be_bytes());
let bytes = resource(&future).to_bytes().unwrap();
let r = AnimationResource::from_bytes(&bytes).unwrap();
assert!(r.records[0].descriptor().unwrap().is_err());
assert_eq!(r.to_bytes().unwrap(), bytes, "unsupported typed version must survive raw");
}
#[test]
fn absent_delays_and_duplicate_ids_are_not_silently_repaired() {
let d = Descriptor::default().with(
"FrIn",
Value::List(vec![
Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4))),
Value::Descriptor(Descriptor::default().with("FrID", Value::Integer(4)).with("FrDl", Value::Integer(0))),
]),
);
let frames = frame_info(&d).unwrap();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].delay_centiseconds, None);
assert_eq!(frames[1].delay_centiseconds, Some(0));
let bytes = AnimationResource { records: vec![AnimationRecord { key: *b"next", data: &[] }, AnimationRecord { key: *b"next", data: &[1] }] }
.to_bytes()
.unwrap();
assert_eq!(AnimationResource::from_bytes(&bytes).unwrap().to_bytes().unwrap(), bytes);
}
#[test]
fn wrong_frame_and_set_types_return_errors() {
for value in [Value::Boolean(false), Value::List(vec![Value::Integer(1)]), Value::List(vec![Value::Descriptor(Descriptor::default())])] {
assert!(frame_info(&Descriptor::default().with("FrIn", value.clone())).is_err());
assert!(frame_sets(&Descriptor::default().with("FSts", value)).is_err());
}
for delay in [Value::Integer(-1), Value::Double(3.0)] {
let frame = Descriptor::default().with("FrID", Value::Integer(1)).with("FrDl", delay);
assert!(frame_info(&Descriptor::default().with("FrIn", Value::List(vec![Value::Descriptor(frame)]))).is_err());
}
for (key, value) in [("FsID", Value::Boolean(false)), ("AFrm", Value::Double(0.0)), ("FsFr", Value::List(vec![Value::Boolean(true)]))] {
let mut set = Descriptor::default().with("FsID", Value::Integer(1)).with("FsFr", Value::List(vec![]));
set.items.retain(|(id, _)| !id.is(key));
set = set.with(key, value);
assert!(frame_sets(&Descriptor::default().with("FSts", Value::List(vec![Value::Descriptor(set)]))).is_err());
}
}
#[test]
fn sparse_layer_states_retain_missing_properties_and_multiple_references() {
let first = Descriptor::default()
.with("FrLs", Value::List(vec![Value::Integer(21), Value::Integer(-3)]))
.with("enab", Value::Boolean(true))
.with("Ofst", Value::Descriptor(Descriptor::default().with("Hrzn", Value::Integer(-7)).with("Vrtc", Value::Integer(12))));
let second = Descriptor::default().with("FrLs", Value::List(vec![Value::Integer(99)]));
let d = Descriptor::default()
.with("LaID", Value::Integer(8))
.with("LaSt", Value::List(vec![Value::Descriptor(first.clone()), Value::Descriptor(second)]));
let view = layer_frame_states(&d).unwrap();
assert_eq!(view.states[0].frame_ids, [21, -3]);
assert_eq!(view.states[0].descriptor, &first);
assert_eq!(view.states[1].descriptor.get("Ofst"), None);
assert_eq!(view.states[1].descriptor.get("enab"), None);
for d in [
Descriptor::default(),
Descriptor::default().with("LaID", Value::Boolean(true)),
Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::Boolean(true)),
Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Integer(0)])),
] {
assert!(layer_frame_states(&d).is_err());
}
for state in [
Descriptor::default(),
Descriptor::default().with("FrLs", Value::Integer(0)),
Descriptor::default().with("FrLs", Value::List(vec![Value::Boolean(true)])),
] {
let d = Descriptor::default().with("LaID", Value::Integer(8)).with("LaSt", Value::List(vec![Value::Descriptor(state)]));
assert!(layer_frame_states(&d).is_err());
}
}
proptest! {
#[test]
fn arbitrary_animation_bytes_never_panic(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
let _ = AnimationResource::from_bytes(&data);
let _ = parse_descriptor(&data);
}
#[test]
fn opaque_record_roundtrip(data in proptest::collection::vec(any::<u8>(), 0..2048), key in any::<[u8; 4]>()) {
let r = AnimationResource { records: vec![AnimationRecord { key, data: &data }] };
let bytes = r.to_bytes().unwrap();
prop_assert_eq!(AnimationResource::from_bytes(&bytes).unwrap(), r);
}
}