mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
craft-psd: whole .atn libraries with several sets, and their byte spans (#1047)
A version-16 .atn file can hold several sets after one version header. - `atn::parse_sets` / `parse_sets_with_encoding` read every set to the end of the file (a malformed later set fails the whole result); `write_sets` writes them with one header. Archive-wide limits: `MAX_SETS`, `MAX_TOTAL_ACTIONS`, `MAX_TOTAL_ITEMS`, on top of the per-set and per-action ones. - `parse_sets_with_spans` returns, for each set, action and step, the byte ranges of its header fields and descriptor in the input, so a library can edit one field and keep every other byte as it was. - `parse` and `write` (one set) are unchanged. Ported from storytold/photocraft#11021 (its crates/psd part). Co-authored-by: yoriswind <yoriswind@yahoo.com>
This commit is contained in:
co-authored by
yoriswind
parent
3540cf0a7f
commit
54b8aa8fb3
+274
-25
@@ -1,4 +1,4 @@
|
||||
//! Photoshop Actions files (`.atn`): one action set, its actions and their steps.
|
||||
//! Photoshop Actions files (`.atn`): action sets, their actions and their steps.
|
||||
//!
|
||||
//! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions*
|
||||
//! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was
|
||||
@@ -6,7 +6,8 @@
|
||||
//! code-unit count + UTF-16BE, usually NUL-terminated) unless noted.
|
||||
//!
|
||||
//! ```text
|
||||
//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action
|
||||
//! file := version u32 (12 or 16), set (version 16 may hold further sets until EOF)
|
||||
//! set := name UnicodeString, expanded u8, count u32, count × action
|
||||
//! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16,
|
||||
//! name UnicodeString, expanded u8, count u32, count × item
|
||||
//! item := expanded u8, enabled u8, with dialog u8, dialog options u8,
|
||||
@@ -31,8 +32,9 @@
|
||||
//!
|
||||
//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor
|
||||
//! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so
|
||||
//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files
|
||||
//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout.
|
||||
//! `parse(write(f)) == f` for sets [`parse`] read. The existing single-set API retains trailing-byte
|
||||
//! warnings. [`parse_sets`] and [`write_sets`] preserve complete version-16 multi-set files with
|
||||
//! one version header; a version-12 file's legacy descriptors keep their meaning, not their layout.
|
||||
|
||||
use crate::codepage;
|
||||
use crate::descriptor::{Descriptor, UnicodeString};
|
||||
@@ -40,6 +42,9 @@ use crate::error::{PsdError, Result};
|
||||
use crate::io::{Reader, WriteExt, decode_legacy_name};
|
||||
|
||||
mod legacy;
|
||||
mod sets;
|
||||
|
||||
pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, parse_sets_with_spans, write_sets};
|
||||
|
||||
/// The version written by this module (Photoshop 6 and every later release).
|
||||
pub const VERSION: u32 = 16;
|
||||
@@ -48,6 +53,70 @@ pub const MAX_ACTIONS: usize = 10_000;
|
||||
/// Most steps read from one action.
|
||||
pub const MAX_ITEMS: usize = 100_000;
|
||||
|
||||
/// A complete version16 archive and byte provenance into its original input.
|
||||
/// Ranges address the caller's input, including original names/flags/descriptor encodings.
|
||||
/// No source bytes are copied or rewritten by this parser.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SpannedAtnArchive {
|
||||
/// Parsed sets in physical file order.
|
||||
pub sets: Vec<AtnFile>,
|
||||
/// Byte spans for the sets, in the same order.
|
||||
pub spans: Vec<AtnSetSpan>,
|
||||
}
|
||||
|
||||
/// One physical set, including its original header fields and action occurrences.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AtnSetSpan {
|
||||
/// Half-open input range of the complete set, including its actions.
|
||||
pub range: std::ops::Range<usize>,
|
||||
/// Set header through the action count, before the first action.
|
||||
pub header: std::ops::Range<usize>,
|
||||
/// Length-prefixed Unicode set name bytes.
|
||||
pub name: std::ops::Range<usize>,
|
||||
/// Original expanded-flag byte.
|
||||
pub expanded: std::ops::Range<usize>,
|
||||
/// Original four-byte action count.
|
||||
pub count: std::ops::Range<usize>,
|
||||
/// Action spans in physical file order.
|
||||
pub actions: Vec<AtnActionSpan>,
|
||||
}
|
||||
|
||||
/// One physical action. Header ranges let a library edit one field without normalizing others.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AtnActionSpan {
|
||||
/// Half-open input range of the complete action, including its steps.
|
||||
pub range: std::ops::Range<usize>,
|
||||
/// Action header through the step count, before the first step.
|
||||
pub header: std::ops::Range<usize>,
|
||||
/// Original function-key code bytes.
|
||||
pub function_key: std::ops::Range<usize>,
|
||||
/// Original Shift-modifier flag byte.
|
||||
pub shift: std::ops::Range<usize>,
|
||||
/// Original command-modifier flag byte.
|
||||
pub command: std::ops::Range<usize>,
|
||||
/// Original color-label code bytes.
|
||||
pub color: std::ops::Range<usize>,
|
||||
/// Length-prefixed Unicode action name bytes.
|
||||
pub name: std::ops::Range<usize>,
|
||||
/// Original expanded-flag byte.
|
||||
pub expanded: std::ops::Range<usize>,
|
||||
/// Original four-byte step count.
|
||||
pub count: std::ops::Range<usize>,
|
||||
/// Step spans in physical file order.
|
||||
pub items: Vec<AtnItemSpan>,
|
||||
}
|
||||
|
||||
/// One original event, even when it maps to several native command steps.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AtnItemSpan {
|
||||
/// Half-open input range of the complete original step.
|
||||
pub range: std::ops::Range<usize>,
|
||||
/// Expanded, enabled, dialog, dialog-options bytes, in that order.
|
||||
pub flags: std::ops::Range<usize>,
|
||||
/// Descriptor body after its -1 flag; absent for a descriptor-free event.
|
||||
pub descriptor: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
|
||||
/// A parsed action set.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
@@ -217,7 +286,8 @@ fn write_ascii(out: &mut Vec<u8>, s: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Read one step with the descriptor dialect selected by the file version.
|
||||
fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result<AtnItem> {
|
||||
fn read_item(r: &mut Reader<'_>, legacy: bool, mut span: Option<&mut AtnItemSpan>) -> Result<AtnItem> {
|
||||
let start = r.pos();
|
||||
let expanded = r.u8()? != 0;
|
||||
let enabled = r.u8()? != 0;
|
||||
let with_dialog = r.u8()? != 0;
|
||||
@@ -230,29 +300,67 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result<AtnItem> {
|
||||
let dictionary = read_ascii(r)?;
|
||||
let descriptor = match r.i32()? {
|
||||
0 => None,
|
||||
-1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }),
|
||||
-1 => {
|
||||
let start = r.pos();
|
||||
let descriptor = if legacy { legacy::read(r)? } else { Descriptor::read(r)? };
|
||||
if let Some(span) = span.as_deref_mut() {
|
||||
span.descriptor = Some(start..r.pos());
|
||||
}
|
||||
Some(descriptor)
|
||||
}
|
||||
n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))),
|
||||
};
|
||||
if let Some(span) = span {
|
||||
span.range = start..r.pos();
|
||||
span.flags = start..start.saturating_add(4);
|
||||
}
|
||||
Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor })
|
||||
}
|
||||
|
||||
/// Read an action and its steps using the file's descriptor dialect.
|
||||
fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result<AtnAction> {
|
||||
fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>, mut span: Option<&mut AtnActionSpan>) -> Result<AtnAction> {
|
||||
let start = r.pos();
|
||||
let function_key = r.u16()?;
|
||||
let shift_start = r.pos();
|
||||
let shift = r.u8()? != 0;
|
||||
let command_start = r.pos();
|
||||
let command = r.u8()? != 0;
|
||||
let color_start = r.pos();
|
||||
let color = r.u16()?;
|
||||
let name_start = r.pos();
|
||||
let name = UnicodeString::read(r)?;
|
||||
let expanded_start = r.pos();
|
||||
let expanded = r.u8()? != 0;
|
||||
let count_start = r.pos();
|
||||
let n = r.u32()?;
|
||||
if let Some(span) = span.as_deref_mut() {
|
||||
span.header = start..r.pos();
|
||||
span.function_key = start..shift_start;
|
||||
span.shift = shift_start..command_start;
|
||||
span.command = command_start..color_start;
|
||||
span.color = color_start..name_start;
|
||||
span.name = name_start..expanded_start;
|
||||
span.expanded = expanded_start..count_start;
|
||||
span.count = count_start..r.pos();
|
||||
}
|
||||
// Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag.
|
||||
r.check_count(u64::from(n), 20)?;
|
||||
if n as usize > MAX_ITEMS {
|
||||
return Err(PsdError::LimitExceeded("too many steps in one action"));
|
||||
}
|
||||
if let Some(budget) = budget {
|
||||
budget.items(n as usize)?;
|
||||
}
|
||||
let mut items = Vec::with_capacity((n as usize).min(1024));
|
||||
for _ in 0..n {
|
||||
items.push(read_item(r, legacy)?);
|
||||
let mut item_span = span.as_ref().map(|_| AtnItemSpan::default());
|
||||
items.push(read_item(r, legacy, item_span.as_mut())?);
|
||||
if let (Some(span), Some(item)) = (span.as_deref_mut(), item_span) {
|
||||
span.items.push(item);
|
||||
}
|
||||
}
|
||||
if let Some(span) = span {
|
||||
span.range = start..r.pos();
|
||||
}
|
||||
Ok(AtnAction { function_key, shift, command, color, name, expanded, items })
|
||||
}
|
||||
@@ -278,33 +386,74 @@ pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result<AtnFil
|
||||
|
||||
fn parse_layout(data: &[u8]) -> Result<AtnFile> {
|
||||
let mut r = Reader::new(data);
|
||||
let version = read_version(&mut r)?;
|
||||
let mut file = read_set(&mut r, version == 12, None)?;
|
||||
if !r.is_empty() {
|
||||
file.warnings.push(format!("{} trailing bytes ignored", r.remaining()));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn read_version(r: &mut Reader<'_>) -> Result<u32> {
|
||||
let version = r.u32()?;
|
||||
if !matches!(version, 12 | VERSION) {
|
||||
return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)")));
|
||||
}
|
||||
let name = UnicodeString::read(&mut r)?;
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn read_set(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result<AtnFile> {
|
||||
read_set_with_spans(r, legacy, budget, None)
|
||||
}
|
||||
|
||||
fn read_set_with_spans(
|
||||
r: &mut Reader<'_>,
|
||||
legacy: bool,
|
||||
mut budget: Option<&mut sets::Budget>,
|
||||
mut span: Option<&mut AtnSetSpan>,
|
||||
) -> Result<AtnFile> {
|
||||
let start = r.pos();
|
||||
let name = UnicodeString::read(r)?;
|
||||
let expanded_start = r.pos();
|
||||
let expanded = r.u8()? != 0;
|
||||
let count_start = r.pos();
|
||||
let n = r.u32()?;
|
||||
if let Some(span) = span.as_deref_mut() {
|
||||
span.header = start..r.pos();
|
||||
span.name = start..expanded_start;
|
||||
span.expanded = expanded_start..count_start;
|
||||
span.count = count_start..r.pos();
|
||||
}
|
||||
// Each action needs at least its key, modifiers, colour, an empty name, the flag and a count.
|
||||
r.check_count(u64::from(n), 15)?;
|
||||
if n as usize > MAX_ACTIONS {
|
||||
return Err(PsdError::LimitExceeded("too many actions in one set"));
|
||||
}
|
||||
if let Some(budget) = budget.as_deref_mut() {
|
||||
budget.actions(n as usize)?;
|
||||
}
|
||||
let mut actions = Vec::with_capacity((n as usize).min(256));
|
||||
for _ in 0..n {
|
||||
actions.push(read_action(&mut r, version == 12)?);
|
||||
let mut action_span = span.as_ref().map(|_| AtnActionSpan::default());
|
||||
actions.push(read_action(r, legacy, budget.as_deref_mut(), action_span.as_mut())?);
|
||||
if let (Some(span), Some(action)) = (span.as_deref_mut(), action_span) {
|
||||
span.actions.push(action);
|
||||
}
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if !r.is_empty() {
|
||||
warnings.push(format!("{} trailing bytes ignored", r.remaining()));
|
||||
if let Some(span) = span {
|
||||
span.range = start..r.pos();
|
||||
}
|
||||
Ok(AtnFile { name, expanded, actions, warnings, encoding: None })
|
||||
Ok(AtnFile { name, expanded, actions, warnings: Vec::new(), encoding: None })
|
||||
}
|
||||
|
||||
/// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown
|
||||
/// from them needs one; adds a warning when the guess is uncertain.
|
||||
fn name_encoding(f: &AtnFile, warnings: &mut Vec<String>) -> Option<&'static encoding_rs::Encoding> {
|
||||
let items = || f.actions.iter().flat_map(|a| &a.items);
|
||||
name_encoding_sets(std::slice::from_ref(f), warnings)
|
||||
}
|
||||
|
||||
fn name_encoding_sets(files: &[AtnFile], warnings: &mut Vec<String>) -> Option<&'static encoding_rs::Encoding> {
|
||||
let items = || files.iter().flat_map(|file| &file.actions).flat_map(|action| &action.items);
|
||||
let legacy = |b: &[u8]| !b.is_ascii();
|
||||
// Steps that store their name as Unicode too: the encoding that turns one into the other.
|
||||
let pairs: Vec<(&[u8], String)> = items()
|
||||
@@ -333,13 +482,26 @@ fn name_encoding(f: &AtnFile, warnings: &mut Vec<String>) -> Option<&'static enc
|
||||
return None;
|
||||
}
|
||||
// The set, action and class names are UTF-16: their script points at the writer's locale.
|
||||
let mut hint = f.name.to_string_lossy();
|
||||
for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) {
|
||||
if hint.len() > codepage::GUESS_SAMPLE {
|
||||
break;
|
||||
let mut hint = String::new();
|
||||
for (index, file) in files.iter().enumerate() {
|
||||
if index == 0 {
|
||||
hint = file.name.to_string_lossy();
|
||||
} else if hint.len() <= codepage::GUESS_SAMPLE {
|
||||
hint.push('\n');
|
||||
hint.push_str(&file.name.to_string_lossy());
|
||||
}
|
||||
for name in file
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action| action.name.to_string_lossy())
|
||||
.chain(file.actions.iter().flat_map(|action| &action.items).filter_map(class_name))
|
||||
{
|
||||
if hint.len() > codepage::GUESS_SAMPLE {
|
||||
break;
|
||||
}
|
||||
hint.push('\n');
|
||||
hint.push_str(&name);
|
||||
}
|
||||
hint.push('\n');
|
||||
hint.push_str(&name);
|
||||
}
|
||||
let guess = codepage::guess(&orphans, &hint);
|
||||
if guess.uncertain {
|
||||
@@ -385,7 +547,12 @@ fn count(n: usize, what: &'static str) -> Result<u32> {
|
||||
pub fn write(f: &AtnFile) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
out.put_u32(VERSION);
|
||||
f.name.write(&mut out);
|
||||
write_set(&mut out, f)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_set(out: &mut Vec<u8>, f: &AtnFile) -> Result<()> {
|
||||
f.name.write(out);
|
||||
out.put_u8(u8::from(f.expanded));
|
||||
out.put_u32(count(f.actions.len(), "too many actions")?);
|
||||
for a in &f.actions {
|
||||
@@ -393,14 +560,14 @@ pub fn write(f: &AtnFile) -> Result<Vec<u8>> {
|
||||
out.put_u8(u8::from(a.shift));
|
||||
out.put_u8(u8::from(a.command));
|
||||
out.put_u16(a.color);
|
||||
a.name.write(&mut out);
|
||||
a.name.write(out);
|
||||
out.put_u8(u8::from(a.expanded));
|
||||
out.put_u32(count(a.items.len(), "too many steps")?);
|
||||
for item in &a.items {
|
||||
write_item(&mut out, item)?;
|
||||
write_item(out, item)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -863,4 +1030,86 @@ mod tests {
|
||||
let _ = parse(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent wire input: duplicate raw UTF16 names, noncanonical true bytes, and a
|
||||
/// descriptor boolean whose semantic value cannot preserve its original byte by itself.
|
||||
fn spanned_wire_archive() -> Vec<u8> {
|
||||
fn ascii(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
let mut out = 16u32.to_be_bytes().to_vec();
|
||||
let raw_name = [0, 0, 0, 2, 0xd8, 0, 0, 0]; // unpaired high surrogate, then NUL
|
||||
for _ in 0..2 {
|
||||
out.extend_from_slice(&raw_name);
|
||||
out.push(2); // expanded=true, retaining its original byte matters
|
||||
out.extend_from_slice(&1u32.to_be_bytes());
|
||||
out.extend_from_slice(&[0, 3, 2, 0, 0, 12]); // F3, Shift byte2, no Command, colour12
|
||||
out.extend_from_slice(&raw_name);
|
||||
out.push(2);
|
||||
out.extend_from_slice(&2u32.to_be_bytes());
|
||||
out.extend_from_slice(&[2, 2, 0, 3]);
|
||||
out.extend_from_slice(b"longFltn");
|
||||
ascii(&mut out, b"Flatten");
|
||||
out.extend_from_slice(&0i32.to_be_bytes());
|
||||
out.extend_from_slice(&[0, 1, 2, 2]);
|
||||
out.extend_from_slice(b"TEXT");
|
||||
ascii(&mut out, b"futureAction");
|
||||
ascii(&mut out, b"Future");
|
||||
out.extend_from_slice(&(-1i32).to_be_bytes());
|
||||
out.extend_from_slice(&0u32.to_be_bytes()); // empty descriptor Unicode name
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(b"null");
|
||||
out.extend_from_slice(&1u32.to_be_bytes());
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(b"flagbool");
|
||||
out.push(2);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_spans_retain_physical_occurrences_and_original_wire_fields() {
|
||||
let bytes = spanned_wire_archive();
|
||||
let archive = parse_sets_with_spans(&bytes).unwrap();
|
||||
assert_eq!(archive.sets.len(), 2);
|
||||
assert_eq!(archive.spans.len(), 2);
|
||||
assert_eq!(archive.spans[0].range.start, 4);
|
||||
assert_eq!(archive.spans[0].range.end, archive.spans[1].range.start);
|
||||
assert_eq!(archive.spans[1].range.end, bytes.len());
|
||||
let mut untouched = bytes[..4].to_vec();
|
||||
for (file, set) in archive.sets.iter().zip(&archive.spans) {
|
||||
assert_eq!(file.name.0, [0xd800, 0]);
|
||||
assert_eq!(bytes[set.expanded.clone()], [2]);
|
||||
assert_eq!(bytes[set.count.clone()], 1u32.to_be_bytes());
|
||||
assert_eq!(set.header.end, set.actions[0].range.start);
|
||||
let action = &set.actions[0];
|
||||
assert_eq!(bytes[action.function_key.clone()], [0, 3]);
|
||||
assert_eq!(bytes[action.shift.clone()], [2]);
|
||||
assert_eq!(bytes[action.command.clone()], [0]);
|
||||
assert_eq!(bytes[action.color.clone()], [0, 12]);
|
||||
assert_eq!(bytes[action.expanded.clone()], [2]);
|
||||
assert_eq!(bytes[action.count.clone()], 2u32.to_be_bytes());
|
||||
assert_eq!(action.header.end, action.items[0].range.start);
|
||||
assert_eq!(bytes[action.items[0].flags.clone()], [2, 2, 0, 3]);
|
||||
assert_eq!(bytes[action.items[1].flags.clone()], [0, 1, 2, 2]);
|
||||
assert_eq!(action.items[0].range.end, action.items[1].range.start);
|
||||
assert_eq!(action.items[1].range.end, action.range.end);
|
||||
assert_eq!(action.range.end, set.range.end);
|
||||
assert_eq!(bytes[action.items[1].range.clone()].last(), Some(&2));
|
||||
untouched.extend_from_slice(&bytes[set.range.clone()]);
|
||||
}
|
||||
assert_eq!(untouched, bytes, "no semantic writer generates the expected raw bytes");
|
||||
assert_eq!(parse_sets(&bytes).unwrap(), archive.sets, "ordinary parsing keeps its semantic API");
|
||||
assert_ne!(write_sets(&archive.sets).unwrap(), bytes, "typed boolean rewriting is not a raw library round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_spans_reject_partial_later_records_and_legacy_layout() {
|
||||
let mut bytes = spanned_wire_archive();
|
||||
bytes.pop(); // the second set's descriptor value is incomplete
|
||||
assert!(parse_sets_with_spans(&bytes).is_err(), "no partial archive is admitted");
|
||||
assert!(parse_sets_with_spans(&16u32.to_be_bytes()).is_err(), "a version prefix is not an invented empty ATN");
|
||||
assert!(matches!(parse_sets_with_spans(&12u32.to_be_bytes()), Err(PsdError::Unsupported(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Whole-file version-16 set streams. The original single-set/legacy API remains compatible.
|
||||
use super::{
|
||||
AtnFile, AtnSetSpan, MAX_ACTIONS, MAX_ITEMS, SpannedAtnArchive, VERSION, codepage, name_encoding_sets, read_set_with_spans, read_version,
|
||||
write_set,
|
||||
};
|
||||
use crate::{
|
||||
error::{PsdError, Result},
|
||||
io::{Reader, WriteExt},
|
||||
};
|
||||
|
||||
/// Whole-archive resource ceilings, not Photoshop format maxima. Per-set/action ceilings still apply.
|
||||
pub const MAX_SETS: usize = MAX_ACTIONS;
|
||||
/// Maximum action headers across the complete archive, including any metadata sidecar.
|
||||
pub const MAX_TOTAL_ACTIONS: usize = MAX_ACTIONS;
|
||||
/// Maximum steps across the complete archive, including any metadata sidecar.
|
||||
pub const MAX_TOTAL_ITEMS: usize = MAX_ITEMS;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Budget {
|
||||
actions: usize,
|
||||
items: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(super) fn actions(&mut self, count: usize) -> Result<()> {
|
||||
if count > MAX_ACTIONS {
|
||||
return Err(PsdError::LimitExceeded("too many actions in one set"));
|
||||
}
|
||||
self.actions = self
|
||||
.actions
|
||||
.checked_add(count)
|
||||
.filter(|sum| *sum <= MAX_TOTAL_ACTIONS)
|
||||
.ok_or(PsdError::LimitExceeded("too many actions in actions archive"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn items(&mut self, count: usize) -> Result<()> {
|
||||
if count > MAX_ITEMS {
|
||||
return Err(PsdError::LimitExceeded("too many steps in one action"));
|
||||
}
|
||||
self.items = self
|
||||
.items
|
||||
.checked_add(count)
|
||||
.filter(|sum| *sum <= MAX_TOTAL_ITEMS)
|
||||
.ok_or(PsdError::LimitExceeded("too many steps in actions archive"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every set in a version-16 file, in order, with one version prefix and strict EOF.
|
||||
/// A malformed later set fails the whole result. Version12 retains the existing one-set/tail contract.
|
||||
pub fn parse_sets(data: &[u8]) -> Result<Vec<AtnFile>> {
|
||||
parse_sets_with_encoding(data, None)
|
||||
}
|
||||
|
||||
/// [`parse_sets`] with one legacy name encoding for the complete file. Automatic detection also
|
||||
/// uses the complete stream; its diagnostic is attached once to the first set.
|
||||
pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result<Vec<AtnFile>> {
|
||||
parse_archive(data, encoding, false).map(|archive| archive.sets)
|
||||
}
|
||||
|
||||
/// Read a strict complete version16 archive, retaining physical record/field ranges.
|
||||
/// Legacy version12 conversion is deliberately excluded: its descriptor dialect cannot be
|
||||
/// spliced unchanged into a version16 library. Existing import APIs still accept it.
|
||||
/// A header without any real set remains invalid; an empty logical library needs a valid carrier.
|
||||
pub fn parse_sets_with_spans(data: &[u8]) -> Result<SpannedAtnArchive> {
|
||||
parse_archive(data, None, true)
|
||||
}
|
||||
|
||||
fn parse_archive(data: &[u8], encoding: Option<&str>, capture: bool) -> Result<SpannedAtnArchive> {
|
||||
let forced = encoding.map(codepage::resolve_label).transpose()?;
|
||||
let mut reader = Reader::new(data);
|
||||
let version = read_version(&mut reader)?;
|
||||
let legacy = version == 12;
|
||||
if capture && legacy {
|
||||
return Err(PsdError::Unsupported("raw Actions library provenance requires version16".into()));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut spans = Vec::new();
|
||||
let mut budget = Budget::default();
|
||||
loop {
|
||||
if files.len() >= MAX_SETS {
|
||||
return Err(PsdError::LimitExceeded("too many sets in actions archive"));
|
||||
}
|
||||
let before = reader.remaining();
|
||||
let mut span = capture.then(AtnSetSpan::default);
|
||||
let mut file = read_set_with_spans(&mut reader, legacy, Some(&mut budget), span.as_mut())?;
|
||||
if let Some(span) = span {
|
||||
spans.push(span);
|
||||
}
|
||||
if legacy && !reader.is_empty() {
|
||||
file.warnings.push(format!("{} trailing bytes ignored", reader.remaining()));
|
||||
}
|
||||
if reader.remaining() >= before {
|
||||
return Err(PsdError::invalid("actions set made no progress"));
|
||||
}
|
||||
files.push(file);
|
||||
if legacy || reader.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
let encoding = forced.or_else(|| name_encoding_sets(&files, &mut warnings)).map(|encoding| encoding.name().to_string());
|
||||
for file in &mut files {
|
||||
file.encoding = encoding.clone();
|
||||
}
|
||||
if let Some(first) = files.first_mut() {
|
||||
first.warnings.extend(warnings);
|
||||
}
|
||||
Ok(SpannedAtnArchive { sets: files, spans })
|
||||
}
|
||||
|
||||
/// Write all sets under one version16 prefix. Validate aggregate counts before allocating output.
|
||||
/// At least one set is required; an empty set is valid. Stored names/descriptors stay verbatim.
|
||||
pub fn write_sets(files: &[AtnFile]) -> Result<Vec<u8>> {
|
||||
if files.is_empty() {
|
||||
return Err(PsdError::invalid("actions archive must contain at least one set"));
|
||||
}
|
||||
if files.len() > MAX_SETS {
|
||||
return Err(PsdError::LimitExceeded("too many sets in actions archive"));
|
||||
}
|
||||
let mut budget = Budget::default();
|
||||
for file in files {
|
||||
budget.actions(file.actions.len())?;
|
||||
for action in &file.actions {
|
||||
budget.items(action.items.len())?;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.put_u32(VERSION);
|
||||
for file in files {
|
||||
write_set(&mut out, file)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Independently specified version16 wire streams: one header, consecutive set bodies.
|
||||
use craft_psd::{atn, descriptor::Value, error::PsdError};
|
||||
use proptest::prelude::*;
|
||||
|
||||
fn u32be(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
fn unicode(out: &mut Vec<u8>, units: &[u16]) {
|
||||
u32be(out, units.len() as u32);
|
||||
for unit in units {
|
||||
out.extend_from_slice(&unit.to_be_bytes());
|
||||
}
|
||||
}
|
||||
fn text(out: &mut Vec<u8>, value: &str) {
|
||||
unicode(out, &value.encode_utf16().collect::<Vec<_>>());
|
||||
}
|
||||
fn id(out: &mut Vec<u8>, key: &str) {
|
||||
if key.len() == 4 {
|
||||
u32be(out, 0);
|
||||
} else {
|
||||
u32be(out, key.len() as u32);
|
||||
}
|
||||
out.extend_from_slice(key.as_bytes());
|
||||
}
|
||||
fn descriptor(items: &[(&str, &[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
unicode(&mut out, &[]);
|
||||
id(&mut out, "null");
|
||||
u32be(&mut out, items.len() as u32);
|
||||
for (key, kind, value) in items {
|
||||
id(&mut out, key);
|
||||
out.extend_from_slice(*kind);
|
||||
out.extend(value);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn list(descriptors: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
u32be(&mut out, descriptors.len() as u32);
|
||||
for descriptor in descriptors {
|
||||
out.extend_from_slice(b"Objc");
|
||||
out.extend(descriptor);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn string(value: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
text(&mut out, value);
|
||||
out
|
||||
}
|
||||
fn set_header(out: &mut Vec<u8>, name: &[u16], actions: u32) {
|
||||
unicode(out, name);
|
||||
out.push(0);
|
||||
u32be(out, actions);
|
||||
}
|
||||
fn action_header(out: &mut Vec<u8>, name: &str, items: u32) {
|
||||
out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // Fn, Shift, Control and colour.
|
||||
text(out, name);
|
||||
out.push(0);
|
||||
u32be(out, items);
|
||||
}
|
||||
fn bare_item(out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&[0, 1, 0, 0]);
|
||||
out.extend_from_slice(b"longInvr");
|
||||
u32be(out, 0);
|
||||
u32be(out, 0);
|
||||
}
|
||||
|
||||
fn fixture() -> (Vec<u8>, usize, Vec<u8>, Vec<u8>) {
|
||||
let metadata = descriptor(&[
|
||||
("actionDescriptionKey", b"TEXT", string("Description\0")),
|
||||
("actionThumbnailTypeKey", b"long", 3i32.to_be_bytes().to_vec()),
|
||||
("actionIconKey", b"TEXT", string("future-icon\0")),
|
||||
("actionPreviewKey", b"bool", vec![0]),
|
||||
("actionGenerativeKey", b"bool", vec![0]),
|
||||
("actionPreconditionVal", b"long", (-1i32).to_be_bytes().to_vec()),
|
||||
("futureDouble", b"doub", 0x7ff8_0000_0000_0042u64.to_be_bytes().to_vec()),
|
||||
]);
|
||||
let first = descriptor(&[("setColorIndex", b"long", 7i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[metadata]))]);
|
||||
let empty = descriptor(&[("setColorIndex", b"long", 19i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[]))]);
|
||||
let root = descriptor(&[("sets", b"VlLs", list(&[first, empty])), ("futureRaw", b"tdta", vec![0, 0, 0, 3, 0, 255, 7])]);
|
||||
let mut payload = 16u32.to_be_bytes().to_vec();
|
||||
payload.extend(root);
|
||||
let mut raw = Vec::new();
|
||||
u32be(&mut raw, payload.len() as u32);
|
||||
raw.extend(&payload);
|
||||
let outer = descriptor(&[("Msge", b"TEXT", string("Original note\0")), ("Cntn", b"bool", vec![1]), ("modernizedActionData", b"tdta", raw)]);
|
||||
let mut bytes = 16u32.to_be_bytes().to_vec();
|
||||
set_header(&mut bytes, &"Real\0".encode_utf16().collect::<Vec<_>>(), 1);
|
||||
action_header(&mut bytes, "Invert\0", 1);
|
||||
bare_item(&mut bytes);
|
||||
let first_end = bytes.len();
|
||||
set_header(&mut bytes, &[0xd800, 0], 0); // Empty real set and unpaired UTF16 remain exact.
|
||||
set_header(&mut bytes, &"Action sidecar\0".encode_utf16().collect::<Vec<_>>(), 1);
|
||||
action_header(&mut bytes, "Play for details\0", 1);
|
||||
bytes.extend_from_slice(&[0, 1, 1, 2]);
|
||||
bytes.extend_from_slice(b"TEXT");
|
||||
u32be(&mut bytes, 4);
|
||||
bytes.extend_from_slice(b"stop");
|
||||
u32be(&mut bytes, 4);
|
||||
bytes.extend_from_slice(b"Stop");
|
||||
bytes.extend_from_slice(&(-1i32).to_be_bytes());
|
||||
bytes.extend(&outer);
|
||||
(bytes, first_end, outer, payload)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_and_three_set_bodies_preserve_raw_sidecar_descriptor_separately() {
|
||||
let (bytes, first_end, outer, payload) = fixture();
|
||||
assert_eq!(bytes.get(first_end..first_end + 4).unwrap(), &2u32.to_be_bytes());
|
||||
let sets = atn::parse_sets(&bytes).unwrap();
|
||||
assert_eq!(sets.len(), 3);
|
||||
assert_eq!(sets[0].name.to_string_lossy(), "Real");
|
||||
assert_eq!(sets[1].name.0, vec![0xd800, 0]);
|
||||
assert!(sets[1].actions.is_empty());
|
||||
let sidecar = sets[2].actions[0].items[0].descriptor.as_ref().unwrap();
|
||||
assert_eq!(sidecar.to_bytes(), outer, "descriptor bytes have their own independent oracle");
|
||||
assert_eq!(sidecar.get("modernizedActionData"), Some(&Value::RawData(payload)));
|
||||
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
|
||||
let single = atn::parse(&bytes).unwrap();
|
||||
assert_eq!(single.name, sets[0].name);
|
||||
assert_eq!(single.warnings, [format!("{} trailing bytes ignored", bytes.len() - first_end)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncated_later_set_fails_without_returning_partial_sets() {
|
||||
let (bytes, first_end, _, _) = fixture();
|
||||
// The first complete set alone is valid. The second complete empty set is another valid prefix.
|
||||
let second_end = first_end + 13;
|
||||
for cut in first_end + 1..bytes.len() {
|
||||
if cut != second_end {
|
||||
assert!(atn::parse_sets(&bytes[..cut]).is_err(), "cut {cut}");
|
||||
}
|
||||
}
|
||||
assert_eq!(atn::parse_sets(&bytes[..first_end]).unwrap().len(), 1);
|
||||
assert_eq!(atn::parse_sets(&bytes[..second_end]).unwrap().len(), 2);
|
||||
let mut repeated = bytes[..first_end].to_vec();
|
||||
repeated.extend_from_slice(&16u32.to_be_bytes());
|
||||
set_header(&mut repeated, &[], 0);
|
||||
assert!(atn::parse_sets(&repeated).is_err(), "a repeated version is not the measured set boundary");
|
||||
assert!(atn::parse_sets(&16u32.to_be_bytes()).is_err());
|
||||
assert!(atn::write_sets(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_encoding_applies_to_all_sets_and_legacy_tails_remain_compatible() {
|
||||
let (bytes, _, _, _) = fixture();
|
||||
let sets = atn::parse_sets_with_encoding(&bytes, Some("shift_jis")).unwrap();
|
||||
assert!(sets.iter().all(|set| set.encoding.as_deref() == Some("Shift_JIS")));
|
||||
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
|
||||
assert!(atn::parse_sets_with_encoding(&bytes, Some("utf-16")).is_err());
|
||||
let mut legacy = 12u32.to_be_bytes().to_vec();
|
||||
set_header(&mut legacy, &[], 0);
|
||||
legacy.extend_from_slice(&[99, 1, 4]);
|
||||
let old = atn::parse(&legacy).unwrap();
|
||||
assert_eq!(atn::parse_sets(&legacy).unwrap(), vec![old.clone()]);
|
||||
assert_eq!(old.warnings, ["3 trailing bytes ignored"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_archive_set_and_action_budgets_are_checked_across_boundaries() {
|
||||
let mut bytes = 16u32.to_be_bytes().to_vec();
|
||||
for _ in 0..atn::MAX_SETS {
|
||||
set_header(&mut bytes, &[], 0);
|
||||
}
|
||||
let sets = atn::parse_sets(&bytes).unwrap();
|
||||
assert_eq!(sets.len(), atn::MAX_SETS);
|
||||
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
|
||||
set_header(&mut bytes, &[], 0);
|
||||
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
|
||||
let mut excess = sets;
|
||||
excess.push(Default::default());
|
||||
assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_))));
|
||||
|
||||
let mut bytes = 16u32.to_be_bytes().to_vec();
|
||||
for _ in 0..2 {
|
||||
set_header(&mut bytes, &[], (atn::MAX_TOTAL_ACTIONS / 2) as u32);
|
||||
for _ in 0..atn::MAX_TOTAL_ACTIONS / 2 {
|
||||
action_header(&mut bytes, "", 0);
|
||||
}
|
||||
}
|
||||
let sets = atn::parse_sets(&bytes).unwrap();
|
||||
assert_eq!(sets.iter().map(|set| set.actions.len()).sum::<usize>(), atn::MAX_TOTAL_ACTIONS);
|
||||
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
|
||||
set_header(&mut bytes, &[], 1);
|
||||
action_header(&mut bytes, "", 0);
|
||||
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_steps_are_bounded_before_a_later_action_allocates() {
|
||||
let mut bytes = 16u32.to_be_bytes().to_vec();
|
||||
set_header(&mut bytes, &[], 2);
|
||||
for _ in 0..2 {
|
||||
action_header(&mut bytes, "", (atn::MAX_TOTAL_ITEMS / 2) as u32);
|
||||
for _ in 0..atn::MAX_TOTAL_ITEMS / 2 {
|
||||
bare_item(&mut bytes);
|
||||
}
|
||||
}
|
||||
let sets = atn::parse_sets(&bytes).unwrap();
|
||||
assert_eq!(atn::write_sets(&sets).unwrap(), bytes);
|
||||
set_header(&mut bytes, &[], 1);
|
||||
action_header(&mut bytes, "", 1);
|
||||
bare_item(&mut bytes);
|
||||
assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_))));
|
||||
let mut excess = sets;
|
||||
excess[0].actions[0].items.push(Default::default());
|
||||
assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||
|
||||
#[test]
|
||||
fn arbitrary_streams_reject_or_reach_a_stable_writer(data in prop::collection::vec(any::<u8>(), 0..2048)) {
|
||||
if let Ok(files) = atn::parse_sets(&data) {
|
||||
let encoded = atn::write_sets(&files).unwrap();
|
||||
let again = atn::parse_sets(&encoded).unwrap();
|
||||
prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_independent_multiset_stream_rejects_or_reaches_a_stable_writer(index in any::<usize>(), value in any::<u8>()) {
|
||||
let (mut bytes, _, _, _) = fixture();
|
||||
let at = index % bytes.len(); bytes[at] = value;
|
||||
if let Ok(files) = atn::parse_sets(&bytes) {
|
||||
let encoded = atn::write_sets(&files).unwrap();
|
||||
let again = atn::parse_sets(&encoded).unwrap();
|
||||
prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user