Files
336619e3e4 craft-psd: fixes ported from open PhotoCraft pull requests (#1024)
* craft-psd: raise the 64-bit .abr sample budget to 2 GiB

Photoshop writes brush packs up to its 2 GB .abr limit; a 1 GiB budget
refused valid packs over it. 32-bit targets keep 512 MiB.

Ported from storytold/photocraft#13282 (photocraft#5949).

* craft-psd: FELS is checked and transcoded as filter effects

FELS holds the smart-filter masks of linked smart objects in the FEid
layout. check_structure now re-parses it, and a layered TIFF transcodes
it field by field: a little-endian save dropped the block as unknown.

Ported from storytold/photocraft#8275 (photocraft#8260); the test is new.

* craft-psd: a layered TIFF keeps external smart-object links

An external linked file (liFE) holds the descriptor of its link, and
from version 3 its bytes. The TIFF transcoder skipped both, so the record
failed to convert and a little-endian save dropped the lnkE block.

Ported from storytold/photocraft#6072; the test is new.

* craft-psd: the example and the save test write into fresh private directories

The big_psb example and the save_and_open test used a predictable temp
directory name, which a planted directory or symlink could take over.
They now create a new owner-only directory, and the example removes a
half-written fixture from its own default folder.

Ported from storytold/photocraft#13266 (its crates/psd part).

* craft-psd: the pixel preflight sees the merged image's compression and size

from_bytes_and_merged_preflight_info and
from_source_and_merged_preflight_info hand the preflight the merged
image's compression and encoded length before the image is read, so a
caller can estimate the memory a decode will need (an opaque RGB image
stored raw needs no inflate buffer). A missing compression field is
None, and a refusal takes precedence over it.

Ported from storytold/photocraft#11288 (its crates/psd part); the test is
new.

* craft-psd: typed layer selection ids (resource 1069)

ResourceData::LayerSelectionIds: the selected layers' ids in stored
order, duplicates and zero kept. The count is checked against the whole
payload before anything is allocated; truncated or trailing data is
malformed, not a partial selection.

Ported from storytold/photocraft#10268 (its crates/psd part).

* craft-psd: README matches the code

The typed blocks tsly, lmgm and vmgm, vector masks and slices typed by
path and slices, the resources ImageResource::parsed types, the ATN
round-trip caveats and version-12 reading, and the corpus test's
known-bad file.

Ported from storytold/photocraft#11600 (its crates/psd part), with the
resource list brought up to date.

* craft-psd: atn_dump escapes control characters from the file

A set's names, warnings, paths and errors are printed with control
characters escaped, so a file cannot drive the terminal.

Ported from storytold/photocraft#11218 (merged into PhotoCraft's copy
after craft-psd was moved).

* craft-psd: the .abr budget test checks the 64-bit bound at compile time

clippy refuses an assertion whose value is constant.

---------

Co-authored-by: rgoza1996 <97354241+rgoza1996@users.noreply.github.com>
Co-authored-by: Korg1ch <57859048+Korg1ch@users.noreply.github.com>
Co-authored-by: PJaYOfficial <1597623+PJaYOfficial@users.noreply.github.com>
Co-authored-by: acilione <antoninocilione96@gmail.com>
Co-authored-by: kirubhakaran12 <27127513+kirubhakaran12@users.noreply.github.com>
Co-authored-by: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com>
2026-11-11 20:39:59 -05:00
..

craft-psd

A standalone reader and writer for Adobe Photoshop PSD (version 1) and PSB (version 2, "large document format") files. It keeps everything it reads, so an unmodified file writes back byte for byte. It came from PhotoCraft (photocraft-psd) and depends on no app.

  • Clean-room implementation from Adobe's public Photoshop File Formats Specification. Where the spec is silent, behavior follows MIT-licensed psd-tools / ag-psd documentation, and the code comments say so.
  • #![forbid(unsafe_code)]. Builds for wasm32-unknown-unknown. The core API works on byte slices; the file helpers only exist on native targets.
  • Parsing never panics on malformed input. Limits: dimensions ≤ 300 000; allocations are checked against the remaining input; a single decode produces at most MAX_DECODED_BYTES (8 GiB on 64-bit targets, 2 GiB on 32-bit targets); descriptor nesting depth ≤ 64.

Guarantees

Property How
PsdFile::from_bytes(b)?.to_bytes()? == b for unmodified files Channel data keeps its original encoding. Unknown blocks and resources are stored raw. Exact padding is recorded wherever it can vary: tagged blocks, layer info, section trailers.
from_bytes(to_bytes(f)) == f for any model f Canonical defaults: padding: None means "zero-pad to even", and the parser yields None whenever the file used that default.
Lazy decoding The parser only copies encoded bytes. Channels are decoded when asked (ChannelData::decode, ImageData::decode).
Lazy reading PsdFile::from_source reads only the structure. Channel data and the merged image stay in the source as Stored ranges and are read when decoded, so opening a 2 GB file doesn't need 2 GB for the file itself. The model equals what from_bytes builds, and it writes back the same bytes.
No compositing PsdBuilder takes the merged image from the caller. Without one, it writes a white placeholder and sets has_real_merged_data = false in resource 1057.

Public API (overview)

// Top level
PsdFile::from_bytes(&[u8]) -> Result<PsdFile>
PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option<Result<Vec<u8>>>)>
                                            // + decode_merged() when want(&file): ZIP inflated once
PsdFile::from_bytes_salvaging(&[u8]) -> Result<(PsdFile, Option<PsdError>)>  // damaged layers: flattened, with the skipped error
PsdFile::composite_f32() / layer_f32(i, canvas) -> Result<FloatImage>   // straight RGBA f32, every mode and depth, within a budget
PsdFile::from_source(Source) -> Result<PsdFile>   // channel data and merged image left in the source
PsdFile::from_source_and_merged_recovering(Source, want, recover)
PsdFile::to_bytes(&self) -> Result<Vec<u8>>
PsdFile::open(path) / save(path)            // native only
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement,
          global_layer_mask, global_blocks, layer_mask_trailing, image_data }
PsdFile::layers() -> &[LayerRecord]         // file order = bottom-most first
PsdFile::layers_mut() -> &mut Vec<LayerRecord>
PsdFile::layer(i) -> Option<Layer<'_>>;  iter_layers()
PsdFile::layer_tree() -> Vec<LayerNode>     // nested groups, children bottom-to-top
PsdFile::layer_tree_max_depth(n) -> Vec<LayerNode> // same, groups nested n deep keep no children
PsdFile::decode_merged() -> Result<Vec<u8>> // planar big-endian samples
PsdFile::composite_rgba8() -> Result<RgbaImage>
PsdFile::resource(id), global_block(key), icc_profile(), resolution(),
        has_real_merged_data(), merged_has_alpha(), validate()

// Head only: header, colour mode data and image resources; no layers, no pixels
Head::from_bytes(&[u8]) -> Result<Head>     // the file or a prefix reaching the end of the resources
Head::from_source(&Source) -> Result<Head>  // reads nothing after the resources
Head { header, color_mode_data, resources, layer_section_offset };  Head::resource(id)
Header::from_bytes(&[u8]) -> Result<Header> // the first 26 bytes
head::sniff(&[u8]) -> bool

// Sources (lazy reading)
trait ReadAt { len(), read_at(offset, buf) }   // [u8], Vec<u8>, Arc<T>, FileSource (pread / seek_read)
Source::new(impl ReadAt) / read_vec(offset, len)   // shared handle; equal when the same source
Stored { source, offset, len }::read() / read_part(at, len)

// Header
Header { version: Version::{Psd,Psb}, channels, width, height, depth, color_mode, reserved }
ColorMode::{Bitmap, Grayscale, Indexed, Rgb, Cmyk, Multichannel, Duotone, Lab, Unknown(u16)}

// Layers
LayerInfo { merged_alpha, layers: Vec<LayerRecord>, padding }
LayerRecord { rect, channels: Vec<ChannelData>, blend_mode, opacity, clipping, flags,
              filler, mask: MaskData, blending_ranges, name, blocks, extra_trailing }
LayerRecord::name()            // prefers `luni`
LayerRecord::section_divider(), section_type(), layer_id(), fill_opacity(), is_visible(),
             block(key), block_mut(key), channel(id), channel_rect(id), decode_channel(id, depth, version)
ChannelData { id, compression: Option<Compression>, data, stored: Option<Stored> }   // encoded bytes kept verbatim
ChannelData::new(id, compression, data); encoded() -> Cow<[u8]>; encoded_len(); load()
ChannelData::encode(id, compression, decoded, w, h, depth, version)
ChannelData::decode(w, h, depth, version); set_decoded(...)
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)

// Tagged blocks
TaggedBlock { signature /* 8BIM | 8B64 */, key, data, padding: Option<Vec<u8>> }
TaggedBlock::parsed() -> Option<Result<BlockData>>
BlockData::{UnicodeName, SectionDivider, LayerId, NameSource, BlendClippedAsGroup,
            BlendInteriorElements, Knockout, TransparencyShapes, LayerMaskHidesEffects,
            VectorMaskHidesEffects, Protection, SheetColor, FillOpacity, MetadataSetting}
constructors: unicode_name, section_divider, layer_id, name_source, blend_clipped_as_group,
              blend_interior_elements, knockout, transparency_shapes, layer_mask_hides_effects,
              vector_mask_hides_effects, protection, sheet_color, fill_opacity
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>>
ResourceData::{ResolutionInfo, LayerState, LayerGroupInfo, Thumbnail, GlobalAngle, IccProfile,
               GlobalAltitude, VersionInfo, Exif, Xmp, TimelineInfo, Animation, LayerSelectionIds}

// Legacy animation syntax (no playback/document-model dependency)
animation::AnimationResource::from_bytes / to_bytes  // maniIRFR; ordered raw records
animation::AnimationRecord::descriptor()              // AnDs; version-16 descriptor
animation::{frame_info, frame_sets, layer_frame_states} // borrowed sparse views, opaque ids
animation::parse_descriptor                          // descriptor + up to 3 zero alignment bytes
metadata::MetadataItem::animation_descriptor()       // mlst / tmln; leaves other metadata alone

// Compression
Compression::{Raw, Rle, Zip, ZipPrediction, Unknown(u16)}
compression::{encode_planes, decode_planes, PlaneLayout, packbits::{encode, decode}, predict, unpredict}
pixels::{samples_u16, samples_f32, u16_to_bytes, f32_to_bytes, unpack_bits, plane_to_u8}

// Descriptors
descriptor::{Descriptor, VersionedDescriptor, Value, Id, Class, ReferenceItem, UnicodeString, ObjectArray}
Descriptor::from_bytes / to_bytes;  VersionedDescriptor::from_bytes / to_bytes (version 16)

// Text engine data (`EngineData` in `TySh`, and the document's `Txt2`)
engine_data::parse(&[u8]) -> Result<Value, ParseError>;  parse_lossy(&[u8]) -> Value;  write(&Value);  write_bare(&[(String, Value)])
engine_data::Value::{Dict, Array, String, RawString, Int, Real, Bool, Name}   // RawString is written back as the same bytes
engine_data::first_style(&Value) -> TextStyle   // font, size, fill, justification, tracking, leading, faux styles, box text
engine_data::minimal(text, font, size, rgb, justification) -> Vec<u8>

// Layered TIFF (tags 37724 and 34377)
tiff::ImageSourceData::from_bytes / to_bytes(ByteOrder) / into_psd(header, resources, image_data);  tiff::resources_from_bytes / resources_to_bytes
tiff::layered::{TAG_IMAGE_SOURCE_DATA, TAG_IMAGE_RESOURCES, FILTER_MASK_DEFAULT}
ImageSourceData::for_tiff(&mut PsdFile, ifd_alpha)   // Photoshop's conventions: Patt, FMsk, MTrn, LMsk, per-layer blocks
ImageSourceData::drop_photoshop_padding()            // on open, so the padding does not pile up
tiff::layered::ifd_channel_resources(&mut resources, IfdChannels { color, merged_alpha, write_alpha, extra, kept })
tiff::layered::{interleaved_to_planar, planar_to_interleaved, swap_plane, strip_foreign_order_blocks, alpha_identifiers}

// Builder
PsdBuilder::new(w, h).color_mode(..).depth(8|16).version(..).compression(..).resolution(dpi).icc_profile(..)
builder.push_layer(LayerSpec) / begin_group(GroupSpec) / end_group()? / composite(PixelData)
builder.build() -> Result<PsdFile>;  builder.to_bytes()
PixelData::{Rgba8, Rgba16, GrayA8, GrayA16, Cmyka8, Cmyka16}   // CMYK given as ink, stored inverted

// Test generator (feature `testgen`)
testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_plane}

Feature matrix

Area Read Write Typed Notes
Header, PSD v1 / PSB v2 ✓ ✓ ✓ depth 1/8/16/32; all 8 color modes plus Unknown
Color mode data ✓ ✓ raw indexed palette is used by composite_rgba8
Image resources ✓ ✓ partial 1005, 1024, 1026, 1033/1036 (raw), 1037, 1039, 1049, 1057, 1058, 1060, 1069
Layer records ✓ ✓ ✓ flags, clipping, filler, blending ranges (raw + accessor)
Layer masks ✓ ✓ ✓ 20 / 36-byte forms, mask parameters, real mask; unparseable masks stay raw
Channel ids -1 / -2 / -3 ✓ ✓ ✓ per-channel rect via channel_rect
Raw / RLE / ZIP / ZIP+prediction ✓ ✓ – prediction at 8/16/32 bits (32: byte-plane shuffle + delta); RLE counts are u16 in PSD, u32 in PSB
Merged image data ✓ ✓ – validated at parse time (sizes, RLE counts, zlib stream incl. checksum); a zlib stream declaring more than MAX_DECODED_BYTES is kept verbatim without inflating it, and decoding it reports the limit
Tagged blocks (raw passthrough) ✓ ✓ – keeps 8BIM / 8B64 and exact padding
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 tsly lmgm vmgm 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
Legacy frame animation syntax ✓ ✓ partial mani / AnDs, mlst, tmln, resource 1075; raw-preserving descriptor views, not playback or keyframe evaluation
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
Builder – ✓ – RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping
Patterns ✓ ✓ ✓ Patt / Pat2 / Pat3, .pat, and .abr embedded patterns; non-empty tiles allow in-bounds channel subrectangles, decoded size is capped and PackBits expansion is input-bounded
Actions (.atn) ✓ ✓ ✓ atn::parse / atn::write: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip for version-16 files, except that flag and Boolean bytes other than 0 or 1 are written back as 1 and trailing bytes after the last action are dropped (with a parse warning); version-12 (Photoshop 5) files are read too and written back as version 16

Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel metadata is checked before any plane is decoded, against one PatternBudget shared by every pattern of a parse (a block, a .pat file): MAX_DECODED_BYTES decoded and 512 MiB of zero padding in total. parse_pattern_block_with lets several blocks of one document share a budget; .abr patterns are charged to the file's sample budget. All channels of a pattern must have the same depth. Fully populated channels reuse their decoded buffer without a second copy; allocations for padded channels return an error on failure.

Preserved raw, not yet typed (parsers planned for M8)

  • Layer effects: lfx2, lrFX, lmfx. The descriptors inside can be parsed on demand with VersionedDescriptor::from_bytes(&block.data[4..]).
  • Type layers: TySh, tySh. The EngineData inside them has a parser and writer (engine_data); the blocks themselves are not typed.
  • Smart objects: SoLd, PlLd, SoLE, lnk2, lnkD, lnk3.
  • Shapes: vogk, vscg (vector masks, vmsk / vsms, are typed by path).
  • Adjustment and fill layers: curv, levl, hue2, SoCo, GdFl, PtFl, …, plus Txt2, FMsk, 3D and video blocks.
  • Global layer mask info (raw, with accessors), thumbnails (raw), guides, and every other resource id except the ones ImageResource::parsed() types (resolution info, layer state, layer group info, global angle and altitude, version info, Exif, XMP, layer selection ids, timeline info, mani animation), paths (path) and slices (slices).

Legacy animation inspection

cargo run -p craft-psd --example animation_inspect -- /path/to/input.psd
cargo test -p craft-psd --test it animation::

The inspection example accepts an explicit local PSD/PSB (up to 128 MiB), reports frame counts, stored delays and descriptor counts, and checks byte-exact raw round-trips without modifying the input. It does not print layer names, text or descriptor values. No personal file is bundled; committed animation tests construct synthetic payloads and PSD/PSB containers.

mani is detected by its payload signature within the plug-in resource range 4000–4999, not by assuming that resource 4003 always belongs to animation. Unknown records and unsupported descriptor versions stay raw. FrIn definitions and FSts playback order are separate; ids are opaque stored long values. Missing frame delays remain absent, and sparse mlst properties are not filled with guessed defaults. Timeline tracks, loops, inheritance and property evaluation are left to higher layers. This parser alone does not make PSD animations play.

Testing

cargo test -p craft-psd --all-features runs the unit tests, integration tests and proptests:

  • Byte stability and model round-trips for every generated case: all depths, color modes and compressions, PSD and PSB.
  • PackBits edge cases.
  • Known vectors for 8/16/32-bit prediction.
  • PSD vs PSB length handling.
  • Truncation sweeps: every prefix of several small files must return Err.
  • Random-mutation and random-byte proptests: never panic.
  • Layer tree cases.
  • Builder → rgba8 correctness.
  • Unknown blend modes and unknown blocks pass through unchanged.

tests/it/corpus.rs and tests/it/corpus_atn.rs (feature corpus) walk a real-file corpus that is not in this repository: corpus/psd, corpus/psd-tools and corpus/atn at the workspace root, or under CRAFT_PSD_CORPUS (a PhotoCraft checkout's corpus directory). For each file they assert that parsing succeeds and the file round-trips byte for byte. With the feature on and the corpus missing they fail. The one known-bad upstream file, group-divider-blend-mode.psd, is reported but does not fail the test (KNOWN_BAD). Add -- --nocapture to see the per-file results.

A cargo-fuzz skeleton lives in fuzz/. It is its own workspace and is excluded from the craft-libs workspace. Run it with cargo +nightly fuzz run parse (or descriptor) from crates/psd.

Spec ambiguities and decisions

  • Tagged block padding. The spec says lengths are "rounded up to an even byte count". In practice writers either include the padding in the length or append it after the data, and pad to 2 or 4. The parser detects the padding that actually follows each block: the smallest k ≤ 3 after which the next signature or the end of the region appears. It stores that padding unless it matches the default.
  • Layer info padding (2 vs 4) is recorded the same way.
  • 16/32-bit layers are read from global Lr16 / Lr32 blocks when the main layer info is empty. The block data is a layer-info body with no inner length field.
  • Mask record layout. The order is: rect, default color, flags, parameters (when flag bit 4 is set), then the real flags, background and rect whenever at least 18 bytes remain. Anything left over (for example the 2 padding bytes of the 20-byte form) is kept in trailing.
  • Mask channel rects. Channel -2 uses the mask rect. Channel -3 uses the real mask rect.
  • Merged ZIP data is a single zlib stream over all planes. Prediction is applied per row across all planes.
  • ZIP prediction at depth 1 is treated as byte-wise delta.
  • ObAr is read as a u32 prefix followed by a descriptor-shaped body, following ag-psd's reading. The spec does not document it.
  • Descriptor bool bytes other than 0/1 are normalized to 1 on write.
  • Pascal names have their pad bytes written as zeros. A layer & mask section that contains only a zero layer-info length is normalized to an empty section. These are the only known non-byte-exact cases, and both are pathological.
  • Header limits. The parser accepts up to 300 000 × 300 000 for both versions. PsdFile::validate() enforces the spec's 30 000 limit for PSD.