Merge PR 1 (codecs, now on current main) into PR 3

Brings split/tiff-extra-samples-codecs at e6fca054, which merged main
1695f323. Conflicts:
- crates/io/src/lib.rs: main moved PSD import into import_psd(); it now
  passes PsdImportOptions::default().
- PsdExportOptions (psd_export.rs, tiff_layers.rs): main's new
  read_linked_files next to this branch's photoshop_spot_polarity.
- docs: main's newer status lines and rows, with this branch's TIFF
  statements re-applied (dated 2026-10-23). docs/scorecard.md is main's
  and is regenerated after PR 2 is merged too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jason D
2026-10-23 08:48:35 -04:00
936 changed files with 73101 additions and 6226 deletions
+2 -1
View File
@@ -106,6 +106,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla
| PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD |
| Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position |
| luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` |
| Layer XMP (`shmd` › `cust` › `layerXMP`) | ✓ | ✓ | ✓ | `metadata::layer_xmp` / `take_layer_xmp` / `put_layer_xmp`, laid out as Photoshop 2026 writes it |
| ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper |
| Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting |
| RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone |
@@ -144,7 +145,7 @@ allocations for padded channels return an error on failure.
* Builder → `rgba8` correctness.
* Unknown blend modes and unknown blocks pass through unchanged.
`tests/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently.
`tests/it/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently.
A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`.
+2 -2
View File
@@ -66,8 +66,8 @@ fn main() {
for item in &a.items {
let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" });
match &item.descriptor {
Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)),
None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary),
Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.label(), keys(d)),
None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()),
}
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Check `.atn` files for byte-exact round trips: parse, write, compare, and show the first
//! difference with its context: `cargo run -p photocraft-psd --example atn_roundtrip -- set.atn…`
//!
//! Exit status 1 when any file differs or fails to parse.
use photocraft_psd::atn;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect::<Vec<_>>().join(" ")
}
fn main() {
let mut failed = false;
for path in std::env::args().skip(1) {
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
eprintln!("{path}: {e}");
failed = true;
continue;
}
};
let file = match atn::parse(&bytes) {
Ok(f) => f,
Err(e) => {
println!("{path}: parse failed: {e}");
failed = true;
continue;
}
};
let written = match atn::write(&file) {
Ok(w) => w,
Err(e) => {
println!("{path}: write failed: {e}");
failed = true;
continue;
}
};
if written == bytes {
println!("{path}: identical ({} bytes)", bytes.len());
continue;
}
failed = true;
let at = bytes.iter().zip(&written).position(|(a, b)| a != b).unwrap_or(bytes.len().min(written.len()));
let lo = at.saturating_sub(24);
println!("{path}: differs at byte {at} (original {} bytes, rewritten {} bytes)", bytes.len(), written.len());
println!(" original : {}", hex(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])));
println!(" rewritten: {}", hex(written.get(lo..(at + 40).min(written.len())).unwrap_or(&[])));
println!(" original : {}", String::from_utf8_lossy(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])).replace(|c: char| c.is_control(), "."));
}
if failed {
std::process::exit(1);
}
}
+10 -172
View File
@@ -1,186 +1,24 @@
//! Writes multi-gigabyte PSB files for large-file tests (#375, #216), straight from the Adobe
//! PSD spec and independent of this crate's writer, so the fixtures don't inherit its limits.
//! Writes multi-gigabyte PSB files (and the 2 GB PSD) for large-file tests (#375, #216), straight
//! from the Adobe PSD spec and independent of this crate's writer, so the fixtures don't inherit
//! its limits. The generator is `photocraft_psd::fixtures` (feature `testgen`).
//!
//! `cargo run --release -p photocraft-psd --example big_psb -- <out dir> [case...]`
//!
//! Every sample follows [`sample`], so a reader can check any pixel without the file. RLE cases
//! keep each row constant (tiny files, full decoded size); raw cases vary along x too.
//! Every sample follows `fixtures::sample`, so a reader can check any pixel without the file. RLE
//! cases keep each row constant (tiny files, full decoded size); raw cases vary along x too.
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use photocraft_psd::fixtures::{PSB_CASES, PSD_2GB};
/// One generated file.
struct Case {
name: &'static str,
width: u32,
height: u32,
depth: u16,
/// Raw (0) or RLE (1).
rle: bool,
/// One full-canvas RGBA layer, or a flattened file (composite only).
layered: bool,
}
const CASES: &[Case] = &[
// Composite of 2.52 GB in one decode.
Case { name: "flat8-29k-rle", width: 29_000, height: 29_000, depth: 8, rle: true, layered: false },
// A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB.
Case { name: "flat8-40k-raw", width: 40_000, height: 40_000, depth: 8, rle: false, layered: false },
// 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB.
Case { name: "layer8-25k-raw", width: 25_000, height: 25_000, depth: 8, rle: false, layered: true },
// Layer channels of 900 MB, composite of 2.7 GB.
Case { name: "layer8-30k-rle", width: 30_000, height: 30_000, depth: 8, rle: true, layered: true },
// Layer channels of 2.18 GB each.
Case { name: "layer16-33k-rle", width: 33_000, height: 33_000, depth: 16, rle: true, layered: true },
];
/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`). RLE files ignore `x`.
/// 16-bit files store `v * 257`.
pub fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 {
if c == 3 {
return 255;
}
let x = if rle { 0 } else { x };
(x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8
}
fn main() -> io::Result<()> {
fn main() -> std::io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let out = args.first().map_or_else(|| std::env::temp_dir().join("photocraft-big-psb"), std::path::PathBuf::from);
std::fs::create_dir_all(&out)?;
let wanted = args.get(1..).unwrap_or(&[]);
for case in CASES.iter().filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) {
let path = out.join(format!("{}.psb", case.name));
for case in PSB_CASES.iter().chain([&PSD_2GB]).filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) {
let path = out.join(case.file_name());
let t = std::time::Instant::now();
write_case(case, &path)?;
let len = std::fs::metadata(&path)?.len();
let len = case.write_file(&path)?;
println!("{}: {:.2} GB in {:.1} s", path.display(), len as f64 / 1e9, t.elapsed().as_secs_f64());
}
Ok(())
}
fn row_bytes(c: &Case) -> u64 {
u64::from(c.width) * u64::from(c.depth / 8)
}
/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder.
fn rle_row(n: u64, b: u8) -> Vec<u8> {
let mut v = Vec::new();
let mut left = n;
while left > 0 {
let run = left.min(128);
if run == 1 {
v.extend_from_slice(&[0, b]);
} else {
v.extend_from_slice(&[(257 - run) as u8, b]);
}
left -= run;
}
v
}
/// The bytes of one row of channel `ch`.
fn row(c: &Case, y: u32, ch: u32, buf: &mut Vec<u8>) {
buf.clear();
if c.rle {
buf.extend(rle_row(row_bytes(c), sample(0, y, ch, true)));
return;
}
for x in 0..c.width {
let s = sample(x, y, ch, false);
buf.push(s);
if c.depth == 16 {
buf.push(s);
}
}
}
/// Size of a channel block (compression word, RLE counts, rows) of `planes` planes.
fn channel_len(c: &Case, planes: u64) -> u64 {
let rows = planes * u64::from(c.height);
if c.rle { 2 + rows * 4 + rows * rle_row(row_bytes(c), 0).len() as u64 } else { 2 + rows * row_bytes(c) }
}
/// Writes the compression word, the counts and the rows of `chans`.
fn write_planes(w: &mut impl Write, c: &Case, chans: &[u32]) -> io::Result<()> {
w.write_all(&u16::from(c.rle).to_be_bytes())?;
if c.rle {
let n = rle_row(row_bytes(c), 0).len() as u32;
for _ in 0..chans.len() as u64 * u64::from(c.height) {
w.write_all(&n.to_be_bytes())?;
}
}
let mut buf = Vec::new();
for &ch in chans {
for y in 0..c.height {
row(c, y, ch, &mut buf);
w.write_all(&buf)?;
}
}
Ok(())
}
fn write_case(c: &Case, path: &Path) -> io::Result<()> {
let mut w = BufWriter::with_capacity(8 << 20, File::create(path)?);
// Header: PSB (version 2), 3 channels, RGB.
w.write_all(b"8BPS")?;
w.write_all(&2u16.to_be_bytes())?;
w.write_all(&[0; 6])?;
w.write_all(&3u16.to_be_bytes())?;
w.write_all(&c.height.to_be_bytes())?;
w.write_all(&c.width.to_be_bytes())?;
w.write_all(&c.depth.to_be_bytes())?;
w.write_all(&3u16.to_be_bytes())?;
// Colour mode data and image resources: empty.
w.write_all(&0u32.to_be_bytes())?;
w.write_all(&0u32.to_be_bytes())?;
if c.layered {
// Transparency (-1), then R, G, B: one block per channel.
let ids: [(i16, u32); 4] = [(-1, 3), (0, 0), (1, 1), (2, 2)];
let ch_len = channel_len(c, 1);
let name = b"Layer 1";
let mut rec = Vec::new();
for v in [0i32, 0, c.height as i32, c.width as i32] {
rec.extend_from_slice(&v.to_be_bytes());
}
rec.extend_from_slice(&4u16.to_be_bytes());
for (id, _) in ids {
rec.extend_from_slice(&id.to_be_bytes());
rec.extend_from_slice(&ch_len.to_be_bytes());
}
rec.extend_from_slice(b"8BIMnorm");
rec.extend_from_slice(&[255, 0, 0, 0]);
// Extra data: no mask, no blending ranges, the Pascal name padded to 4.
let mut extra = Vec::new();
extra.extend_from_slice(&0u32.to_be_bytes());
extra.extend_from_slice(&0u32.to_be_bytes());
extra.push(name.len() as u8);
extra.extend_from_slice(name);
while extra.len() % 4 != 0 {
extra.push(0);
}
rec.extend_from_slice(&(extra.len() as u32).to_be_bytes());
rec.extend_from_slice(&extra);
let mut layer_info = 2 + rec.len() as u64 + 4 * ch_len;
let pad = layer_info % 2;
layer_info += pad;
// Layer and mask information: layer info (8-byte length) + empty global mask (4 bytes).
w.write_all(&(8 + layer_info + 4).to_be_bytes())?;
w.write_all(&layer_info.to_be_bytes())?;
w.write_all(&1i16.to_be_bytes())?;
w.write_all(&rec)?;
for (_, ch) in ids {
write_planes(&mut w, c, &[ch])?;
}
w.write_all(&vec![0; pad as usize])?;
w.write_all(&0u32.to_be_bytes())?;
} else {
w.write_all(&0u64.to_be_bytes())?;
}
// The merged image.
write_planes(&mut w, c, &[0, 1, 2])?;
w.flush()
}
+1 -1
View File
@@ -3,7 +3,7 @@
//!
//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len`
//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with
//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`.
//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/it/swatch_malformed.rs`.
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_psd::grd::{self, GrdColor};
+40 -9
View File
@@ -435,6 +435,7 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result
return Err(PsdError::Unsupported(format!("brush file subversion {}", out.subversion)));
}
let mut hierarchy = None;
let mut patterns = PatternBudget::default();
while r.left() >= 12 {
let sig = r.arr::<4>()?;
if &sig != b"8BIM" {
@@ -458,16 +459,17 @@ fn parse_v6_within(r: &mut Rd, out: &mut AbrFile, budget: &mut Budget) -> Result
match &key {
b"samp" => read_samp(data, out.subversion, out, budget)?,
b"patt" => {
// Patterns share the samples' budget, so neither can claim it twice.
// Patterns share the samples' budget and one padding budget across every `patt`
// section, so neither can be claimed twice and failed blocks still deduct their
// decoded charges.
let limit = u64::try_from(budget.0).unwrap_or(u64::MAX);
let mut patterns = PatternBudget::with_decoded_limit(limit);
patterns.cap_decoded_to(limit);
let left = patterns.decoded_left();
match parse_pattern_block_with(data, &mut patterns) {
Ok(p) => {
let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX);
budget.spend(spent)?;
out.patterns.extend(p);
}
let res = parse_pattern_block_with(data, &mut patterns);
let spent = usize::try_from(left.saturating_sub(patterns.decoded_left())).unwrap_or(usize::MAX);
budget.spend(spent)?;
match res {
Ok(p) => out.patterns.extend(p),
Err(e) => out.warnings.push(format!("embedded patterns unreadable: {e}")),
}
}
@@ -706,10 +708,39 @@ mod tests {
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 2, 0));
// One byte short: the patterns no longer fit after the sample (they used not to count).
let (out, left) = parse_within(189);
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 89));
assert_eq!((out.samples.len(), out.patterns.len(), left), (1, 0, 14));
assert!(out.warnings.iter().any(|w| w.contains("embedded patterns unreadable")), "{:?}", out.warnings);
}
#[test]
fn failed_pattern_section_does_not_refund_budget_to_later_sections() {
let p = PsdPattern {
mode: 3,
width: 5,
height: 3,
name: "p".into(),
id: "id".into(),
palette: None,
depth: 8,
channels: vec![vec![1; 15], vec![2; 15], vec![3; 15]],
alpha: None,
};
let valid = write_pattern_block(std::slice::from_ref(&p)).unwrap();
let mut malformed = valid.clone();
malformed.extend_from_slice(&100u32.to_be_bytes());
let mut body = 2u16.to_be_bytes().to_vec();
section(&mut body, b"patt", &malformed);
section(&mut body, b"patt", &valid);
let mut out = AbrFile::default();
let mut budget = Budget(60);
parse_v6_within(&mut Rd::new(&body), &mut out, &mut budget).unwrap();
assert!(out.patterns.is_empty(), "a failed block is discarded atomically");
assert_eq!(budget.0, 0, "the failed block's first 45 bytes and later partial 15-byte charge are retained");
assert_eq!(out.warnings.iter().filter(|w| w.contains("embedded patterns unreadable")).count(), 2);
}
#[test]
fn v1_v2_round_trip_raw_and_rle() {
for version in [1u16, 2] {
+42
View File
@@ -166,6 +166,36 @@ pub fn parse(data: &[u8]) -> Result<AcoFile> {
}
}
/// How many bytes the `.aco` sections at the start of `data` span (what [`parse`] reads), or
/// `None` when they are truncated or the version is unknown. Tells a real `.aco` apart from
/// bytes that only start like one, such as an `.act` whose first colour is `0, 1, x`.
pub fn sections_len(data: &[u8]) -> Option<usize> {
fn skip_section(r: &mut Reader<'_>, named: bool) -> Result<()> {
let count = r.u16()?;
for _ in 0..count {
r.skip(10)?;
if named {
let n = u64::from(r.u32()?);
r.bytes_u64(n * 2)?;
}
}
Ok(())
}
let mut r = Reader::new(data);
match r.u16().ok()? {
1 => {
skip_section(&mut r, false).ok()?;
if r.remaining() >= 4 && r.peek_rest().get(..2) == Some(&[0, 2]) {
r.u16().ok()?;
skip_section(&mut r, true).ok()?;
}
}
2 => skip_section(&mut r, true).ok()?,
_ => return None,
}
Some(r.pos())
}
/// Write an `.aco` file: a version 1 section followed by a version 2 section with the names,
/// as Photoshop writes them. Fails when there are more than [`MAX_SWATCHES`] swatches.
pub fn write(swatches: &[AcoSwatch]) -> Result<Vec<u8>> {
@@ -210,6 +240,18 @@ mod tests {
]
}
#[test]
fn sections_len_is_what_parse_reads() {
let file = write(&sample()).unwrap();
assert_eq!(sections_len(&file), Some(file.len()));
let mut padded = file.clone();
padded.extend_from_slice(&[0; 6]);
assert_eq!(sections_len(&padded), Some(file.len()), "trailing bytes are not part of it");
assert_eq!(sections_len(&file[..file.len() - 1]), None, "truncated");
assert_eq!(sections_len(&[0, 3, 0, 0]), None, "unknown version");
assert_eq!(sections_len(&[0, 1, 0, 0]), Some(4));
}
#[test]
fn round_trips_every_space_at_16_bits() {
let s = sample();
+143
View File
@@ -0,0 +1,143 @@
//! Photoshop Color Table files (`.act`): raw 8-bit RGB palettes.
//!
//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Table): 256 RGB
//! triplets (768 bytes), optionally followed by a 4-byte trailer: a big-endian `u16` color
//! count ("the number of colors to use") and a big-endian `u16` transparent color index.
//! There are no names, no groups and no other color spaces. The trailer is kept verbatim so a
//! read → write round trip never loses it; interpreting it is the caller's job.
use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt};
/// Colors in a `.act` palette.
pub const COLOR_COUNT: usize = 256;
/// Palette bytes: 256 RGB triplets.
pub const PALETTE_LEN: usize = COLOR_COUNT * 3;
/// Trailer bytes (color count + transparent index).
pub const TRAILER_LEN: usize = 4;
/// A parsed `.act` file: the full 256-color palette plus the optional trailer, kept verbatim.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActFile {
/// All 256 palette colors as RGB triplets.
pub colors: [[u8; 3]; COLOR_COUNT],
/// Trailer color count ("the number of colors to use"), if the file has one.
pub count: Option<u16>,
/// Trailer transparent color index, if the file has one.
pub transparent: Option<u16>,
}
impl ActFile {
/// Colors to use: the trailer count clamped to the palette, else the whole palette.
pub fn used(&self) -> &[[u8; 3]] {
let n = self.count.map_or(COLOR_COUNT, |c| (c as usize).min(COLOR_COUNT));
&self.colors[..n]
}
/// The transparent color index, if the trailer names one inside the used colors.
pub fn transparent_index(&self) -> Option<usize> {
let t = usize::from(self.transparent?);
(t < self.used().len()).then_some(t)
}
}
/// Parse an `.act` file. Anything but 768 bytes (palette only) or 772 bytes (plus the
/// trailer) is an error; never panics.
pub fn parse(data: &[u8]) -> Result<ActFile> {
if data.len() != PALETTE_LEN && data.len() != PALETTE_LEN + TRAILER_LEN {
return Err(PsdError::invalid("an .act file is 768 bytes, or 772 with the count trailer"));
}
let mut r = Reader::new(data);
let raw = r.bytes(PALETTE_LEN)?;
let mut colors = [[0u8; 3]; COLOR_COUNT];
for (i, c) in colors.iter_mut().enumerate() {
let o = i * 3;
c.copy_from_slice(&raw[o..o + 3]);
}
let (count, transparent) = if r.remaining() >= TRAILER_LEN { (Some(r.u16()?), Some(r.u16()?)) } else { (None, None) };
Ok(ActFile { colors, count, transparent })
}
/// Write an `.act` file: the 256-color palette plus the trailer when `count` or `transparent` is given.
/// Short palettes are padded with black; fails on a palette longer than 256 colors.
pub fn write(colors: &[[u8; 3]], count: Option<u16>, transparent: Option<u16>) -> Result<Vec<u8>> {
if colors.len() > COLOR_COUNT {
return Err(PsdError::LimitExceeded("an .act file holds at most 256 colors"));
}
let mut out = Vec::with_capacity(PALETTE_LEN + TRAILER_LEN);
for c in colors {
out.extend_from_slice(c);
}
out.resize(PALETTE_LEN, 0);
if count.is_some() || transparent.is_some() {
out.put_u16(count.unwrap_or(COLOR_COUNT as u16));
out.put_u16(transparent.unwrap_or(0xFFFF));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> [[u8; 3]; COLOR_COUNT] {
let mut colors = [[0u8; 3]; COLOR_COUNT];
for (i, c) in colors.iter_mut().enumerate() {
let v = i as u8;
*c = [v, v.wrapping_mul(7), v.wrapping_add(13)];
}
colors[0] = [255, 255, 255];
colors
}
#[test]
fn round_trips_with_and_without_the_trailer() {
let colors = sample();
let bare = write(&colors, None, None).unwrap();
assert_eq!(bare.len(), PALETTE_LEN);
let f = parse(&bare).unwrap();
assert_eq!((f.count, f.transparent), (None, None));
assert_eq!(f.used(), &colors);
assert_eq!(f.transparent_index(), None);
assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), bare, "byte-stable");
let trailer = write(&colors, Some(40), Some(7)).unwrap();
assert_eq!(trailer.len(), PALETTE_LEN + TRAILER_LEN);
let f = parse(&trailer).unwrap();
assert_eq!((f.count, f.transparent), (Some(40), Some(7)));
assert_eq!(f.used().len(), 40);
assert_eq!(f.transparent_index(), Some(7));
assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), trailer, "byte-stable");
}
#[test]
fn short_palettes_pad_and_long_ones_fail() {
let few = vec![[1u8, 2, 3]; 3];
let bytes = write(&few, None, None).unwrap();
assert_eq!(bytes.len(), PALETTE_LEN);
assert_eq!(&bytes[..9], &[1u8, 2, 3, 1, 2, 3, 1, 2, 3]);
assert_eq!(&bytes[9..12], &[0u8, 0, 0]);
let many = vec![[0u8; 3]; COLOR_COUNT + 1];
assert!(write(&many, None, None).is_err());
}
#[test]
fn hostile_lengths_are_errors_not_panics() {
let good = write(&sample(), Some(256), Some(255)).unwrap();
assert_eq!(good.len(), PALETTE_LEN + TRAILER_LEN);
for n in 0..good.len() {
// Only the two exact lengths parse (768 reads as a trailer-less palette).
if n == PALETTE_LEN {
assert!(parse(&good[..n]).is_ok(), "{n}");
} else {
assert!(parse(&good[..n]).is_err(), "{n}");
}
}
assert!(parse(&[]).is_err());
assert!(parse(&vec![0u8; PALETTE_LEN + TRAILER_LEN + 1]).is_err(), "trailing byte");
// A count past the palette clamps; a transparent index past it is none.
let f = parse(&write(&sample(), Some(1000), Some(999)).unwrap()).unwrap();
assert_eq!(f.used().len(), COLOR_COUNT);
assert_eq!(f.transparent_index(), None);
}
}
+61 -21
View File
@@ -17,8 +17,10 @@
//!
//! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the
//! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events
//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are
//! rejected as unsupported.
//! carry them. The event name and the panel name are byte strings (ASCII, or the platform's
//! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim;
//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a
//! different, undocumented layout and are rejected as unsupported.
//!
//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor
//! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so
@@ -26,7 +28,7 @@
use crate::descriptor::{Descriptor, UnicodeString};
use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt};
use crate::io::{Reader, WriteExt, decode_legacy_name};
/// The only version this module reads and writes (Photoshop 6 and every later release).
pub const VERSION: u32 = 16;
@@ -84,8 +86,9 @@ pub struct AtnItem {
pub dialog_options: u8,
/// The event.
pub event: AtnEvent,
/// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …).
pub dictionary: String,
/// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …), as stored:
/// ASCII, or the authoring platform's legacy encoding when localised. See [`Self::label`].
pub dictionary: Vec<u8>,
/// The event's parameters; `None` for events without any.
pub descriptor: Option<Descriptor>,
}
@@ -98,49 +101,71 @@ impl Default for AtnItem {
with_dialog: false,
dialog_options: 0,
event: AtnEvent::Code(*b" "),
dictionary: String::new(),
dictionary: Vec::new(),
descriptor: None,
}
}
}
impl AtnItem {
/// The panel name as text: UTF-8 when it is, otherwise decoded byte by byte (Latin-1).
pub fn dictionary(&self) -> String {
decode_legacy_name(&self.dictionary)
}
/// A readable name for the step: the descriptor's class name when Photoshop stored one
/// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id.
pub fn label(&self) -> String {
let class = self.descriptor.as_ref().map(|d| d.name.to_string_lossy()).unwrap_or_default();
if !class.trim().is_empty() {
return class;
}
let dict = self.dictionary();
if dict.trim().is_empty() { self.event.id() } else { dict }
}
}
/// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AtnEvent {
/// Stored as `long` + 4 bytes.
Code([u8; 4]),
/// Stored as `TEXT` + length-prefixed ASCII.
Name(String),
/// Stored as `TEXT` + length-prefixed bytes (ASCII in every file seen).
Name(Vec<u8>),
}
impl AtnEvent {
/// A string-named event.
pub fn name(s: &str) -> Self {
AtnEvent::Name(s.as_bytes().to_vec())
}
/// The event id as text: the code's four characters, or the name.
pub fn id(&self) -> String {
match self {
AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(),
AtnEvent::Name(n) => n.clone(),
AtnEvent::Name(n) => String::from_utf8_lossy(n).into_owned(),
}
}
/// `true` if the event is the code or name `s`.
pub fn is(&self, s: &str) -> bool {
match self {
AtnEvent::Code(c) => c == s.as_bytes(),
AtnEvent::Name(n) => n == s,
AtnEvent::Name(n) => n == s.as_bytes(),
}
}
}
/// Reads a u32 length + ASCII bytes (event and dictionary names).
fn read_ascii(r: &mut Reader<'_>) -> Result<String> {
/// Reads a u32 length + bytes (event and dictionary names), verbatim.
fn read_ascii(r: &mut Reader<'_>) -> Result<Vec<u8>> {
let n = r.u32()?;
Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned())
Ok(r.bytes_u64(u64::from(n))?.to_vec())
}
fn write_ascii(out: &mut Vec<u8>, s: &str) -> Result<()> {
fn write_ascii(out: &mut Vec<u8>, s: &[u8]) -> Result<()> {
let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?;
out.put_u32(n);
out.put(s.as_bytes());
out.put(s);
Ok(())
}
@@ -277,12 +302,12 @@ mod tests {
with_dialog: true,
dialog_options: 2,
event: AtnEvent::Code(*b"GsnB"),
dictionary: "Gaussian Blur".into(),
dictionary: b"Gaussian Blur".to_vec(),
descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))),
};
let trim = AtnItem {
event: AtnEvent::Name("trim".into()),
dictionary: "Trim".into(),
event: AtnEvent::name("trim"),
dictionary: b"Trim".to_vec(),
descriptor: Some(
Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() }
.with(
@@ -293,7 +318,7 @@ mod tests {
),
..Default::default()
};
let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() };
let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: b"Flatten Image".to_vec(), ..Default::default() };
AtnFile {
name: UnicodeString::new_nul("Set ✓"),
expanded: true,
@@ -324,6 +349,21 @@ mod tests {
assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB");
assert!(parsed.actions[1].items[0].event.is("trim"));
assert_eq!(parsed.actions[1].items[0].event.id(), "trim");
assert_eq!(parsed.actions[0].items[0].label(), "Gaussian Blur");
assert_eq!(parsed.actions[1].items[0].label(), "Trim");
// A localised panel name in the platform's legacy encoding survives a round trip and
// the Unicode class name is the readable label.
let localised = AtnItem {
event: AtnEvent::name("set"),
dictionary: b"D\x8efinir".to_vec(),
descriptor: Some(Descriptor { name: UnicodeString::new_nul("Définir"), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }),
..Default::default()
};
assert_eq!(localised.label(), "Définir");
let f = AtnFile { actions: vec![AtnAction { items: vec![localised.clone()], ..Default::default() }], ..Default::default() };
let bytes = write(&f).unwrap();
assert_eq!(parse(&bytes).unwrap().actions[0].items[0], localised);
assert_eq!(AtnItem { event: AtnEvent::name("x"), ..Default::default() }.label(), "x");
}
/// The layout Photoshop writes for a one-step Trim action, assembled by hand from the
@@ -338,8 +378,8 @@ mod tests {
expanded: true,
items: vec![AtnItem {
expanded: true,
event: AtnEvent::Name("trim".into()),
dictionary: "Trim".into(),
event: AtnEvent::name("trim"),
dictionary: b"Trim".to_vec(),
descriptor: Some(Descriptor {
name: UnicodeString::new_nul("Trim"),
class_id: crate::descriptor::Id::Str(b"trim".to_vec()),
+4 -1
View File
@@ -170,7 +170,10 @@ impl FilterEffects {
let pad = ((4 - len % 4) % 4).min(r.remaining() as u64);
r.skip(pad as usize)?;
let rest = r.peek_rest();
if rest.iter().all(|&b| b == 0) && rest.len() <= 3 {
// Padding is at most three bytes. Check the length before scanning the suffix for
// zeros: otherwise each iteration scans the whole remaining buffer, which is
// quadratic when a block contains many empty sections.
if rest.len() <= 3 && rest.iter().all(|&b| b == 0) {
break;
}
if rest.len() < 8 {
+566
View File
@@ -0,0 +1,566 @@
//! Large-file stress fixtures (#216, #375, #5107), generated at test time and never committed.
//!
//! Two kinds:
//!
//! * [`Streamed`]: multi-gigabyte PSD/PSB files written straight from the Adobe PSD spec, row by
//! row, independent of [`PsdBuilder`] (so they don't inherit its limits, and the generator needs
//! a few megabytes of memory however large the file). One full-canvas layer (or none) plus the
//! merged image; every sample follows [`sample`], so a reader can check any pixel without the
//! file. [`PSD_2GB`] is the largest file Photoshop saves as PSD; [`PSB_CASES`] are 2.5–5 GB.
//! * [`Grid`]: layered documents built with [`PsdBuilder`]: a background and a grid of layers in
//! groups, with masks, a hidden layer, a closed group and drop shadows. [`LAYERED_20MP`] is the
//! 20 MP photo-sized document, [`LAYOUT_150`] the 150-layer A4 layout. [`Grid::expected`] is
//! the merged colour at any point away from the shadows ([`Grid::sample_points`] lists some).
//!
//! Photoshop's limits, which the presets respect: a PSD is at most 30,000 px a side and 2 GB; a
//! PSB at most 300,000 px a side.
use std::io::{self, Write};
use crate::builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder};
use crate::descriptor::{Descriptor, Id, Value, VersionedDescriptor};
use crate::error::Result;
use crate::file::PsdFile;
use crate::header::Version;
use crate::layer::Rect;
use crate::tagged::TaggedBlock;
/// Photoshop's PSD size cap: files of 2 GB or more must be PSB.
pub const PSD_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
/// Photoshop's PSD side cap (px).
pub const PSD_MAX_SIDE: u32 = 30_000;
/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`) in a [`Streamed`] file.
/// RLE files ignore `x` (constant rows compress to almost nothing). 16-bit files store `v * 257`.
pub fn sample(x: u32, y: u32, c: u32, rle: bool) -> u8 {
if c == 3 {
return 255;
}
let x = if rle { 0 } else { x };
(x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13)).wrapping_add(c.wrapping_mul(101)) & 0xff) as u8
}
/// A large RGB file written row by row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Streamed {
/// File stem, e.g. `layer8-25k-raw`.
pub name: &'static str,
/// PSD (v1) or PSB (v2).
pub version: Version,
/// Width (px).
pub width: u32,
/// Height (px).
pub height: u32,
/// 8 or 16.
pub depth: u16,
/// RLE (PackBits) or raw.
pub rle: bool,
/// One full-canvas RGBA layer, or a flattened file (merged image only).
pub layered: bool,
}
const fn psb(name: &'static str, side: u32, depth: u16, rle: bool, layered: bool) -> Streamed {
Streamed { name, version: Version::Psb, width: side, height: side, depth, rle, layered }
}
/// A 2.02 GB layered PSD just under Photoshop's 2 GB cap: 17,000² px, one raw full-canvas layer
/// (289 MB per channel) and the raw merged image.
pub const PSD_2GB: Streamed =
Streamed { name: "psd-layer8-17k-raw", version: Version::Psd, width: 17_000, height: 17_000, depth: 8, rle: false, layered: true };
/// The 4–5 GB PSB generated for #375 and #216.
pub const PSB_CASES: [Streamed; 5] = [
// Composite of 2.52 GB in one decode.
psb("flat8-29k-rle", 29_000, 8, true, false),
// A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB.
psb("flat8-40k-raw", 40_000, 8, false, false),
// 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB.
PSB_4GB,
// Layer channels of 900 MB, composite of 2.7 GB.
psb("layer8-30k-rle", 30_000, 8, true, true),
// Layer channels of 2.18 GB each.
psb("layer16-33k-rle", 33_000, 16, true, true),
];
/// The layered 4.38 GB PSB (25,000² px, raw): layer data and the merged image past 4 GiB.
pub const PSB_4GB: Streamed = psb("layer8-25k-raw", 25_000, 8, false, true);
/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder.
fn rle_row(n: u64, b: u8) -> Vec<u8> {
let mut v = Vec::new();
let mut left = n;
while left > 0 {
let run = left.min(128);
// `run` is 1..=128, so 257 - run is 129..=256 and only 256 (run 1) needs the literal form.
if run == 1 {
v.extend_from_slice(&[0, b]);
} else {
v.extend_from_slice(&[(257 - run) as u8, b]);
}
left -= run;
}
v
}
fn too_big(what: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, format!("{what} does not fit a PSD (v1) length field"))
}
impl Streamed {
/// File name with the right extension.
pub fn file_name(&self) -> String {
format!("{}.{}", self.name, if self.version == Version::Psb { "psb" } else { "psd" })
}
fn psb(&self) -> bool {
self.version == Version::Psb
}
fn row_bytes(&self) -> u64 {
u64::from(self.width) * u64::from(self.depth / 8)
}
/// Bytes of one RLE row (all rows have the same length).
fn rle_len(&self) -> u64 {
rle_row(self.row_bytes(), 0).len() as u64
}
/// Size of a channel block (compression word, RLE counts, rows) of `planes` planes.
fn channel_len(&self, planes: u64) -> u64 {
let rows = planes * u64::from(self.height);
let count = if self.psb() { 4 } else { 2 };
if self.rle { 2 + rows * count + rows * self.rle_len() } else { 2 + rows * self.row_bytes() }
}
/// Writes a length field: 8 bytes in PSB, 4 in PSD.
fn put_len(&self, out: &mut Vec<u8>, v: u64, what: &str) -> io::Result<()> {
if self.psb() {
out.extend_from_slice(&v.to_be_bytes());
} else {
out.extend_from_slice(&u32::try_from(v).map_err(|_| too_big(what))?.to_be_bytes());
}
Ok(())
}
/// The layer record of the one layer: transparency (-1), then R, G, B.
fn layer_record(&self) -> io::Result<Vec<u8>> {
let ch_len = self.channel_len(1);
let name = b"Layer 1";
let mut rec = Vec::new();
for v in [0, 0, self.height, self.width] {
rec.extend_from_slice(&v.to_be_bytes());
}
rec.extend_from_slice(&4u16.to_be_bytes());
for id in [-1i16, 0, 1, 2] {
rec.extend_from_slice(&id.to_be_bytes());
self.put_len(&mut rec, ch_len, "a layer channel")?;
}
rec.extend_from_slice(b"8BIMnorm");
rec.extend_from_slice(&[255, 0, 0, 0]);
// Extra data: no mask, no blending ranges, the Pascal name padded to 4.
let mut extra = Vec::new();
extra.extend_from_slice(&0u32.to_be_bytes());
extra.extend_from_slice(&0u32.to_be_bytes());
extra.push(name.len() as u8);
extra.extend_from_slice(name);
while extra.len() % 4 != 0 {
extra.push(0);
}
rec.extend_from_slice(&(extra.len() as u32).to_be_bytes());
rec.extend_from_slice(&extra);
Ok(rec)
}
/// The header, the empty colour-mode and resource sections and the layer section's head
/// (everything before the first channel's data), plus the padding after the layer channels.
fn head(&self) -> io::Result<(Vec<u8>, u64)> {
let mut h = Vec::new();
h.extend_from_slice(b"8BPS");
h.extend_from_slice(&(if self.psb() { 2u16 } else { 1 }).to_be_bytes());
h.extend_from_slice(&[0; 6]);
h.extend_from_slice(&3u16.to_be_bytes());
h.extend_from_slice(&self.height.to_be_bytes());
h.extend_from_slice(&self.width.to_be_bytes());
h.extend_from_slice(&self.depth.to_be_bytes());
h.extend_from_slice(&3u16.to_be_bytes());
// Colour mode data and image resources: empty.
h.extend_from_slice(&0u32.to_be_bytes());
h.extend_from_slice(&0u32.to_be_bytes());
if !self.layered {
self.put_len(&mut h, 0, "the layer section")?;
return Ok((h, 0));
}
let rec = self.layer_record()?;
let mut layer_info = 2 + rec.len() as u64 + 4 * self.channel_len(1);
let pad = layer_info % 2;
layer_info += pad;
let len_field = if self.psb() { 8 } else { 4 };
// Layer and mask information: layer info (with its length) + empty global mask (4 bytes).
self.put_len(&mut h, len_field + layer_info + 4, "the layer section")?;
self.put_len(&mut h, layer_info, "the layer info")?;
h.extend_from_slice(&1i16.to_be_bytes());
h.extend_from_slice(&rec);
Ok((h, pad))
}
/// The exact size of the file [`write_to`](Self::write_to) writes, without writing it.
pub fn file_len(&self) -> io::Result<u64> {
let (head, pad) = self.head()?;
let layers = if self.layered { 4 * self.channel_len(1) + pad + 4 } else { 0 };
Ok(head.len() as u64 + layers + self.channel_len(3))
}
/// The bytes of one row of channel `ch`.
fn row(&self, y: u32, ch: u32, buf: &mut Vec<u8>) {
buf.clear();
if self.rle {
buf.extend(rle_row(self.row_bytes(), sample(0, y, ch, true)));
return;
}
for x in 0..self.width {
let s = sample(x, y, ch, false);
buf.push(s);
if self.depth == 16 {
buf.push(s);
}
}
}
/// Writes the compression word, the counts and the rows of `chans`.
fn write_planes(&self, w: &mut impl Write, chans: &[u32]) -> io::Result<()> {
w.write_all(&u16::from(self.rle).to_be_bytes())?;
if self.rle {
let n = self.rle_len();
let one = if self.psb() {
u32::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec()
} else {
u16::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec()
};
for _ in 0..chans.len() as u64 * u64::from(self.height) {
w.write_all(&one)?;
}
}
let mut buf = Vec::new();
for &ch in chans {
for y in 0..self.height {
self.row(y, ch, &mut buf);
w.write_all(&buf)?;
}
}
Ok(())
}
/// Streams the file into `w` (buffer it). Returns the bytes written. A PSD whose lengths
/// don't fit its 32-bit fields is refused before anything is written.
pub fn write_to(&self, w: &mut impl Write) -> io::Result<u64> {
let len = self.file_len()?;
let (head, pad) = self.head()?;
w.write_all(&head)?;
if self.layered {
// Transparency, then R, G, B.
for ch in [3, 0, 1, 2] {
self.write_planes(w, &[ch])?;
}
w.write_all(&vec![0; pad as usize])?;
w.write_all(&0u32.to_be_bytes())?;
}
self.write_planes(w, &[0, 1, 2])?;
w.flush()?;
Ok(len)
}
/// Writes the file to `path` (created or truncated). Returns its size.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(&self, path: &std::path::Path) -> io::Result<u64> {
let mut w = io::BufWriter::with_capacity(8 << 20, std::fs::File::create(path)?);
self.write_to(&mut w)
}
/// The 8-bit value of channel `c` (3 = alpha) at (`x`, `y`): the layer's and the merged
/// image's (they are the same).
pub fn sample(&self, x: u32, y: u32, c: u32) -> u8 {
sample(x, y, c, self.rle)
}
}
/// A layered document: a background and a grid of layers, one group per grid row.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Grid {
/// Canvas width (px).
pub width: u32,
/// Canvas height (px).
pub height: u32,
/// Layers in the grid (the background is extra).
pub layers: u32,
/// Grid columns; a group per row of `cols` layers.
pub cols: u32,
}
/// A 20 MP (5472 × 3648) layered photo: background, 5 layers in 2 groups (one closed), a
/// mask and a drop shadow.
pub const LAYERED_20MP: Grid = Grid { width: 5472, height: 3648, layers: 5, cols: 3 };
/// A 150-layer A4 layout at 300 ppi (2480 × 3508): background + 149 layers in 15 groups, with
/// masks, hidden layers, closed groups and drop shadows.
pub const LAYOUT_150: Grid = Grid { width: 2480, height: 3508, layers: 149, cols: 10 };
/// What a grid layer carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridLayer {
/// Bounds in document coordinates.
pub rect: Rect,
/// Base colour; the layer adds its local x to it (a horizontal ramp).
pub base: [u8; 3],
/// Has a mask showing only the left half.
pub masked: bool,
/// Hidden.
pub hidden: bool,
/// Has a drop shadow (`lfx2`).
pub shadow: bool,
}
/// The `lfx2` descriptor of a drop shadow: multiply, black, 75 %, 120°, distance 5, size 5.
fn drop_shadow() -> Vec<u8> {
let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v };
let color = Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0));
let shadow = Descriptor::new("DrSh")
.with("enab", Value::Boolean(true))
.with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") })
.with("Clr ", Value::Descriptor(color))
.with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.0 })
.with("uglg", Value::Boolean(false))
.with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 })
.with("Dstn", px(5.0))
.with("Ckmt", px(0.0))
.with("blur", px(5.0));
let root = Descriptor::new("null")
.with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 })
.with("masterFXSwitch", Value::Boolean(true))
.with("DrSh", Value::Descriptor(shadow));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor::new(root).to_bytes());
data
}
impl Grid {
/// Grid rows (= groups).
pub fn rows(&self) -> u32 {
self.layers.div_ceil(self.cols.max(1))
}
fn cell(&self) -> (u32, u32) {
(self.width / self.cols.max(1), self.height / self.rows().max(1))
}
/// Gap between a layer and its cell's edge (larger than any shadow's reach).
fn inset(&self) -> u32 {
let (cw, ch) = self.cell();
(cw.min(ch) / 8).max(1)
}
/// Layer `i` of the grid (0-based, bottom to top), or `None` past the end.
pub fn layer(&self, i: u32) -> Option<GridLayer> {
if i >= self.layers {
return None;
}
let (cw, ch) = self.cell();
let inset = self.inset();
let (col, row) = (i % self.cols.max(1), i / self.cols.max(1));
let rect =
Rect::from_xywh((col * cw + inset) as i32, (row * ch + inset) as i32, cw.saturating_sub(2 * inset).max(1), ch.saturating_sub(2 * inset).max(1));
let masked = i % 5 == 1;
let hidden = i % 13 == 12;
Some(GridLayer {
rect,
base: [(i * 37 + 20) as u8, (i * 91 + 50) as u8, (i * 53 + 90) as u8],
masked,
hidden,
shadow: i % 7 == 3 && !masked && !hidden,
})
}
/// Name of grid layer `i`.
pub fn layer_name(i: u32) -> String {
format!("Layer {}", i + 1)
}
/// Name of the group of grid row `r`.
pub fn group_name(r: u32) -> String {
format!("Row {}", r + 1)
}
/// Groups of rows `r % 4 == 3` are closed in the layers panel.
pub fn group_open(r: u32) -> bool {
r % 4 != 3
}
/// The background's colour at (`x`, `y`): [`sample`] in runs of 16 px (RLE has runs to find).
pub fn background(x: u32, y: u32) -> [u8; 3] {
[0, 1, 2].map(|c| sample(x / 16, y, c, false))
}
/// Grid layer `i`'s pixel colour at document (`x`, `y`), ignoring visibility and mask.
fn layer_color(l: &GridLayer, x: u32) -> [u8; 3] {
let lx = x.wrapping_sub(l.rect.left as u32);
l.base.map(|b| (u32::from(b) + lx) as u8)
}
/// The merged colour at (`x`, `y`). Exact except within a few pixels of a shadowed layer's
/// edges (the merged image this generator stores leaves shadows out).
pub fn expected(&self, x: u32, y: u32) -> [u8; 3] {
let (cw, ch) = self.cell();
let (col, row) = (x / cw.max(1), y / ch.max(1));
if col < self.cols
&& let Some(l) = self.layer(row * self.cols + col)
{
let (xi, yi) = (x as i32, y as i32);
let inside = xi >= l.rect.left && xi < l.rect.right && yi >= l.rect.top && yi < l.rect.bottom;
let shown = !l.masked || i64::from(xi) < i64::from(l.rect.left) + l.rect.width() / 2;
if inside && shown && !l.hidden {
return Self::layer_color(&l, x);
}
}
Self::background(x, y)
}
/// Points to check, away from every shadow: in each of a few layers, its shown half, its
/// masked-out half, and the gap at its cell's corner.
pub fn sample_points(&self) -> Vec<(u32, u32)> {
let step = (self.layers / 12).max(1);
let mut v = vec![(0, 0), (self.width - 1, self.height - 1)];
for i in (0..self.layers).step_by(step as usize) {
let Some(l) = self.layer(i) else { continue };
let (w, h) = (l.rect.width() as u32, l.rect.height() as u32);
let (x0, y0) = (l.rect.left as u32, l.rect.top as u32);
v.push((x0 + w / 4, y0 + h / 2));
v.push((x0 + w * 3 / 4, y0 + h / 2));
v.push((x0 - self.inset() / 2, y0 - self.inset() / 2));
}
v
}
/// Builds the file (8-bit RGB, RLE): layers bottom to top, the merged image from
/// [`expected`](Self::expected).
pub fn build(&self, version: Version) -> Result<PsdFile> {
let (w, h) = (self.width, self.height);
let mut b = PsdBuilder::new(w, h).version(version);
let mut bg = Vec::with_capacity(w as usize * h as usize * 4);
for y in 0..h {
for x in 0..w {
bg.extend(Self::background(x, y));
bg.push(255);
}
}
b.push_layer(LayerSpec::new("Background", 0, 0, w, h, PixelData::Rgba8(bg)));
for row in 0..self.rows() {
b.begin_group(GroupSpec { open: Self::group_open(row), ..GroupSpec::new(Self::group_name(row)) });
for i in row * self.cols..((row + 1) * self.cols).min(self.layers) {
let Some(l) = self.layer(i) else { continue };
let (lw, lh) = (l.rect.width() as u32, l.rect.height() as u32);
let mut px = Vec::with_capacity(lw as usize * lh as usize * 4);
for _ in 0..lh {
for lx in 0..lw {
px.extend(Self::layer_color(&l, l.rect.left as u32 + lx));
px.push(255);
}
}
let mut spec = LayerSpec::new(Self::layer_name(i), l.rect.left, l.rect.top, lw, lh, PixelData::Rgba8(px));
spec.visible = !l.hidden;
if l.masked {
let data = (0..lh).flat_map(|_| (0..lw).map(move |x| if x < lw / 2 { 255 } else { 0 })).collect();
spec.mask = Some(MaskSpec { rect: l.rect, data, default_color: 0, disabled: false });
}
if l.shadow {
spec.extra_blocks.push(TaggedBlock::new(*b"lfx2", drop_shadow()));
}
b.push_layer(spec);
}
b.end_group()?;
}
let mut merged = Vec::with_capacity(w as usize * h as usize * 4);
for y in 0..h {
for x in 0..w {
merged.extend(self.expected(x, y));
merged.push(255);
}
}
b.composite(PixelData::Rgba8(merged));
b.build()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn presets_respect_photoshop_limits() {
let psd = PSD_2GB.file_len().unwrap();
assert!(psd < PSD_MAX_FILE_BYTES && psd > 2_000_000_000, "{psd}");
const { assert!(PSD_2GB.width <= PSD_MAX_SIDE && PSD_2GB.height <= PSD_MAX_SIDE) };
for c in PSB_CASES {
assert!(c.file_len().is_ok() && c.width <= 300_000, "{}", c.name);
}
assert!(PSB_4GB.file_len().unwrap() > 4 << 30);
// A PSD too big for its 32-bit lengths is refused, not wrapped.
let huge = Streamed { width: 33_000, height: 33_000, ..PSD_2GB };
assert!(huge.file_len().is_err());
assert!(huge.write_to(&mut io::sink()).is_err());
}
/// Small streamed files parse, and their pixels are [`sample`]'s.
#[test]
fn small_streamed_files_parse() {
for version in [Version::Psd, Version::Psb] {
for (rle, layered, depth) in [(false, true, 8), (true, true, 8), (true, false, 16), (false, false, 8)] {
let c = Streamed { name: "t", version, width: 300, height: 7, depth, rle, layered };
let mut out = Vec::new();
let n = c.write_to(&mut out).unwrap();
assert_eq!(n, out.len() as u64, "{c:?}");
let file = PsdFile::from_bytes(&out).unwrap();
let merged = file.image_data.decode(&file.header).unwrap();
let bps = usize::from(depth / 8);
let plane = 300 * 7 * bps;
for (x, y, ch) in [(0u32, 0u32, 0u32), (299, 6, 2), (150, 3, 1)] {
let i = ch as usize * plane + (y as usize * 300 + x as usize) * bps;
assert_eq!(merged[i], c.sample(x, y, ch), "{c:?} ({x}, {y}) {ch}");
}
assert_eq!(file.layers().len(), usize::from(layered), "{c:?}");
}
}
}
#[test]
fn grid_layout_is_consistent() {
for g in [LAYERED_20MP, LAYOUT_150] {
let last = g.layer(g.layers - 1).unwrap();
assert!(last.rect.right <= g.width as i32 && last.rect.bottom <= g.height as i32);
assert!(g.layer(g.layers).is_none());
let all: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect();
assert!(all.iter().any(|l| l.masked) && all.iter().any(|l| l.shadow));
for (x, y) in g.sample_points() {
assert!(x < g.width && y < g.height);
}
}
assert!((0..LAYOUT_150.layers).filter_map(|i| LAYOUT_150.layer(i)).any(|l| l.hidden));
}
/// A small grid builds, parses, and its merged image is [`Grid::expected`].
#[test]
fn small_grid_builds() {
let g = Grid { width: 120, height: 90, layers: 14, cols: 4 };
let file = g.build(Version::Psd).unwrap();
let bytes = file.to_bytes().unwrap();
let file = PsdFile::from_bytes(&bytes).unwrap();
// Background + 14 layers + 4 groups (two records each).
assert_eq!(file.layers().len(), 1 + 14 + 8);
let merged = file.image_data.decode(&file.header).unwrap();
for (x, y) in g.sample_points() {
let want = g.expected(x, y);
for c in 0..3 {
assert_eq!(merged[c * 120 * 90 + (y * 120 + x) as usize], want[c], "({x}, {y}) {c}");
}
}
}
}
+3
View File
@@ -42,6 +42,7 @@
pub mod abr;
pub mod aco;
pub mod act;
pub mod ase;
pub mod atn;
pub mod blend;
@@ -51,6 +52,8 @@ pub mod descriptor;
pub mod error;
pub mod file;
pub mod filter_effects;
#[cfg(any(test, feature = "testgen"))]
pub mod fixtures;
pub mod grd;
pub mod hdr;
pub mod header;
+158 -1
View File
@@ -1,11 +1,12 @@
//! `shmd` (metadata setting) layer block: a list of keyed sub-blocks, such as `cmls` (layer comp
//! state, a versioned descriptor), `cust` and `mlst`.
//! state, a versioned descriptor), `cust` (the layer's modify time and XMP packet) and `mlst`.
//!
//! Layout (Adobe PSD spec, "Metadata setting"): u32 count, then per item the signature `8BIM`,
//! a 4-byte key, a copy-on-sheet-duplication byte, 3 padding bytes, a u32 length and the data.
//! Photoshop pads item data to an even length (the padding is part of the stored length), so
//! parsing and writing are exact: `write(parse(b)) == b`.
use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt};
@@ -71,10 +72,166 @@ pub fn write_shmd(items: &[MetadataItem]) -> Vec<u8> {
out
}
/// The `shmd` item that holds a layer's own metadata (its modify time and XMP).
const CUST: [u8; 4] = *b"cust";
/// Key of a layer's XMP packet in the `cust` descriptor.
const LAYER_XMP: &str = "layerXMP";
// A layer's XMP packet (Photoshop's `layer.xmpMetadata.rawData`), as observed in files Photoshop
// 2026 saves: a `TEXT` item `layerXMP` (UTF-16 with a terminating NUL) in the `metadata`
// descriptor (version 16, class name a lone NUL) of the `shmd` item `cust`, after the
// `layerTime` double that every Photoshop layer has. A layer without XMP has no `layerXMP` item.
fn pad_even(mut v: Vec<u8>) -> Vec<u8> {
if v.len() % 2 == 1 {
v.push(0);
}
v
}
/// The `cust` descriptor of `item`, or `None` when it isn't one.
fn cust_descriptor(item: &MetadataItem) -> Option<VersionedDescriptor> {
(item.key == CUST).then(|| VersionedDescriptor::parse_prefix(&item.data).ok().map(|(d, _)| d)).flatten()
}
/// The XMP packet stored on a layer whose `shmd` block data is `shmd`, or `None` when it has
/// none (or the block can't be read).
pub fn layer_xmp(shmd: &[u8]) -> Option<String> {
let items = parse_shmd(shmd).ok()?;
items.iter().filter_map(cust_descriptor).find_map(|d| match d.descriptor.get(LAYER_XMP) {
Some(Value::Text(t)) => Some(t.to_string_lossy()),
_ => None,
})
}
/// Splits a layer's XMP packet out of its `shmd` block data: the packet, and the block without
/// it (`None` when nothing is left). `None` when the block holds no packet, so the caller keeps
/// the original bytes. A `cust` item left empty is dropped, the other items are kept as stored.
pub fn take_layer_xmp(shmd: &[u8]) -> Option<(String, Option<Vec<u8>>)> {
let mut items = parse_shmd(shmd).ok()?;
let (i, mut vd) = items.iter().enumerate().find_map(|(i, it)| cust_descriptor(it).filter(|d| d.descriptor.get(LAYER_XMP).is_some()).map(|d| (i, d)))?;
let at = vd.descriptor.items.iter().position(|(k, _)| k.is(LAYER_XMP))?;
let xmp = match vd.descriptor.items.remove(at).1 {
Value::Text(t) => t.to_string_lossy(),
_ => return None,
};
if vd.descriptor.items.is_empty() {
items.remove(i);
} else {
items[i].data = pad_even(vd.to_bytes());
}
Some((xmp, (!items.is_empty()).then(|| write_shmd(&items))))
}
/// `shmd` block data (`shmd`, or a new block when `None`) with `xmp` as the layer's XMP packet,
/// written as Photoshop writes it: it replaces an existing packet in place, or is appended to
/// the `cust` descriptor (a new `cust` item when there is none). An unreadable block is an
/// error, so the caller never replaces data it couldn't read.
pub fn put_layer_xmp(shmd: Option<&[u8]>, xmp: &str) -> Result<Vec<u8>> {
let mut items = match shmd {
Some(b) => parse_shmd(b)?,
None => Vec::new(),
};
let value = Value::Text(UnicodeString::new_nul(xmp));
match items.iter().position(|it| it.key == CUST) {
Some(i) => {
let (mut vd, _) = VersionedDescriptor::parse_prefix(&items[i].data)?;
match vd.descriptor.items.iter_mut().find(|(k, _)| k.is(LAYER_XMP)) {
Some(entry) => entry.1 = value,
None => vd.descriptor.items.push((Id::new(LAYER_XMP), value)),
}
items[i].data = pad_even(vd.to_bytes());
}
None => {
let d = Descriptor { name: UnicodeString::new_nul(""), class_id: Id::new("metadata"), items: vec![(Id::new(LAYER_XMP), value)] };
items.push(MetadataItem::new(CUST, VersionedDescriptor::new(d).to_bytes()));
}
}
Ok(write_shmd(&items))
}
#[cfg(test)]
mod tests {
use super::*;
fn unhex(lines: &[&str]) -> Vec<u8> {
let h: String = lines.concat();
(0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect()
}
/// `shmd` of a layer saved by Photoshop 2026 after a script set its XMP to [`PS_PACKET`].
const PS_WITH_XMP: &[&str] = &[
"000000013842494d63757374000000000000024800000010000000010000000000086d65746164617461000000020000",
"00096c6179657254696d65646f756241dab643ffd54363000000086c61796572584d505445585400000100003c007800",
"3a0078006d0070006d00650074006100200078006d006c006e0073003a0078003d002200610064006f00620065003a00",
"6e0073003a006d006500740061002f0022003e003c007200640066003a00520044004600200078006d006c006e007300",
"3a007200640066003d00220068007400740070003a002f002f007700770077002e00770033002e006f00720067002f00",
"31003900390039002f00300032002f00320032002d007200640066002d00730079006e007400610078002d006e007300",
"230022003e003c007200640066003a004400650073006300720069007000740069006f006e0020007200640066003a00",
"610062006f00750074003d0022002200200078006d006c006e0073003a00730061006d0070006c0065003d0022006800",
"7400740070003a002f002f006500780061006d0070006c0065002e0063006f006d002f00730061006d0070006c006500",
"2f0031002e0030002f0022003e003c00730061006d0070006c0065003a00760061006c00750065003e0061006c007000",
"680061002d003100320033003c002f00730061006d0070006c0065003a00760061006c00750065003e003c002f007200",
"640066003a004400650073006300720069007000740069006f006e003e003c002f007200640066003a00520044004600",
"3e003c002f0078003a0078006d0070006d006500740061003e000000",
];
/// `shmd` of a layer without XMP in the same file (its own `layerTime` only).
const PS_WITHOUT_XMP: &[&str] =
&["000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"];
const PS_PACKET: &str = concat!(
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">"#,
r#"<rdf:Description rdf:about="" xmlns:sample="http://example.com/sample/1.0/"><sample:value>alpha-123</sample:value>"#,
r#"</rdf:Description></rdf:RDF></x:xmpmeta>"#
);
#[test]
fn reads_photoshop_layer_xmp() {
assert_eq!(layer_xmp(&unhex(PS_WITH_XMP)).as_deref(), Some(PS_PACKET));
assert_eq!(layer_xmp(&unhex(PS_WITHOUT_XMP)), None);
assert!(take_layer_xmp(&unhex(PS_WITHOUT_XMP)).is_none());
}
#[test]
fn take_then_put_is_exact() {
let ps = unhex(PS_WITH_XMP);
let (xmp, rest) = take_layer_xmp(&ps).unwrap();
assert_eq!(xmp, PS_PACKET);
let rest = rest.unwrap();
// What's left is a layer without XMP, shaped as Photoshop writes one.
assert_eq!(rest.len(), unhex(PS_WITHOUT_XMP).len());
assert_eq!(layer_xmp(&rest), None);
assert_eq!(put_layer_xmp(Some(&rest), &xmp).unwrap(), ps);
}
#[test]
fn put_replaces_in_place_and_creates_a_block() {
let ps = unhex(PS_WITH_XMP);
let edited = put_layer_xmp(Some(&ps), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").unwrap();
assert_eq!(layer_xmp(&edited).as_deref(), Some("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>"));
// `layerTime` is kept, still first.
let items = parse_shmd(&edited).unwrap();
let d = cust_descriptor(&items[0]).unwrap().descriptor;
assert!(d.items[0].0.is("layerTime") && d.items[1].0.is(LAYER_XMP) && d.items.len() == 2);
let fresh = put_layer_xmp(None, PS_PACKET).unwrap();
assert_eq!(layer_xmp(&fresh).as_deref(), Some(PS_PACKET));
assert_eq!(take_layer_xmp(&fresh), Some((PS_PACKET.to_string(), None)));
// Other items (here a layer comp state) stay as they were.
let comps = write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2])]);
let both = put_layer_xmp(Some(&comps), PS_PACKET).unwrap();
assert_eq!(parse_shmd(&both).unwrap()[0], parse_shmd(&comps).unwrap()[0]);
assert_eq!(take_layer_xmp(&both).unwrap().1.unwrap(), comps);
}
#[test]
fn put_refuses_an_unreadable_block() {
assert!(put_layer_xmp(Some(&[0, 0, 0, 9]), PS_PACKET).is_err());
let bad_cust = write_shmd(&[MetadataItem::new(CUST, vec![0, 0, 0, 16, 1])]);
assert!(put_layer_xmp(Some(&bad_cust), PS_PACKET).is_err());
assert_eq!(layer_xmp(&bad_cust), None);
assert!(take_layer_xmp(&bad_cust).is_none());
}
#[test]
fn roundtrip_exact() {
let items = vec![MetadataItem::new(*b"cmls", vec![1, 2, 3]), MetadataItem { copy: true, ..MetadataItem::new(*b"cust", vec![9; 4]) }];
+23 -6
View File
@@ -56,6 +56,12 @@ impl PatternBudget {
PatternBudget { decoded_left: d.decoded_left.min(decoded), padded_left: d.padded_left.min(decoded) }
}
/// Cap both decoded and padded bytes to the amount still available to a containing file.
pub fn cap_decoded_to(&mut self, decoded: u64) {
self.decoded_left = self.decoded_left.min(decoded);
self.padded_left = self.padded_left.min(decoded);
}
/// Decoded bytes still available.
pub fn decoded_left(&self) -> u64 {
self.decoded_left
@@ -425,9 +431,8 @@ pub fn parse_pattern_block(data: &[u8]) -> Result<Vec<PsdPattern>> {
}
/// [`parse_pattern_block`] charging `budget`, so several blocks of one document share one limit.
/// The budget is charged only for patterns that are returned.
/// Charges are monotonic even when a later record in the block fails.
pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Result<Vec<PsdPattern>> {
let mut left = budget.clone();
let mut r = Rd { b: data, p: 0 };
let mut out = Vec::new();
while r.p + 4 <= data.len() {
@@ -436,11 +441,10 @@ pub fn parse_pattern_block_with(data: &[u8], budget: &mut PatternBudget) -> Resu
break;
}
let body = r.take(len)?;
out.push(read_pattern(&mut Rd { b: body, p: 0 }, &mut left)?);
out.push(read_pattern(&mut Rd { b: body, p: 0 }, budget)?);
let pad = (4 - len % 4) % 4;
r.p = (r.p + pad).min(data.len());
}
*budget = left;
Ok(out)
}
@@ -729,6 +733,18 @@ mod tests {
block
}
#[test]
fn failed_pattern_block_keeps_partial_charge_for_the_next_block() {
let one = padded_gray_block(100);
let mut malformed = one.clone();
malformed.extend_from_slice(&100u32.to_be_bytes());
let mut budget = PatternBudget::with_decoded_limit(15_000);
assert!(parse_pattern_block_with(&malformed, &mut budget).is_err());
assert_eq!(budget.decoded_left(), 5_000);
assert!(parse_pattern_block_with(&one, &mut budget).is_err());
assert_eq!(budget.decoded_left(), 5_000);
}
#[test]
fn padding_budget_is_shared_by_every_pattern_of_a_parse() {
// Each pattern pads 100x100 = 10000 bytes; a 15000-byte budget fits one, not two. Before
@@ -741,8 +757,9 @@ mod tests {
let two = [one.clone(), one.clone()].concat();
let mut budget = PatternBudget::with_decoded_limit(15_000);
assert!(matches!(parse_pattern_block_with(&two, &mut budget), Err(PsdError::LimitExceeded(_))));
// A failed parse keeps nothing, so it charges nothing.
assert_eq!(budget.decoded_left(), 15_000);
// Charges are monotonic: the first pattern's 10000 bytes stay charged even when the second
// record fails.
assert_eq!(budget.decoded_left(), 5_000);
// Two blocks of one document share a budget too.
let mut budget = PatternBudget::with_decoded_limit(15_000);
+37 -2
View File
@@ -10,6 +10,8 @@
//! 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};
@@ -274,13 +276,22 @@ impl SlicesResource {
&& 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(s) = slices.iter_mut().find(|s| s.id == id) {
s.outsets = extra.outsets;
if let Some(&i) = id_to_index.get(&id) {
slices[i].outsets = extra.outsets;
}
}
}
@@ -493,4 +504,28 @@ mod tests {
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");
}
}
+123 -10
View File
@@ -859,15 +859,7 @@ pub mod transcode {
// Type: version, transform, text version + descriptor, warp version + descriptor,
// and the bounds as four 32-bit values (the specification says doubles; Photoshop
// writes 16 bytes).
b"TySh" => {
self.u16()?;
self.f64s(6)?;
self.u16()?;
self.versioned_descriptor()?;
self.u16()?;
self.versioned_descriptor()?;
self.i32s(4)?;
}
b"TySh" => self.type_layer()?,
b"vmsk" | b"vsms" => {
self.u32()?;
self.u32()?;
@@ -1165,6 +1157,31 @@ pub mod transcode {
// ---- descriptors ----
/// Photoshop's Intel-order TIFF type blocks can use PSD byte order for the
/// text descriptor, then container order again for warp and bounds (#4758).
fn type_layer(&mut self) -> Result<()> {
self.u16()?;
self.f64s(6)?;
self.type_descriptor(50)?;
self.type_descriptor(1)?;
self.i32s(4)
}
fn type_descriptor(&mut self, version: u16) -> Result<()> {
let [hi, lo] = version.to_be_bytes();
let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from };
let data = self.r.peek_rest();
let mut descriptor = Tx::new(data, self.version, from, self.to);
descriptor.depth = self.depth;
descriptor.u16()?;
descriptor.versioned_descriptor()?;
let used = data.len() - descriptor.remaining();
self.r.bytes(used)?;
self.warnings.append(&mut descriptor.warnings);
self.out.extend_from_slice(&descriptor.out);
Ok(())
}
fn versioned_descriptor(&mut self) -> Result<()> {
self.u32()?;
self.descriptor()
@@ -1284,6 +1301,7 @@ pub mod transcode {
self.i32()?;
}
b"Idnt" | b"indx" => {
self.class()?;
self.u32()?;
}
b"name" => {
@@ -1410,9 +1428,27 @@ pub mod transcode {
})
}
/// `FEid` / `FXid`: version, u64 length, items with per-channel planes (4-byte RLE counts).
/// `FEid` / `FXid`: version, then one section per smart object (u64 length, items with
/// per-channel planes and 4-byte RLE counts), each padded to 4 bytes.
fn filter_effects(&mut self) -> Result<()> {
self.u32()?;
loop {
let len = self.r.clone().len_field(true)?;
self.filter_effects_section()?;
// Mirrors `FilterEffects::parse`: the last section's padding may be cut short.
let pad = usize::try_from((4 - len % 4) % 4).unwrap_or(0).min(self.remaining());
self.raw(pad)?;
let rest = self.r.peek_rest();
if rest.len() <= 3 && rest.iter().all(|&b| b == 0) {
return Ok(());
}
if rest.len() < 8 {
return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len())));
}
}
}
fn filter_effects_section(&mut self) -> Result<()> {
self.sized_region(true, |t| {
while !t.r.is_empty() {
t.pascal(1)?;
@@ -1789,6 +1825,17 @@ mod tests {
assert!(little.windows(11).any(|w| w == b"longKeyName"), "string ids are not reversed");
}
#[test]
fn descriptor_references_by_identifier_and_index_keep_their_class() {
use crate::descriptor::{Class, ReferenceItem};
let class = || Class { name: UnicodeString::new(""), class_id: Id::new("Lyr ") };
let desc = Descriptor::new("null")
.with("null", Value::Reference(vec![ReferenceItem::Identifier { class: class(), id: 7 }, ReferenceItem::Index { class: class(), index: 2 }]));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor::new(desc).to_bytes());
block_rt(*b"lfx2", data);
}
#[test]
fn type_block_layout() {
let mut data = Vec::new();
@@ -1808,6 +1855,39 @@ mod tests {
assert_eq!(&little[50..58], &1.0f64.to_le_bytes());
}
#[test]
fn photoshop_type_block_switches_to_big_endian_after_its_matrix() {
let mut data = Vec::new();
data.put_u16(1);
for v in [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 247.25, 2864.5] {
data.put_f64(v);
}
data.put_u16(50);
let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic headline")));
data.extend(VersionedDescriptor::new(text).to_bytes());
data.put_u16(1);
data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes());
for v in [0, 0, 120, 30] {
data.put_i32(v);
}
let (_, mut little) = block_rt(*b"TySh", data.clone());
// Photoshop uses big-endian text but returns to little-endian for warp/bounds.
let (_, used) = VersionedDescriptor::parse_prefix(&data[52..]).unwrap();
little[98..100 + used].copy_from_slice(&data[50..52 + used]);
let (back, warnings) = ImageSourceData::from_bytes(&little).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(!back.global_blocks[0].is_foreign_order());
assert_eq!(back.global_blocks[0].data, data);
// The normalized model remains writable in either order.
for order in [ByteOrder::Big, ByteOrder::Little] {
let (bytes, warnings) = back.to_bytes(order).unwrap();
assert!(warnings.is_empty());
let (again, warnings) = ImageSourceData::from_bytes(&bytes).unwrap();
assert!(warnings.is_empty());
assert_eq!(again.global_blocks[0].data, data);
}
}
#[test]
fn legacy_effects_and_adjustments() {
// lrFX with a drop shadow and a solid fill.
@@ -2052,6 +2132,39 @@ mod tests {
assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx);
}
/// Photoshop writes one `FEid` section per smart object: every section's lengths and
/// numbers are swapped, not only the first one's.
#[test]
fn filter_effects_block_swaps_every_section() {
use crate::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
let rect = crate::layer::Rect { top: 0, left: 0, bottom: 2, right: 3 };
let item = |id: &str, v: u8| FilterEffectsItem {
id: id.into(),
version: 1,
rect,
depth: 8,
max_channels: 1,
slots: vec![Some(EffectsPlane::encode(Compression::Raw, &[v; 6], 3, 2, 8).unwrap()), None, None],
mask: None,
};
let fx = FilterEffects { version: 1, items: vec![item("first", 1), item("second-object", 2)] };
let data = fx.to_bytes();
let len1 = u64::from_be_bytes(data[4..12].try_into().unwrap()) as usize;
let second = 12 + len1 + (4 - len1 % 4) % 4;
let len2 = &data[second..second + 8];
block_rt(*b"FEid", data.clone());
let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"FEid", data.clone())], ..Default::default() };
let (little, _) = d.to_bytes(ByteOrder::Little).unwrap();
// 36-byte signature, then `MIB8`, the reversed key and a u32 length: the block data.
let body = &little[36 + 12..];
let mut swapped = len2.to_vec();
swapped.reverse();
assert_eq!(&body[second..second + 8], &swapped[..], "second section's length is little-endian");
let (back, w) = ImageSourceData::from_bytes(&little).unwrap();
assert!(w.is_empty(), "{w:?}");
assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx);
}
#[test]
fn metadata_setting_items() {
let desc = VersionedDescriptor::new(Descriptor::new("null")).to_bytes();
+60
View File
@@ -0,0 +1,60 @@
//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` under
//! `corpus/atn/` (gitignored, fetched by `cargo xtask corpus --atn`) parses, and writing it back
//! reproduces the bytes exactly. A missing corpus fails.
#![cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
use photocraft_psd::atn;
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
// An unreadable folder fails the test rather than shrinking the corpus.
let rd = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
for e in rd {
let p = e.unwrap_or_else(|e| panic!("{}: {e}", dir.display())).path();
if p.is_dir() {
collect(&p, out);
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("atn")) {
out.push(p);
}
}
}
/// Sets in the corpus; raise, never lower.
const FLOOR: usize = 16;
#[test]
fn atn_corpus_parses_and_is_byte_stable() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/atn");
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --atn`", root.display());
let mut files = Vec::new();
collect(&root, &mut files);
files.sort();
let mut failures = Vec::new();
let (mut actions, mut steps) = (0usize, 0usize);
for p in &files {
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
let bytes = std::fs::read(p).unwrap();
let file = match atn::parse(&bytes) {
Ok(f) => f,
Err(e) => {
failures.push(format!("{name}: {e}"));
continue;
}
};
if !file.warnings.is_empty() {
failures.push(format!("{name}: warnings {:?}", file.warnings));
}
actions += file.actions.len();
steps += file.actions.iter().map(|a| a.items.len()).sum::<usize>();
match atn::write(&file) {
Ok(written) if written == bytes => {}
Ok(written) => failures.push(format!("{name}: rewritten bytes differ ({} vs {} bytes)", written.len(), bytes.len())),
Err(e) => failures.push(format!("{name}: write failed: {e}")),
}
println!("{name}: {} actions, {} steps", file.actions.len(), file.actions.iter().map(|a| a.items.len()).sum::<usize>());
}
println!("{} files, {actions} actions, {steps} steps", files.len());
assert!(files.len() >= FLOOR, "only {} .atn files; the corpus floor is {FLOOR}", files.len());
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
+12
View File
@@ -0,0 +1,12 @@
//! The crate's integration tests, linked into one test binary.
mod builder;
mod corpus;
mod corpus_atn;
mod layer_block_order;
mod malformed;
mod merged_recovery;
mod proptests;
mod roundtrip;
mod swatch_malformed;
mod tree;