mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge PR 1 (codecs, now on current main) into PR 3
Brings split/tiff-extra-samples-codecs ate6fca054, which merged main1695f323. 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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]) }];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user