Files
craft-libs/crates/psd/src/lib.rs
T
f462bb8128 craft-psd: deferred global blocks and a merged-image preview (ported from PhotoCraft) (#1037)
* craft-psd: Photoshop PSD/PSB reader and writer, moved from PhotoCraft

PhotoCraft's `photocraft-psd` crate (crates/psd at storytold/photocraft
63b641b55e54bf8f5c83efd977fdb16a2c255431) as `craft-psd`: the lossless
PSD/PSB model, reader, writer and builder, descriptors, the Photoshop
resource files that share its primitives (.abr, .asl, .aco, .act, .ase,
.grd, .pat, .atn, .csh, .shc) and the Photoshop blocks of a layered TIFF.
The crate had no dependency on any other PhotoCraft crate.

Code is unchanged apart from the crate name, craft-libs' rustfmt width,
comments that named other PhotoCraft crates, issue references (now
`photocraft#N`), and the corpus tests' directory (`CRAFT_PSD_CORPUS` or
`corpus/` at the workspace root). Tests, proptests, fuzz targets and
fixtures move with it.

Part of #778, #786.

* craft-psd: engine_data, the text engine document of type layers

The EngineData reader and writer from PhotoCraft's photocraft-text
(crates/text/src/engine_data.rs at storytold/photocraft 63b641b5): a
tree of values that is written back in Photoshop's layout, with strings
stored as plain bytes kept as plain bytes.

From EffectCraft's effectcraft-psd (crates/psd/src/engine_data.rs at
storytold/effectcraft 072e1af9), ported onto that tree: `first_style`,
the first style run as a typed `TextStyle` with bounded sizes and
spacings, and `minimal`, a one-run document for writers and fixtures.
`Value::index` is new.

Both apps had their own parser for the same syntax.

* craft-psd: engine_data::parse_lossy reads what it can of a damaged document

EffectCraft's EngineData reader was best effort: a truncated or damaged
document still gave the text and style it held. `parse_lossy` does that
on the same parser: a truncated dictionary, array or string keeps what
it has, a byte that starts no key is skipped, a token that is no number
becomes a name, and garbage gives an empty dictionary. `parse` stays
strict and is unchanged in what it accepts.

The parser's `value` is split into one function per kind of value, and
reads with `get` instead of indexing.

* craft-psd: typed views of type layers, smart objects and adjustments

Read-only views EffectCraft's effectcraft-psd had over the blocks this
crate keeps raw (storytold/effectcraft f334bcf3, crates/psd/src/parse.rs
and placed.rs). The blocks stay raw and write back unchanged.

- `text`: `TypeLayer` from `TySh`: text, transform (the identity when not
  finite), bounds, the first style run (read with
  `engine_data::parse_lossy`) and the text descriptor.
  `LayerRecord::type_layer`.
- `smart`: `SmartObject` from `SoLd`/`SoLE` (id, quad, non-affine
  transform, size, warp) and `PlLd`; `Warp` with the named styles and the
  custom quilt mesh; the quad geometry (`map`, `placed_bounds`,
  `placed_size`, `bake_scale`, `placement`). `LinkedFile` and
  `linked_files` for `lnk2`/`lnkD`/`lnk3`/`lnkE`, `PsdFile::linked_files`
  and `PsdFile::linked_data`. `LayerRecord::smart_object`.
- `adjust`: `Adjustment` from `nvrt`, `brit`, `CgEd`, `hue2`/`hue `,
  `levl`, `expA`, and the other adjustment keys by name;
  `LayerRecord::adjustment`, `solid_color` (`SoCo`) and `effects` (`lfx2`).
- `Descriptor`: `object`, `number`, `numbers`, `boolean`, `text`,
  `enumerated`, `raw_data`, `color`; `Value::as_number`.

Linked-file data is a range of the block's data, checked against the
record, instead of a range of the whole file.

* craft-psd: shared descriptor lookups

Descriptor::get_bool, get_number, get_text, get_enum and get_object,
used by asl, grd and slices in place of their own copies.

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

* craft-psd: the typed views use the shared descriptor lookups

One set of Descriptor accessors: the get_* lookups from photocraft#9552.
numbers, raw_data and color move next to them as get_numbers,
get_raw_data and get_color.

* craft-psd: global blocks can stay in the source, and a preview of the merged image

- `PsdFile::from_source_deferring` leaves the data of each global block a
  callback picks in the source (`TaggedBlock::stored`), so an embedded
  smart-object file that is most of a PSD is not read on open.
  `TaggedBlock::encoded`, `encoded_data_len` and `load` give the data
  wherever it is; writing copies it from the source; equality compares the
  bytes, and the same range of the same source without reading it.
  `parsed` refuses a block still in the source.
- `FileSource` fails a read after the file changed in place (length or
  modification time), instead of mixing old offsets with new bytes.
- `PsdFile::merged_preview(max_side)` samples every k-th row and pixel of
  a raw or RLE RGB/gray merged image, 8/16/32-bit (32-bit encoded to sRGB),
  reading a few megabytes of a large file. `ImageData::row_decoder` decodes
  the merged image's rows on their own.

Ported from storytold/photocraft#11586, which contains #6589 and #6616's
crates/psd changes (photocraft#5106).

* craft-psd: linked files read deferred blocks, and the preview size is capped

- `PsdFile::linked_files` and `linked_data` read a linked-layer block
  through `TaggedBlock::encoded`, so a block left in the source is found;
  they return `Result`, and `linked_data` a `Cow` borrowed from a block in
  memory.
- `merged_preview` refuses an output over `MAX_DECODED_BYTES` before it
  decodes or allocates (the size is the caller's), and a failed allocation
  is `OutOfMemory`.

---------

Co-authored-by: Omar Saidahmed <osaidahm@uwaterloo.ca>
Co-authored-by: Phil Trinh <ptrinh@me.com>
2026-11-11 20:56:55 -05:00

121 lines
4.4 KiB
Rust

//! # craft-psd
//!
//! Faithful, lossless reader and writer for Adobe Photoshop PSD (version 1)
//! and PSB (version 2, "large document format") files.
//!
//! * [`PsdFile::from_bytes`] parses a file into a format-level model;
//! [`PsdFile::to_bytes`] writes it back. Unmodified files round-trip byte
//! for byte: channel data is kept in its original encoding, unknown tagged
//! blocks and resources are kept raw, and padding is preserved.
//! * Channel data is decoded lazily ([`ChannelData::decode`],
//! [`ImageData::decode`]) with all four compression methods supported for
//! both decoding and encoding.
//! * [`PsdBuilder`] constructs new files from RGBA/gray/CMYK buffers; the
//! caller supplies the merged composite (this crate never composites).
//! * [`Head`] reads only the header, colour mode data and image resources (size, ICC profile,
//! EXIF, XMP) from the start of a file, without the layers or pixels.
//! * [`descriptor`] parses and writes ActionDescriptors.
//! * [`engine_data`] parses and writes the text engine document of type layers.
//! * [`text`], [`smart`] and [`adjust`] are read-only views of the blocks a viewer needs: a type
//! layer's text and style, a smart object's placement, warp and embedded file, an adjustment
//! layer's settings, a fill layer's colour and the layer effects.
//! * [`PsdFile::composite_f32`] and [`PsdFile::layer_f32`] decode to straight RGBA `f32` in
//! every colour mode and depth, within a memory budget; [`smart::SmartObject::render_placed`]
//! bakes a warped or perspective placement.
//!
//! Implemented clean-room from Adobe's public "Photoshop File Formats
//! Specification". Undocumented details that follow MIT-licensed psd-tools
//! or ag-psd behavior are noted in comments.
//!
//! The parser never panics on malformed input and enforces limits
//! (dimensions ≤ 300000, bounded allocations, bounded descriptor nesting).
//!
//! ```
//! use craft_psd::{LayerSpec, PixelData, PsdBuilder, PsdFile};
//!
//! let mut b = PsdBuilder::new(2, 1);
//! b.push_layer(LayerSpec::new("Red", 0, 0, 2, 1, PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128])));
//! b.composite(PixelData::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 128]));
//! let bytes = b.to_bytes()?;
//!
//! let file = PsdFile::from_bytes(&bytes)?;
//! assert_eq!(file.to_bytes()?, bytes); // byte-stable
//! let layer = file.layer(0).unwrap();
//! assert_eq!(layer.name(), "Red");
//! assert_eq!(layer.rgba8()?.data[7], 128);
//! # Ok::<(), craft_psd::PsdError>(())
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod abr;
pub mod aco;
pub mod act;
pub mod adjust;
pub mod animation;
pub mod ase;
pub mod asl;
pub mod atn;
pub mod blend;
pub mod builder;
mod codepage;
pub mod compression;
pub mod csh;
pub mod descriptor;
pub mod engine_data;
pub mod error;
pub mod file;
pub mod filter_effects;
#[cfg(any(test, feature = "testgen"))]
pub mod fixtures;
pub mod float;
pub mod grd;
pub mod hdr;
pub mod head;
pub mod header;
pub mod image_data;
mod io;
pub mod layer;
mod lazy;
pub mod metadata;
pub mod path;
pub mod patterns;
pub mod pixels;
pub mod preview;
pub mod resources;
pub mod rows;
pub mod shc;
pub mod slices;
pub mod smart;
pub mod source;
pub mod tagged;
#[cfg(any(test, feature = "testgen"))]
// Test-data generator (tests and fuzz seeds only): failing loudly is the point.
#[allow(clippy::expect_used)]
pub mod testgen;
pub mod text;
pub mod tiff;
pub mod tree;
pub use blend::BlendMode;
pub use builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder};
pub use compression::{Compression, PlaneLayout};
pub use descriptor::{Descriptor, VersionedDescriptor};
pub use error::{PsdError, Result};
pub use file::{GlobalLayerMask, LayerInfoPlacement, PsdFile};
pub use float::FloatImage;
pub use head::Head;
pub use header::{ColorMode, Header, Version};
pub use image_data::ImageData;
pub use layer::{BlendingRanges, ChannelData, LayerFlags, LayerInfo, LayerMask, LayerRecord, MaskData, MaskParameters, RealMask, Rect};
pub use pixels::{GrayImage, Layer, RgbaImage};
pub use preview::Preview;
pub use resources::{ImageResource, ResolutionInfo, ResourceData};
pub use rows::RowDecoder;
#[cfg(any(unix, windows))]
pub use source::FileSource;
pub use source::{ReadAt, Source, Stored};
pub use tagged::{BlockData, SectionDivider, SectionType, TaggedBlock};
pub use tree::LayerNode;