mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
This commit is contained in:
1 parent
f114f62179
commit
15eb3a8ba5
9 files changed
+370
-10
No files matched your search
@@ -70,7 +70,7 @@
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🗂️ Real PSD files</h3>
|
||||
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
|
||||
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
@@ -213,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
|
||||
@@ -195,6 +195,14 @@ impl Ex {
|
||||
blocks.push(TaggedBlock::new(key, d));
|
||||
}
|
||||
blocks.extend(extra);
|
||||
// Layer-level blocks keep their pad byte inside the length, as Photoshop writes them:
|
||||
// readers such as psd-tools do not skip a pad after an odd length, so a regenerated
|
||||
// odd-length `lfx2` misaligned every block after it (#200).
|
||||
for b in &mut blocks {
|
||||
if b.data.len() % 2 == 1 && b.padding.is_none() {
|
||||
b.data.push(0);
|
||||
}
|
||||
}
|
||||
LayerRecord {
|
||||
rect,
|
||||
channels,
|
||||
@@ -222,10 +230,13 @@ impl Ex {
|
||||
raw.retain(|(k, _)| k != b"lmfx");
|
||||
}
|
||||
let mut regenerated: Vec<([u8; 4], Vec<u8>)> = Vec::new();
|
||||
// `keys` is in the importer's priority order (`psd_raw` came from the first key present),
|
||||
// not file order: Photoshop writes `PlLd` before `SoLd`, and overwriting the first match
|
||||
// in file order put `soLD` data under the `PlLd` key (#200).
|
||||
let set_principal = |raw: &mut Vec<([u8; 4], Vec<u8>)>, keys: &[&[u8; 4]], data: Option<&std::sync::Arc<Vec<u8>>>| {
|
||||
let Some(d) = data else { return };
|
||||
match raw.iter_mut().find(|(k, _)| keys.contains(&k)) {
|
||||
Some(e) => e.1 = d.to_vec(),
|
||||
match keys.iter().find_map(|k| raw.iter().position(|(rk, _)| rk == *k)) {
|
||||
Some(i) => raw[i].1 = d.to_vec(),
|
||||
None => raw.push((*keys[0], d.to_vec())),
|
||||
}
|
||||
};
|
||||
@@ -276,7 +287,7 @@ impl Ex {
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(sm) => {
|
||||
set_principal(&mut raw, &[b"SoLd", b"PlLd", b"SoLE"], sm.psd_raw.as_ref());
|
||||
set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref());
|
||||
if !raw.iter().any(|(k, _)| matches!(k, b"SoLd" | b"PlLd" | b"SoLE")) {
|
||||
self.warnings.push(format!("layer \"{}\": smart object written as pixels", l.name));
|
||||
}
|
||||
@@ -439,6 +450,18 @@ fn link_group_resource(layers: &[Layer]) -> Option<Vec<u8>> {
|
||||
|
||||
/// Whether the layer's effects still equal what its preserved `lmfx`/`lfx2`
|
||||
/// decodes to. `None` when there is nothing preserved or it can't be decoded.
|
||||
/// Block keys a smart object's `psd_raw` may be written under, in priority order. The data says
|
||||
/// which it is: `PlLd` holds a `plcL` structure, `SoLd`/`SoLE` a `soLD` one (Adobe PSD spec,
|
||||
/// "Placed Layer" and "Placed Layer Data"). Writing one under the other's key makes the block
|
||||
/// unreadable.
|
||||
fn smart_keys(data: Option<&[u8]>) -> &'static [&'static [u8; 4]] {
|
||||
match data.and_then(|d| d.get(..4)) {
|
||||
Some(b"plcL") => &[b"PlLd"],
|
||||
Some(b"soLD") => &[b"SoLd", b"SoLE"],
|
||||
_ => &[b"SoLd", b"PlLd", b"SoLE"],
|
||||
}
|
||||
}
|
||||
|
||||
fn effects_unchanged(l: &Layer) -> Option<bool> {
|
||||
let src = l.psd_blocks.iter().find(|(k, _)| k == b"lmfx").map(|(_, d)| d.clone()).or_else(|| l.effects.psd_raw.clone())?;
|
||||
let (m, items) = crate::effects_map::parse_lfx2(&src)?;
|
||||
@@ -726,6 +749,10 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
||||
for (sig, key, data) in &crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)) {
|
||||
let mut tb = TaggedBlock::new(*key, data.to_vec());
|
||||
tb.signature = *sig;
|
||||
// Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as
|
||||
// psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes)
|
||||
// misaligned every block after it (#200).
|
||||
tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]);
|
||||
global_blocks.push(tb);
|
||||
}
|
||||
let comps_resource = ex.comps.is_some().then(|| crate::comps_map::write_comps_resource(doc));
|
||||
@@ -773,11 +800,19 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
||||
_ => LayerInfoPlacement::Section,
|
||||
};
|
||||
let records = std::mem::take(&mut ex.records);
|
||||
let mut layer_info = has_layers.then_some(LayerInfo { merged_alpha: has_alpha, layers: records, padding: None });
|
||||
if let Some(info) = &mut layer_info {
|
||||
// Photoshop pads the layer info to 4 bytes (an even pad misaligns readers after a global
|
||||
// `Lr16`/`Lr32` block, #200). A length that cannot be computed keeps the even default.
|
||||
if info.pad_to(version, 4).is_err() {
|
||||
info.padding = None;
|
||||
}
|
||||
}
|
||||
let file = PsdFile {
|
||||
header,
|
||||
color_mode_data: Vec::new(),
|
||||
resources,
|
||||
layer_info: has_layers.then_some(LayerInfo { merged_alpha: has_alpha, layers: records, padding: None }),
|
||||
layer_info,
|
||||
layer_info_placement: placement,
|
||||
global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default),
|
||||
global_blocks,
|
||||
|
||||
@@ -327,3 +327,53 @@ fn tone_space(mode: ColorMode) -> photocraft_doc::adjust::ToneSpace {
|
||||
_ => photocraft_doc::adjust::ToneSpace::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict structural check of a written PSD/PSB (#200): every tagged block must
|
||||
/// re-parse with its section sizes matching, laid out as Photoshop writes it so
|
||||
/// that readers which step from block to block by length (psd-tools) stay
|
||||
/// aligned. Returns one message per problem; empty means clean.
|
||||
///
|
||||
/// - the file parses, with no unparsed bytes left in the layer and mask section;
|
||||
/// - the layer info body is padded to a multiple of 4;
|
||||
/// - global (document-level) blocks are padded to a multiple of 4;
|
||||
/// - layer blocks have an even length with no pad byte outside it;
|
||||
/// - blocks with inner lengths (`PlLd`, `SoLd`, `SoLE`, `lnk2`/`lnk3`/`lnkD`, `lfx2`)
|
||||
/// re-parse exactly (`TaggedBlock::check_structure`).
|
||||
pub fn strict_block_errors(bytes: &[u8]) -> Vec<String> {
|
||||
let file = match photocraft_psd::PsdFile::from_bytes(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return vec![format!("parse: {e}")],
|
||||
};
|
||||
let v = file.header.version;
|
||||
let mut errs = Vec::new();
|
||||
if !file.layer_mask_trailing.is_empty() {
|
||||
errs.push(format!("{} unparsed bytes at the end of the layer and mask section", file.layer_mask_trailing.len()));
|
||||
}
|
||||
if let Some(li) = &file.layer_info {
|
||||
let body = li.unpadded_len(v).unwrap_or(1);
|
||||
let pad = li.padding.as_ref().map_or(body % 2, |p| p.len() as u64);
|
||||
if (body + pad) % 4 != 0 {
|
||||
errs.push(format!("layer info: {body} bytes + {pad} padding is not a multiple of 4"));
|
||||
}
|
||||
}
|
||||
for b in &file.global_blocks {
|
||||
let pad = b.padding.as_ref().map_or(b.data.len() % 2, Vec::len);
|
||||
if (b.data.len() + pad) % 4 != 0 {
|
||||
errs.push(format!("global {}: {} data bytes + {pad} padding is not a multiple of 4", b.key_str(), b.data.len()));
|
||||
}
|
||||
if let Err(e) = b.check_structure() {
|
||||
errs.push(format!("global {}: {e}", b.key_str()));
|
||||
}
|
||||
}
|
||||
for (i, l) in file.layers().iter().enumerate() {
|
||||
for b in &l.blocks {
|
||||
if b.data.len() % 2 != 0 || b.padding.as_ref().is_some_and(|p| !p.is_empty()) {
|
||||
errs.push(format!("layer {i} {}: {} data bytes with padding outside the length", b.key_str(), b.data.len()));
|
||||
}
|
||||
if let Err(e) = b.check_structure() {
|
||||
errs.push(format!("layer {i} {}: {e}", b.key_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
errs
|
||||
}
|
||||
@@ -36,6 +36,9 @@
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to also fail on import/export errors
|
||||
//! (files listed in `KNOWN_BAD` excepted).
|
||||
//!
|
||||
//! Every exported file must also pass `common::strict_block_errors` (#200): each tagged block
|
||||
//! re-parses strictly, with the padding Photoshop writes, so other readers (psd-tools) stay aligned.
|
||||
//!
|
||||
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
|
||||
//! asserts import + flatten return (Ok or Err) without panicking.
|
||||
|
||||
@@ -121,6 +124,11 @@ fn dissolve_matches(doc: &photocraft_doc::Document, ours: &[[f32; 4]], ps: &[[f3
|
||||
true
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Files whose export has tagged blocks that fail `common::strict_block_errors`.
|
||||
static BLOCK_ERRORS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
|
||||
@@ -284,6 +292,12 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
// Every exported tagged block must re-parse strictly (#200).
|
||||
let errs = common::strict_block_errors(&r.bytes);
|
||||
if !errs.is_empty() {
|
||||
eprintln!("{name:<60} EXPORT-BLOCK-ERROR {}", errs.join("; "));
|
||||
BLOCK_ERRORS.with(|b| b.borrow_mut().push(format!("{name}: {}", errs.join("; "))));
|
||||
}
|
||||
match import(name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
@@ -357,6 +371,7 @@ fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
fn run_oracle(src: &Source) {
|
||||
let Some((root, enforce)) = locate(src) else { return };
|
||||
let files = files_in(&root);
|
||||
BLOCK_ERRORS.with(|b| b.borrow_mut().clear());
|
||||
let (mut pass, mut thumb_pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff, mut crashes) = (0, Vec::new(), Vec::new());
|
||||
eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%");
|
||||
@@ -394,11 +409,14 @@ fn run_oracle(src: &Source) {
|
||||
);
|
||||
let pass = pass + thumb_pass;
|
||||
eprintln!("{label}: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
|
||||
let block_errors = BLOCK_ERRORS.with(|b| b.borrow().clone());
|
||||
eprintln!("{label}: exported files whose tagged blocks fail the strict re-parse: {}", block_errors.len());
|
||||
if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() {
|
||||
assert_eq!(errors, 0);
|
||||
}
|
||||
if enforce {
|
||||
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
|
||||
assert!(block_errors.is_empty(), "{label}: exported tagged blocks fail the strict re-parse (#200): {block_errors:?}");
|
||||
assert!(pass >= src.pass_floor, "{label}: oracle pass count {pass} fell below the floor {}", src.pass_floor);
|
||||
assert!(rt_same >= src.roundtrip_floor, "{label}: export round trip {rt_same} fell below the floor {}: {rt_diff:?}", src.roundtrip_floor);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! #200: PSD export must write tagged blocks that re-parse strictly. Third-party files
|
||||
//! re-saved by PhotoCraft had `soLD` data under the `PlLd` key, global blocks padded only
|
||||
//! to even, a layer info body not padded to 4 and, in PSB, `cinf`/`lnkE` read and written
|
||||
//! with 4-byte lengths; psd-tools rejected or misaligned all of them.
|
||||
|
||||
mod common;
|
||||
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::descriptor::{Descriptor, VersionedDescriptor};
|
||||
use photocraft_psd::testgen;
|
||||
use photocraft_psd::{Compression, LayerRecord, PsdFile, TaggedBlock, Version};
|
||||
|
||||
/// `PlLd` data per the Adobe spec ("Placed Layer"): `plcL`, version 3, Pascal uuid, page,
|
||||
/// total pages, anti-alias policy, layer type, 8 transform doubles, warp version, warp
|
||||
/// descriptor.
|
||||
fn plld(uuid: &str) -> Vec<u8> {
|
||||
let mut d = b"plcL".to_vec();
|
||||
d.extend(3u32.to_be_bytes());
|
||||
d.push(uuid.len() as u8);
|
||||
d.extend(uuid.as_bytes());
|
||||
for v in [1u32, 1, 16, 2] {
|
||||
d.extend(v.to_be_bytes());
|
||||
}
|
||||
for v in [0.0f64, 0.0, 4.0, 0.0, 4.0, 3.0, 0.0, 3.0] {
|
||||
d.extend(v.to_be_bytes());
|
||||
}
|
||||
d.extend(0u32.to_be_bytes());
|
||||
d.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes());
|
||||
// Photoshop pads the data to 4 inside the block length.
|
||||
while !d.len().is_multiple_of(4) {
|
||||
d.push(0);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// `SoLd` data per the Adobe spec ("Placed Layer Data"): `soLD`, version 4, descriptor.
|
||||
fn sold(uuid: &str) -> Vec<u8> {
|
||||
let mut d = b"soLD".to_vec();
|
||||
d.extend(4u32.to_be_bytes());
|
||||
let desc = Descriptor::new("null").with("Idnt", photocraft_psd::descriptor::Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul(uuid)));
|
||||
d.extend(VersionedDescriptor::new(desc).to_bytes());
|
||||
while !d.len().is_multiple_of(4) {
|
||||
d.push(0);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn reexport(f: &PsdFile) -> Vec<u8> {
|
||||
let bytes = f.to_bytes().unwrap();
|
||||
let imp = import("in.psd", &bytes).unwrap();
|
||||
export(&imp.document, "out.psd", &ExportOptions::default()).unwrap().bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_layer_blocks_keep_their_keys() {
|
||||
let uuid = "5b0c0b42-1b9c-11e0-a5b7-d0b1b2c3d4e5";
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
// Photoshop's order: PlLd before SoLd. The importer keeps SoLd as the principal block.
|
||||
f.layers_mut().push(LayerRecord {
|
||||
name: b"smart".to_vec(),
|
||||
blocks: vec![TaggedBlock::unicode_name("smart"), TaggedBlock::new(*b"PlLd", plld(uuid)), TaggedBlock::new(*b"SoLd", sold(uuid))],
|
||||
..Default::default()
|
||||
});
|
||||
for b in f.layers().last().unwrap().blocks.iter() {
|
||||
b.check_structure().unwrap();
|
||||
}
|
||||
let out = reexport(&f);
|
||||
assert_eq!(common::strict_block_errors(&out), Vec::<String>::new());
|
||||
let back = PsdFile::from_bytes(&out).unwrap();
|
||||
let rec = back.layers().iter().find(|l| l.block(b"SoLd").is_some()).unwrap();
|
||||
// Before the fix the SoLd data overwrote the PlLd block.
|
||||
assert_eq!(rec.block(b"PlLd").unwrap().data, plld(uuid));
|
||||
assert_eq!(rec.block(b"SoLd").unwrap().data, sold(uuid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_structure_rejects_swapped_placed_data() {
|
||||
let uuid = "u";
|
||||
assert!(TaggedBlock::new(*b"PlLd", sold(uuid)).check_structure().is_err());
|
||||
assert!(TaggedBlock::new(*b"SoLd", plld(uuid)).check_structure().is_err());
|
||||
let mut short = plld(uuid);
|
||||
short.truncate(short.len() - 3);
|
||||
assert!(TaggedBlock::new(*b"PlLd", short).check_structure().is_err());
|
||||
let mut long = sold(uuid);
|
||||
long.extend([1, 2, 3, 4, 5]);
|
||||
assert!(TaggedBlock::new(*b"SoLd", long).check_structure().is_err());
|
||||
// Bad linked-file chain: a length past the end of the block.
|
||||
let mut lnk = 100u64.to_be_bytes().to_vec();
|
||||
lnk.extend(b"liFD");
|
||||
assert!(TaggedBlock::new(*b"lnk2", lnk).check_structure().is_err());
|
||||
// Unchecked keys and empty data never panic.
|
||||
for key in [b"PlLd", b"SoLd", b"SoLE", b"lnk2", b"lfx2", b"zzzz"] {
|
||||
let _ = TaggedBlock::new(*key, Vec::new()).check_structure();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_blocks_are_padded_to_four() {
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
// A 77-byte global block (Content Credentials `CAI ` has this shape in the wild),
|
||||
// stored with Photoshop's 4-byte padding.
|
||||
let mut cai = TaggedBlock::new(*b"CAI ", vec![7; 77]);
|
||||
cai.padding = Some(vec![0; 3]);
|
||||
f.global_blocks.push(cai);
|
||||
f.global_blocks.push(TaggedBlock::new(*b"zEnd", vec![1, 2, 3, 4]));
|
||||
let out = reexport(&f);
|
||||
assert_eq!(common::strict_block_errors(&out), Vec::<String>::new());
|
||||
let back = PsdFile::from_bytes(&out).unwrap();
|
||||
assert_eq!(back.global_block(b"CAI ").unwrap().data, vec![7; 77]);
|
||||
assert!(back.global_block(b"zEnd").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_documents_pad_the_layer_info_block() {
|
||||
for depth in [photocraft_color::SampleType::U16, photocraft_color::SampleType::F32] {
|
||||
let mut d = photocraft_doc::Document::new("d", photocraft_geom::Size::new(5, 3), photocraft_color::ColorMode::Rgb, depth);
|
||||
let fmt = d.pixel_format();
|
||||
// Odd sizes so the unpadded layer info length is not a multiple of 4.
|
||||
d.layers.push(common::raster("a", fmt, photocraft_geom::Rect::new(0, 0, 5, 3), 1, true));
|
||||
d.layers.push(common::raster("b", fmt, photocraft_geom::Rect::new(1, 1, 3, 1), 2, true));
|
||||
let out = export(&d, "out.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
assert_eq!(common::strict_block_errors(&out), Vec::<String>::new(), "{depth:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_layer_blocks_carry_their_pad_inside_the_length() {
|
||||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||||
let mut odd = TaggedBlock::new(*b"zOdd", vec![1, 2, 3]);
|
||||
odd.padding = Some(vec![0]);
|
||||
f.layers_mut()[0].blocks.push(odd);
|
||||
let out = reexport(&f);
|
||||
assert_eq!(common::strict_block_errors(&out), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psb_long_length_keys_are_read_and_written_with_eight_bytes() {
|
||||
let mut f = testgen::small(Version::Psb, Compression::Raw);
|
||||
let mut cinf = TaggedBlock::new(*b"cinf", {
|
||||
let mut d = 16u32.to_be_bytes().to_vec();
|
||||
d.extend(Descriptor::new("null").to_bytes());
|
||||
d
|
||||
});
|
||||
cinf.signature = *b"8B64";
|
||||
f.global_blocks.push(cinf.clone());
|
||||
let bytes = f.to_bytes().unwrap();
|
||||
// The length field is 8 bytes: the 4 bytes after the key are the high half (0).
|
||||
let at = bytes.windows(8).position(|w| w == b"8B64cinf").unwrap();
|
||||
assert_eq!(&bytes[at + 8..at + 16], &(cinf.data.len() as u64).to_be_bytes());
|
||||
let back = PsdFile::from_bytes(&bytes).unwrap();
|
||||
assert!(back.layer_mask_trailing.is_empty());
|
||||
assert_eq!(back.global_block(b"cinf").unwrap().data, cinf.data);
|
||||
assert_eq!(back.to_bytes().unwrap(), bytes);
|
||||
}
|
||||
@@ -146,7 +146,12 @@ fn raster_layer_blocks_preserved() {
|
||||
d.layers[1].psd_blocks = blocks.clone();
|
||||
d.layers[1].vector_mask = photocraft_io::vector_map::vector_mask_from_block(&[0; 12], d.size.width, d.size.height);
|
||||
let back = roundtrip(&d);
|
||||
assert_eq!(back.layers[1].psd_blocks, blocks);
|
||||
// An odd-length layer block comes back with its pad byte inside the length (as Photoshop
|
||||
// lays layer blocks out, #200); everything else is verbatim.
|
||||
let mut expected = blocks.clone();
|
||||
expected[2].1 = std::sync::Arc::new(vec![1u8, 0]);
|
||||
assert_eq!(back.layers[1].psd_blocks, expected);
|
||||
assert_eq!(roundtrip(&back).layers[1].psd_blocks, expected, "stable after the first save");
|
||||
assert_eq!(back.layers[1].vector_mask, d.layers[1].vector_mask);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ BlockData::{UnicodeName, SectionDivider, LayerId, NameSource, BlendClippedAsGrou
|
||||
BlendInteriorElements, Knockout, Protection, SheetColor, FillOpacity, MetadataSetting}
|
||||
constructors: unicode_name, section_divider, layer_id, name_source, blend_clipped_as_group,
|
||||
blend_interior_elements, knockout, protection, sheet_color, fill_opacity
|
||||
tagged::uses_long_length(version, key) // PSB 8-byte length keys
|
||||
TaggedBlock::check_structure() -> Result<()> // strict inner re-parse of PlLd, SoLd, SoLE, lnk2/3/D, lfx2
|
||||
tagged::uses_long_length(version, key) // PSB 8-byte length keys (the spec's 13 + lnk3, lnkE, pths, extd, extn, FELS, cinf, artd)
|
||||
LayerInfo::unpadded_len(version), LayerInfo::pad_to(version, 4) // Photoshop pads the layer info to 4
|
||||
|
||||
// Image resources
|
||||
ImageResource { signature, id, name, data }; ImageResource::parsed() -> Option<Result<ResourceData>>
|
||||
|
||||
@@ -644,6 +644,32 @@ impl LayerInfo {
|
||||
Ok(LayerInfo { merged_alpha, layers, padding })
|
||||
}
|
||||
|
||||
/// Length of the body (layer count, records and channel data) without
|
||||
/// its trailing padding.
|
||||
pub fn unpadded_len(&self, version: Version) -> Result<u64> {
|
||||
let mut records = Vec::new();
|
||||
records.put_i16(0);
|
||||
for l in &self.layers {
|
||||
l.write(&mut records, version)?;
|
||||
}
|
||||
let mut n = records.len() as u64;
|
||||
for c in self.layers.iter().flat_map(|l| &l.channels).filter(|c| c.compression.is_some()) {
|
||||
n = n.saturating_add(2).saturating_add(c.data.len() as u64);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Sets the padding so the body length is a multiple of `align`, as
|
||||
/// Photoshop writes it (4). Readers that step over a global `Lr16`/`Lr32`
|
||||
/// block in 4-byte units (psd-tools) misread the blocks after a body that
|
||||
/// is only padded to even.
|
||||
pub fn pad_to(&mut self, version: Version, align: u64) -> Result<()> {
|
||||
let align = align.max(1);
|
||||
let len = self.unpadded_len(version)?;
|
||||
self.padding = Some(vec![0; ((align - len % align) % align) as usize]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serializes the body (without a length field).
|
||||
pub(crate) fn write_body(&self, out: &mut Vec<u8>, version: Version) -> Result<()> {
|
||||
let start = out.len();
|
||||
|
||||
@@ -11,8 +11,16 @@ use crate::error::{PsdError, Result};
|
||||
use crate::header::Version;
|
||||
use crate::io::{Reader, WriteExt};
|
||||
|
||||
/// Keys whose length field is 8 bytes in PSB files.
|
||||
pub const PSB_LONG_KEYS: [&[u8; 4]; 13] = [b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD"];
|
||||
/// Keys whose length field is 8 bytes in PSB files: the thirteen the Adobe
|
||||
/// specification lists, plus keys Photoshop also writes with 8-byte lengths
|
||||
/// although the specification omits them (observed in real PSB files, e.g.
|
||||
/// `lnkE` and `cinf` in the ag-psd test set, and expected by other PSB
|
||||
/// readers such as psd-tools). Reading one of these with a 4-byte length
|
||||
/// misaligns every block after it (#200).
|
||||
pub const PSB_LONG_KEYS: [&[u8; 4]; 21] = [
|
||||
b"LMsk", b"Lr16", b"Lr32", b"Layr", b"Mt16", b"Mt32", b"Mtrn", b"Alph", b"FMsk", b"lnk2", b"FEid", b"FXid", b"PxSD", // spec
|
||||
b"lnk3", b"lnkE", b"pths", b"extd", b"extn", b"FELS", b"cinf", b"artd", // observed
|
||||
];
|
||||
|
||||
/// Returns `true` if `key` uses an 8-byte length in `version`.
|
||||
pub fn uses_long_length(version: Version, key: &[u8; 4]) -> bool {
|
||||
@@ -218,6 +226,60 @@ impl TaggedBlock {
|
||||
Self::flag(*b"iOpa", v)
|
||||
}
|
||||
|
||||
/// Strictly re-parses the internal structure of blocks whose data carries
|
||||
/// its own sizes, so a block that a strict reader would reject is caught
|
||||
/// here: `PlLd` (a `plcL` placed layer), `SoLd`/`SoLE` (`soLD` placed
|
||||
/// layer data), `lnk2`/`lnk3`/`lnkD` (length-prefixed linked files) and
|
||||
/// `lfx2` (object effects). Every inner length must stay inside the block
|
||||
/// and the parse must end at the end of the data, apart from up to three
|
||||
/// zero padding bytes. Other keys are not checked and return `Ok`.
|
||||
///
|
||||
/// Layouts follow the Adobe Photoshop File Format specification
|
||||
/// ("Additional Layer Information": Placed Layer, Placed Layer Data,
|
||||
/// Linked Layer, Object Based Effects Layer Info).
|
||||
pub fn check_structure(&self) -> Result<()> {
|
||||
let d = &self.data[..];
|
||||
let mut r = Reader::new(d);
|
||||
match &self.key {
|
||||
b"PlLd" => {
|
||||
expect_kind(&mut r, b"plcL")?;
|
||||
let _version = r.u32()?;
|
||||
crate::io::read_pascal(&mut r, 1)?;
|
||||
// Page, total pages, anti-alias policy, layer type; transform (8 doubles).
|
||||
r.skip(4 * 4 + 8 * 8)?;
|
||||
let _warp_version = r.u32()?;
|
||||
crate::descriptor::VersionedDescriptor::read(&mut r)?;
|
||||
}
|
||||
b"SoLd" | b"SoLE" => {
|
||||
expect_kind(&mut r, b"soLD")?;
|
||||
let _version = r.u32()?;
|
||||
crate::descriptor::VersionedDescriptor::read(&mut r)?;
|
||||
}
|
||||
b"lfx2" => {
|
||||
let _effects_version = r.u32()?;
|
||||
crate::descriptor::VersionedDescriptor::read(&mut r)?;
|
||||
}
|
||||
b"lnk2" | b"lnk3" | b"lnkD" => {
|
||||
while r.remaining() >= 8 {
|
||||
let len = r.u64()?;
|
||||
let item = r.bytes_u64(len)?;
|
||||
if !matches!(item.get(..4), Some(b"liFD" | b"liFE" | b"liFA")) {
|
||||
return Err(PsdError::invalid(format!("{}: linked item of unknown type", self.key_str())));
|
||||
}
|
||||
// Items are padded to a multiple of 4.
|
||||
let pad = ((4 - len % 4) % 4).min(r.remaining() as u64);
|
||||
r.skip(pad as usize)?;
|
||||
}
|
||||
}
|
||||
_ => return Ok(()),
|
||||
}
|
||||
let rest = r.peek_rest();
|
||||
if rest.len() > 3 || rest.iter().any(|&b| b != 0) {
|
||||
return Err(PsdError::invalid(format!("{}: {} unexpected bytes after the block structure", self.key_str(), rest.len())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, out: &mut Vec<u8>, version: Version) -> Result<()> {
|
||||
out.put(&self.signature);
|
||||
out.put(&self.key);
|
||||
@@ -238,6 +300,14 @@ impl TaggedBlock {
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_kind(r: &mut Reader<'_>, kind: &'static [u8; 4]) -> Result<()> {
|
||||
let found = r.array::<4>()?;
|
||||
if &found != kind {
|
||||
return Err(PsdError::invalid(format!("expected {} structure, found {:?}", String::from_utf8_lossy(kind), String::from_utf8_lossy(&found))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_section(d: &[u8]) -> Result<SectionDivider> {
|
||||
let mut r = Reader::new(d);
|
||||
let kind = SectionType::from_u32(r.u32()?);
|
||||
|
||||
Reference in new issue
Block a user