Files
craft-libs/crates/psd/src/preview.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

159 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A small picture of a file from its merged image, without decoding it whole:
//! [`PsdFile::merged_preview`].
//!
//! Every `k`-th row of each plane is decoded (RLE and raw rows are read on their own, see
//! [`crate::RowDecoder`]) and every `k`-th sample of it kept. For a file parsed with
//! [`PsdFile::from_source`] that reads a few megabytes of a multi-gigabyte file, so the picture
//! can show while the layers are still being decoded.
use crate::compression::Compression;
use crate::error::Result;
use crate::file::PsdFile;
use crate::header::ColorMode;
/// A preview picture: RGBA, 8 bits per channel, row-major, opaque. A transparent document's
/// merged image is stored matted with white, so the picture is the document over white.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Preview {
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// `width × height × 4` bytes.
pub rgba: Vec<u8>,
}
impl PsdFile {
/// The merged image reduced (by a whole factor, nearest sample) to fit `max_side`. `None` when
/// there is no real merged image to show: an RGB or grayscale one (8/16/32-bit) stored raw
/// or RLE (a ZIP stream would have to be inflated whole), and not flagged as a placeholder.
pub fn merged_preview(&self, max_side: u32) -> Result<Option<Preview>> {
let h = &self.header;
let color = match h.color_mode {
ColorMode::Rgb => 3usize,
ColorMode::Grayscale => 1,
_ => return Ok(None),
};
if !matches!(h.depth, 8 | 16 | 32)
|| !matches!(self.image_data.compression, Compression::Raw | Compression::Rle)
|| self.has_real_merged_data() == Some(false)
|| usize::from(h.channels) < color
|| h.width == 0
|| h.height == 0
{
return Ok(None);
}
let k = h.width.max(h.height).div_ceil(max_side.max(1)).max(1);
let (w, ht) = (h.width.div_ceil(k), h.height.div_ceil(k));
let bps = usize::from(h.depth / 8);
// At most `max_side`² pixels, but `max_side` is the caller's: checked and fallible.
let n = (w as usize)
.checked_mul(ht as usize)
.and_then(|p| p.checked_mul(4))
.filter(|&n| n as u64 <= crate::compression::MAX_DECODED_BYTES)
.ok_or(crate::error::PsdError::LimitExceeded("preview exceeds MAX_DECODED_BYTES"))?;
let dec = self.image_data.row_decoder(h)?;
let mut rgba = Vec::new();
rgba.try_reserve_exact(n).map_err(|_| crate::error::PsdError::OutOfMemory("not enough memory for the preview"))?;
rgba.resize(n, 255u8);
for y in 0..ht {
let src_y = (y * k) as usize;
for p in 0..color {
let r = p * h.height as usize + src_y;
let row = dec.rows(r, r + 1)?;
for x in 0..w as usize {
let at = x * k as usize * bps;
let Some(s) = row.get(at..at + bps) else { continue };
let v = match *s {
[a] => a,
[a, _] => a,
// 32-bit samples are linear light: encoded for display (sRGB) first.
[a, b, c, d] => (srgb_encode(f32::from_be_bytes([a, b, c, d]).clamp(0.0, 1.0)) * 255.0).round() as u8,
_ => 0,
};
let px = (y as usize * w as usize + x) * 4;
if color == 1 {
rgba[px..px + 3].fill(v);
} else {
rgba[px + p] = v;
}
}
}
}
Ok(Some(Preview { width: w, height: ht, rgba }))
}
}
/// The sRGB transfer of a linear value in `0..=1`.
fn srgb_encode(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
#[cfg(test)]
mod tests {
use crate::source::Source;
use crate::{Compression, PsdFile, Version, testgen};
/// The preview of a file read from a source is every k-th pixel of the merged image's
/// colour planes, opaque.
#[test]
fn preview_samples_the_merged_image() {
for c in [Compression::Raw, Compression::Rle] {
let file = testgen::small(Version::Psd, c);
let bytes = file.to_bytes().unwrap();
let lazy = PsdFile::from_source(Source::new(bytes)).unwrap();
let planes = file.decode_merged().unwrap();
let (w, h) = (file.header.width, file.header.height);
for max in [2, 3, 1000] {
let p = lazy.merged_preview(max).unwrap().unwrap();
let k = w.max(h).div_ceil(max);
assert_eq!((p.width, p.height), (w.div_ceil(k), h.div_ceil(k)));
for y in 0..p.height {
for x in 0..p.width {
let b = ((y * p.width + x) * 4) as usize;
for ch in 0..3u32 {
let a = (ch * w * h + y * k * w + x * k) as usize;
assert_eq!(p.rgba[b + ch as usize], planes[a], "{c:?} {max} at {x},{y}");
}
assert_eq!(p.rgba[b + 3], 255);
}
}
}
}
assert!(testgen::small(Version::Psd, Compression::Zip).merged_preview(4).unwrap().is_none());
}
/// The preview size is the caller's: one over the decode limit is refused before allocating.
#[test]
fn a_preview_over_the_decode_limit_is_refused() {
let mut file = testgen::small(Version::Psb, Compression::Raw);
(file.header.width, file.header.height) = (300_000, 300_000);
assert!(matches!(file.merged_preview(300_000), Err(crate::error::PsdError::LimitExceeded(_))));
}
/// Grayscale is replicated to RGB; 16-bit keeps each sample's high byte; 32-bit linear
/// samples are sRGB-encoded (0.5 is 188).
#[test]
fn preview_converts_grayscale_and_deep_samples() {
use crate::header::{ColorMode, Header};
use crate::image_data::ImageData;
let make = |mode: ColorMode, depth: u16, channels: u16, plane: Vec<u8>| {
let mut file = testgen::small(Version::Psd, Compression::Raw);
file.layer_info = None;
file.global_blocks.clear();
file.header = Header { channels, width: 2, height: 1, depth, color_mode: mode, ..file.header.clone() };
let planes: Vec<u8> = (0..channels).flat_map(|_| plane.clone()).collect();
file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap();
let bytes = file.to_bytes().unwrap();
PsdFile::from_source(Source::new(bytes)).unwrap().merged_preview(16).unwrap().unwrap()
};
let gray = make(ColorMode::Grayscale, 8, 1, vec![10, 200]);
assert_eq!(gray.rgba, vec![10, 10, 10, 255, 200, 200, 200, 255]);
let deep = make(ColorMode::Rgb, 16, 3, vec![0x12, 0x34, 0xAB, 0xCD]);
assert_eq!(deep.rgba, vec![0x12, 0x12, 0x12, 255, 0xAB, 0xAB, 0xAB, 255]);
let float: Vec<u8> = [0.5f32, 1.0].iter().flat_map(|v| v.to_be_bytes()).collect();
let f = make(ColorMode::Rgb, 32, 3, float);
assert_eq!(f.rgba, vec![188, 188, 188, 255, 255, 255, 255, 255]);
}
}