Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 be29e6bb54 fix(psd): write descriptor ids warp, list and time as length-4 strings
A descriptor class or key id is a u32 length and bytes, where length 0
means a 4-byte code follows. Photoshop stores `warp`, `list` and `time`
as length-4 strings and treats the code form as a different key, so the
warps of new smart objects and type layers and regenerated layer comp
lists were not recognised. In the corpus, `warp` appears 741 times as a
string and never as a code, `list` 5/0, `time` 7/0.

`Id::new` now makes these three names string ids, as keys and class ids.
Reading is byte-based and accepts both forms; a code read from a file is
written back as a code.

Tests: exact bytes for each name as class and key, both forms read,
fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:42:05 -04:00
3 changed files with 64 additions and 2 deletions

No files matched your search

+3
View File
@@ -376,6 +376,9 @@ mod tests {
});
d.last_applied_comp = Some(7);
let res = write_comps_resource(&d).unwrap();
// `list` is a length-4 string id, as Photoshop writes it, never a code.
assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list"));
assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list"));
let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap();
// Attach as Photoshop would: shmd with cmls plus another item.
let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))];
+22
View File
@@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String {
mod tests {
use super::*;
fn count(hay: &[u8], needle: &[u8]) -> usize {
hay.windows(needle.len()).filter(|w| *w == needle).count()
}
/// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as
/// Photoshop does; the code form is a different key to Photoshop.
#[test]
fn fresh_placed_warp_is_a_string_id() {
let spec =
PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
let mut warnings = Vec::new();
let sold = sold_bytes(None, &spec, &mut warnings);
let plld = plld_bytes(&spec, &mut warnings);
// `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class.
for (bytes, n) in [(&sold, 2), (&plld, 1)] {
assert_eq!(count(bytes, b"\0\0\0\x04warp"), n);
assert_eq!(count(bytes, b"\0\0\0\0warp"), 0);
}
let d = sold_descriptor(&sold).unwrap();
assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec())));
}
fn sf(command: &str, params: J) -> SmartFilter {
SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true }
}
+39 -2
View File
@@ -44,6 +44,10 @@ impl UnicodeString {
}
}
/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes
/// (`00000004 'warp'`, not `00000000 'warp'`).
pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"];
/// A class / key / type identifier: a length-prefixed string, where a stored
/// length of 0 means a 4-character code follows.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -57,10 +61,15 @@ pub enum Id {
impl Id {
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
/// [`Id::Str`].
/// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings
/// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the
/// string and the code as different keys and doesn't recognise the code form.
pub fn new(s: &str) -> Self {
let b = s.as_bytes();
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
match b {
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
_ => Id::Str(b.to_vec()),
}
}
/// The identifier's bytes.
pub fn as_bytes(&self) -> &[u8] {
@@ -609,6 +618,34 @@ mod tests {
assert_eq!(p.to_bytes(), b);
}
/// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both
/// as keys and as class ids; other four-character names stay codes. Reading accepts both
/// forms, and a code read from a file is written back as a code.
#[test]
fn photoshop_string_ids_are_written_as_strings() {
let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat();
let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat();
for name in STRING_IDS {
assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec()));
let d = Descriptor::new(name).with(name, Value::Integer(1));
let b = d.to_bytes();
// Name (empty unicode string), then the class id; then the item count and the key.
assert_eq!(&b[4..12], &str_id(name)[..], "class {name}");
assert_eq!(&b[16..24], &str_id(name)[..], "key {name}");
let back = Descriptor::from_bytes(&b).unwrap();
assert_eq!(back, d);
assert!(back.class_id.is(name) && back.get(name).is_some());
// A file that wrote the code form keeps it, and lookups still find the key.
let mut coded = b.clone();
coded.splice(16..24, code_id(name));
let back = Descriptor::from_bytes(&coded).unwrap();
assert_eq!(back.get(name), Some(&Value::Integer(1)));
assert_eq!(back.to_bytes(), coded);
}
assert_eq!(Id::new("view"), Id::Code(*b"view"));
assert_eq!(Id::new("Txt "), Id::Code(*b"Txt "));
}
#[test]
fn versioned_roundtrip() {
let v = VersionedDescriptor::new(sample());