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>
This commit is contained in:
authored and GitHub committed 2026-11-11 20:56:55 -05:00
1 parent 2bd8f292ca
commit f462bb8128
11 files changed
+386 -53

No files matched your search

+5 -4
View File
@@ -488,7 +488,7 @@ impl PsdFile {
let (v, widen) = self.write_form();
let psb = v.is_psb();
let len_field = |long: bool| if long { 8u64 } else { 4 };
let blocks_len = |blocks: &[TaggedBlock]| blocks.iter().fold(0u64, |n, b| n.saturating_add(b.encoded_len_aligned(v, 4) as u64));
let blocks_len = |blocks: &[TaggedBlock]| blocks.iter().fold(0u64, |n, b| n.saturating_add(b.encoded_len_aligned(v, 4)));
let body_len = |info: &LayerInfo| -> Result<u64> {
let unpadded = info.unpadded_len_as(v, widen)?;
let padding = info.padding.as_ref().map_or(unpadded % 2, |p| p.len() as u64);
@@ -1051,9 +1051,10 @@ mod write_tests {
let mut materialized = file.clone();
let mut data = Vec::new();
materialized.layer_info.take().unwrap().write_body(&mut data, version).unwrap();
materialized
.global_blocks
.insert(index.min(materialized.global_blocks.len()), TaggedBlock { signature: *b"8B64", key, data, padding });
materialized.global_blocks.insert(
index.min(materialized.global_blocks.len()),
TaggedBlock { signature: *b"8B64", key, data, padding, stored: None },
);
materialized.layer_info_placement = LayerInfoPlacement::Section;
assert_eq!(actual, materialized.to_bytes().unwrap(), "{version:?} {key:?} {index}");
}
+2 -2
View File
@@ -322,8 +322,8 @@ fn unpadded_len_matches_the_written_body() {
let mut rec = sample_record(version);
rec.mask = MaskData::Mask(mask);
rec.name = vec![b'n'; 300];
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"lsct", data: vec![1; 5], padding: Some(vec![0; 3]) });
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"Lr16", data: vec![2; 7], padding: None });
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"lsct", data: vec![1; 5], padding: Some(vec![0; 3]), stored: None });
rec.blocks.push(TaggedBlock { signature: *b"8BIM", key: *b"Lr16", data: vec![2; 7], padding: None, stored: None });
rec.extra_trailing = vec![9; 2];
layers.push(rec);
}
+45 -2
View File
@@ -154,6 +154,31 @@ impl PsdFile {
source: Source,
want: impl FnOnce(&PsdFile, Option<Compression>, u64) -> Result<bool>,
recover: impl FnOnce(&PsdFile) -> bool,
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
Self::from_source_deferring_info(source, |_, _| false, want, recover)
}
/// [`Self::from_source_and_merged_preflight`] that also leaves the data of each global block
/// `defer(key, length)` accepts in the source ([`TaggedBlock::stored`]): an embedded
/// smart-object block (`lnk2`) can be most of a file. Defer only blocks the reader loads
/// before use (the linked-file blocks `lnk2`/`lnk3`/`lnkD`): a deferred block's `data` is
/// empty, and [`TaggedBlock::parsed`] refuses it.
pub fn from_source_deferring(
source: Source,
defer: impl Fn(&[u8; 4], u64) -> bool,
want: impl FnOnce(&PsdFile) -> Result<bool>,
recover: impl FnOnce(&PsdFile) -> bool,
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
Self::from_source_deferring_info(source, defer, |file, _, _| want(file), recover)
}
/// [`Self::from_source_deferring`] whose preflight also sees the merged image's compression
/// and encoded length, as [`Self::from_source_and_merged_preflight_info`] gives them.
pub fn from_source_deferring_info(
source: Source,
defer: impl Fn(&[u8; 4], u64) -> bool,
want: impl FnOnce(&PsdFile, Option<Compression>, u64) -> Result<bool>,
recover: impl FnOnce(&PsdFile) -> bool,
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
let mut c = Cursor::new(&source);
let header = Header::read(&mut Reader::new(&c.bytes(26)?))?;
@@ -221,6 +246,7 @@ impl PsdFile {
layer_info_placement = LayerInfoPlacement::GlobalBlock { index, signature, key, padding: padding.clone() };
Ok(Some(()))
},
&defer,
&mut global_blocks,
)?;
layer_mask_trailing = trailing;
@@ -262,6 +288,7 @@ fn read_blocks<'a>(
r: &mut Cursor<'a>,
version: Version,
mut consume: impl FnMut(usize, [u8; 4], [u8; 4], Cursor<'a>, &Option<Vec<u8>>) -> Result<Option<()>>,
defer: &dyn Fn(&[u8; 4], u64) -> bool,
blocks: &mut Vec<TaggedBlock>,
) -> Result<Vec<u8>> {
const ALIGNMENT: usize = 4;
@@ -291,8 +318,13 @@ fn read_blocks<'a>(
let padding = if pad_bytes.len() == default && pad_bytes.iter().all(|&b| b == 0) { None } else { Some(pad_bytes) };
let (start, end) = (data.pos, data.end);
if consume(blocks.len(), signature, key, data, &padding)?.is_none() {
let data = Cursor { src: r.src, pos: start, end }.rest()?;
blocks.push(TaggedBlock { signature, key, data, padding });
if defer(&key, end - start) {
let stored = Stored { source: r.src.clone(), offset: start, len: end - start };
blocks.push(TaggedBlock { signature, key, data: Vec::new(), padding, stored: Some(stored) });
} else {
let data = Cursor { src: r.src, pos: start, end }.rest()?;
blocks.push(TaggedBlock { signature, key, data, padding, stored: None });
}
}
}
}
@@ -436,6 +468,17 @@ mod tests {
assert!(lazy.to_bytes().unwrap() == written, "the lazily read PSD writes another PSB");
}
#[test]
fn deferred_blocks_read_back_the_same() {
for bytes in samples() {
let eager = PsdFile::from_bytes(&bytes).unwrap();
let (lazy, _) = PsdFile::from_source_deferring(Source::new(bytes.clone()), |_, _| true, |_| Ok(false), |_| false).unwrap();
assert!(lazy.global_blocks.iter().all(|b| b.data.is_empty() && b.stored.is_some()));
assert_eq!(lazy, eager);
assert_eq!(lazy.to_bytes().unwrap(), bytes);
}
}
#[test]
fn lazy_parse_fails_where_the_eager_one_does() {
for bytes in samples() {
+2
View File
@@ -82,6 +82,7 @@ pub mod metadata;
pub mod path;
pub mod patterns;
pub mod pixels;
pub mod preview;
pub mod resources;
pub mod rows;
pub mod shc;
@@ -109,6 +110,7 @@ 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))]
+158
View File
@@ -0,0 +1,158 @@
//! 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]);
}
}
+37 -9
View File
@@ -76,26 +76,54 @@ impl ChannelData {
/// RLE count table, the whole of a ZIP stream); a damaged row fails when its band is decoded.
pub fn row_decoder(&self, width: usize, height: usize, depth: u16, version: Version) -> Result<RowDecoder<'_>> {
let layout = PlaneLayout { planes: 1, width, height, depth, version };
let total = layout.decoded_len()?;
let row_bytes = layout.row_bytes();
let bytes = match &self.stored {
Some(s) => Bytes::Source(s),
None => Bytes::Memory(&self.data),
};
let kind = match self.compression {
RowDecoder::new(self.compression, bytes, &layout, || self.encoded())
}
}
impl crate::image_data::ImageData {
/// A decoder of the merged image's rows: its planes one after another, so plane `p`'s row
/// `r` is row `p × height + r`. As [`ChannelData::row_decoder`]; only a ZIP merged image is
/// decoded whole (and so only one within `MAX_DECODED_BYTES`).
pub fn row_decoder(&self, header: &crate::header::Header) -> Result<RowDecoder<'_>> {
let layout = Self::layout(header);
let bytes = match &self.stored {
Some(s) => Bytes::Source(s),
None => Bytes::Memory(&self.data),
};
RowDecoder::new(Some(self.compression), bytes, &layout, || self.encoded())
}
}
impl<'a> RowDecoder<'a> {
fn new(
compression: Option<Compression>,
bytes: Bytes<'a>,
layout: &PlaneLayout,
encoded: impl FnOnce() -> Result<Cow<'a, [u8]>>,
) -> Result<Self> {
// The whole-plane cap applies to a whole decode (ZIP) only: rows are decoded a band at a
// time, each band within the cap.
let total = layout.total_bytes()?;
let row_bytes = layout.row_bytes();
let height = layout.rows()?;
let kind = match compression {
None if total == 0 => Kind::Empty,
None => return Err(PsdError::invalid("channel has no data")),
Some(Compression::Raw) => {
if bytes.len() < total as u64 {
if bytes.len() < total {
return Err(PsdError::UnexpectedEof {
offset: usize::try_from(bytes.len()).unwrap_or(usize::MAX),
needed: total - bytes.len() as usize,
needed: usize::try_from(total - bytes.len()).unwrap_or(usize::MAX),
});
}
Kind::Raw(bytes)
}
Some(Compression::Rle) => {
let size: u64 = if version.is_psb() { 4 } else { 2 };
let size: u64 = if layout.version.is_psb() { 4 } else { 2 };
let table_len = (height as u64).saturating_mul(size);
if table_len > bytes.len() {
return Err(PsdError::UnexpectedEof { offset: 0, needed: usize::try_from(table_len - bytes.len()).unwrap_or(usize::MAX) });
@@ -116,7 +144,7 @@ impl ChannelData {
}
let data_len = bytes.len() - table_len;
// As `decode_planes` checks: PackBits expands at most 64x.
if (total as u64) > data_len.saturating_mul(64) {
if total > data_len.saturating_mul(64) {
return Err(PsdError::Decompress("RLE data too short for declared size".into()));
}
if at > bytes.len() {
@@ -127,7 +155,7 @@ impl ChannelData {
}
Kind::Rle { bytes, offsets }
}
Some(c) => Kind::Decoded(decode_planes(c, &self.encoded()?, &layout)?),
Some(c) => Kind::Decoded(decode_planes(c, &encoded()?, layout)?),
};
Ok(RowDecoder { kind, row_bytes, height })
}
@@ -157,7 +185,7 @@ impl RowDecoder<'_> {
out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded channel data"))?;
match &self.kind {
Kind::Empty => {}
Kind::Raw(bytes) => out.extend_from_slice(&bytes.get((r0 * self.row_bytes) as u64, n as u64)?),
Kind::Raw(bytes) => out.extend_from_slice(&bytes.get(r0 as u64 * self.row_bytes as u64, n as u64)?),
Kind::Decoded(plane) => out
.extend_from_slice(plane.get(r0 * self.row_bytes..r1 * self.row_bytes).ok_or_else(|| PsdError::invalid("decoded plane too short"))?),
Kind::Rle { bytes, offsets } => {
+40 -17
View File
@@ -16,6 +16,7 @@
//! The blocks themselves stay raw in the model ([`crate::TaggedBlock`]) and write back
//! unchanged; these are read-only views. Came from EffectCraft's `effectcraft-psd`.
use std::borrow::Cow;
use std::ops::Range;
use crate::descriptor::{Descriptor, UnicodeString, Value};
@@ -23,7 +24,6 @@ use crate::error::{PsdError, Result};
use crate::file::PsdFile;
use crate::io::{Reader, read_pascal};
use crate::layer::LayerRecord;
use crate::tagged::TaggedBlock;
/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes
/// from the file's transform, which is untrusted.
@@ -458,19 +458,29 @@ fn linked_file(item: &[u8], at: usize) -> Result<LinkedFile> {
}
impl PsdFile {
/// Every linked file of the document's linked layer blocks, each with the block that holds
/// it ([`LinkedFile::data`] is a range of that block's data).
pub fn linked_files(&self) -> Vec<(&TaggedBlock, LinkedFile)> {
self.global_blocks
.iter()
.filter(|b| is_linked_layer_key(&b.key))
.flat_map(|b| linked_files(&b.data).into_iter().map(move |f| (b, f)))
.collect()
/// Every linked file of the document's linked layer blocks, each with the index of the
/// global block that holds it ([`LinkedFile::data`] is a range of that block's data). A
/// block left in the source ([`crate::TaggedBlock::stored`]) is read for it.
pub fn linked_files(&self) -> Result<Vec<(usize, LinkedFile)>> {
let mut out = Vec::new();
for (i, b) in self.global_blocks.iter().enumerate().filter(|(_, b)| is_linked_layer_key(&b.key)) {
out.extend(linked_files(&b.encoded()?).into_iter().map(|f| (i, f)));
}
Ok(out)
}
/// The embedded bytes of the linked file `uuid`, when the document embeds it.
pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> {
self.linked_files().into_iter().find_map(|(b, f)| if f.uuid == uuid { b.data.get(f.data?) } else { None })
/// The embedded bytes of the linked file `uuid`, when the document embeds it: borrowed from
/// a block in memory, read from the source for a block left there.
pub fn linked_data(&self, uuid: &str) -> Result<Option<Cow<'_, [u8]>>> {
for b in self.global_blocks.iter().filter(|b| is_linked_layer_key(&b.key)) {
let data = b.encoded()?;
let Some(range) = linked_files(&data).into_iter().find(|f| f.uuid == uuid).and_then(|f| f.data) else { continue };
return Ok(match data {
Cow::Borrowed(d) => d.get(range).map(Cow::Borrowed),
Cow::Owned(d) => d.get(range).map(|e| Cow::Owned(e.to_vec())),
});
}
Ok(None)
}
}
@@ -479,6 +489,7 @@ mod tests {
use super::*;
use crate::descriptor::{Id, ObjectArray};
use crate::io::{WriteExt, write_pascal};
use crate::tagged::TaggedBlock;
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
SmartObject { uuid: "x".into(), quad, size: None, warp }
@@ -651,15 +662,27 @@ mod tests {
#[test]
fn a_document_finds_the_embedded_bytes_of_a_smart_object() {
let mut file = crate::testgen::small(crate::Version::Psd, crate::Compression::Raw);
let first = file.global_blocks.len();
file.global_blocks.push(TaggedBlock::new(*b"lnk2", linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA")));
file.global_blocks.push(TaggedBlock::new(*b"lnkE", linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b"")));
assert_eq!(file.linked_files().len(), 2);
assert_eq!(file.linked_data("uuid-1"), Some(b"PNGDATA".as_slice()));
assert_eq!(file.linked_data("uuid-2"), None);
assert_eq!(file.linked_data("nope"), None);
let embedded = |f: &PsdFile, uuid: &str| f.linked_data(uuid).unwrap().map(Cow::into_owned);
assert_eq!(
file.linked_files().unwrap().iter().map(|(i, f)| (*i, f.uuid.as_str())).collect::<Vec<_>>(),
[(first, "uuid-1"), (first + 1, "uuid-2")]
);
assert_eq!(embedded(&file, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
assert_eq!(embedded(&file, "uuid-2"), None);
assert_eq!(embedded(&file, "nope"), None);
// The blocks are untouched: the file still writes and reads back.
let again = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap();
assert_eq!(again.linked_data("uuid-1"), Some(b"PNGDATA".as_slice()));
assert_eq!(embedded(&again, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
// With the linked-file blocks left in the source, the same files and bytes are found.
let bytes = file.to_bytes().unwrap();
let (deferred, _) =
PsdFile::from_source_deferring(crate::Source::new(bytes), |key, _| is_linked_layer_key(key), |_| Ok(false), |_| false).unwrap();
assert!(deferred.global_blocks.iter().filter(|b| is_linked_layer_key(&b.key)).all(|b| b.data.is_empty() && b.stored.is_some()));
assert_eq!(deferred.linked_files().unwrap(), file.linked_files().unwrap());
assert_eq!(embedded(&deferred, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
}
#[test]
+18 -2
View File
@@ -84,6 +84,10 @@ const MAX_READ: usize = 64 << 20;
pub struct FileSource {
file: std::fs::File,
len: u64,
/// Modification time when opened: a read after the file changed in place fails rather than
/// mixing old offsets with new bytes. (A save that replaces the file by renaming leaves this
/// open file, the old one, unchanged.)
modified: Option<std::time::SystemTime>,
}
#[cfg(any(unix, windows))]
@@ -95,8 +99,8 @@ impl FileSource {
/// Wraps an open file.
pub fn new(file: std::fs::File) -> std::io::Result<Self> {
let len = file.metadata()?.len();
Ok(FileSource { file, len })
let meta = file.metadata()?;
Ok(FileSource { file, len: meta.len(), modified: meta.modified().ok() })
}
/// The file.
@@ -117,6 +121,10 @@ impl ReadAt for FileSource {
}
let n = buf.len();
let failed = |e: std::io::Error| PsdError::Read(format!("reading {n} bytes at offset {offset}: {e}"));
let meta = self.file.metadata().map_err(failed)?;
if meta.len() != self.len || meta.modified().ok() != self.modified {
return Err(PsdError::Read("the file changed after it was opened".into()));
}
// Bounded reads, in chunks: some Windows file
// systems reject a single read of gigabytes (photocraft#375).
let mut done = 0usize;
@@ -206,6 +214,11 @@ pub struct Stored {
}
impl Stored {
/// Whether `other` is the same bytes of the same source (a clone of it).
pub fn same_range(&self, other: &Stored) -> bool {
std::ptr::addr_eq(Arc::as_ptr(&self.source.0), Arc::as_ptr(&other.source.0)) && self.offset == other.offset && self.len == other.len
}
/// Reads the bytes.
pub fn read(&self) -> Result<Vec<u8>> {
self.source.read_vec(self.offset, self.len)
@@ -265,6 +278,9 @@ mod tests {
f.read_at(2, &mut b).unwrap();
assert_eq!(b, [7, 6, 5]);
assert!(f.read_at(3, &mut b).is_err());
// Changed in place after opening: reads fail instead of returning other bytes.
std::fs::OpenOptions::new().append(true).open(&path).unwrap().write_all(&[1]).unwrap();
assert!(f.read_at(0, &mut b).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+76 -14
View File
@@ -28,7 +28,11 @@ pub fn uses_long_length(version: Version, key: &[u8; 4]) -> bool {
}
/// A tagged block, stored raw.
#[derive(Debug, Clone, PartialEq, Eq)]
///
/// A file parsed by [`PsdFile::from_source_deferring`](crate::PsdFile::from_source_deferring) may
/// leave a large block's data in the source (`stored`, with `data` empty); equality compares the
/// bytes wherever they are.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TaggedBlock {
/// `8BIM` or `8B64`.
@@ -42,8 +46,36 @@ pub struct TaggedBlock {
/// layer blocks. Parsing yields `None` whenever
/// the stored padding equals the default, so the model stays canonical.
pub padding: Option<Vec<u8>>,
/// Where the data is when it was left in the source. (Not serialized: load it first.)
#[cfg_attr(feature = "serde", serde(skip))]
pub stored: Option<crate::source::Stored>,
}
impl PartialEq for TaggedBlock {
fn eq(&self, other: &Self) -> bool {
if self.signature != other.signature
|| self.key != other.key
|| self.padding != other.padding
|| self.encoded_data_len() != other.encoded_data_len()
{
return false;
}
// The same bytes of the same source (a clone): equal without reading them, even when
// the source can no longer be read.
if let (Some(a), Some(b)) = (&self.stored, &other.stored)
&& a.same_range(b)
{
return true;
}
match (self.encoded(), other.encoded()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
}
impl Eq for TaggedBlock {}
/// Layer section divider type (`lsct`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -138,7 +170,7 @@ pub enum BlockData<'a> {
impl TaggedBlock {
/// A new `8BIM` block with default padding.
pub fn new(key: [u8; 4], data: Vec<u8>) -> Self {
TaggedBlock { signature: *b"8BIM", key, data, padding: None }
TaggedBlock { signature: *b"8BIM", key, data, padding: None, stored: None }
}
/// `true` for `MIB8` / `46B8`: the signatures [`crate::tiff`] gives a block whose data was
@@ -175,6 +207,11 @@ impl TaggedBlock {
/// Typed view for known keys. `None` for keys without a typed parser,
/// `Some(Err)` if a known key's data is malformed.
pub fn parsed(&self) -> Option<Result<BlockData<'_>>> {
// Its views borrow `data`: a block still in the source must be loaded first, not read
// as empty.
if self.stored.is_some() {
return Some(Err(PsdError::invalid(format!("{}: block data is still in the source; load it first", self.key_str()))));
}
let d = &self.data[..];
let mut r = Reader::new(d);
Some(match &self.key {
@@ -282,7 +319,9 @@ impl TaggedBlock {
/// ("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[..];
// A block left in the source (an embedded smart-object file) is checked from there.
let data = self.encoded()?;
let d = &data[..];
let mut r = Reader::new(d);
match &self.key {
b"PlLd" => {
@@ -333,7 +372,26 @@ impl TaggedBlock {
}
pub(crate) fn write(&self, out: &mut Vec<u8>, version: Version, alignment: usize) -> Result<()> {
Self::write_parts(out, self.signature, self.key, &self.data, self.padding.as_deref(), version, alignment)
Self::write_parts(out, self.signature, self.key, &self.encoded()?, self.padding.as_deref(), version, alignment)
}
/// The data: borrowed from `data`, or read from the source when it was left there.
pub fn encoded(&self) -> Result<std::borrow::Cow<'_, [u8]>> {
crate::source::encoded(&self.data, self.stored.as_ref())
}
/// Length of the data, wherever it is.
pub fn encoded_data_len(&self) -> u64 {
self.stored.as_ref().map_or(self.data.len() as u64, |s| s.len)
}
/// Reads data left in the source into `data`.
pub fn load(&mut self) -> Result<()> {
if let Some(s) = &self.stored {
self.data = s.read()?;
self.stored = None;
}
Ok(())
}
/// Writes a block from its parts (no owned [`TaggedBlock`] needed). `padding: None` writes
@@ -360,14 +418,16 @@ impl TaggedBlock {
/// Serialized size in bytes as a layer block (two-byte alignment).
pub fn encoded_len(&self, version: Version) -> usize {
self.encoded_len_aligned(version, 2)
usize::try_from(self.encoded_len_aligned(version, 2)).unwrap_or(usize::MAX)
}
/// Serialized size in bytes as [`Self::write`] writes it with `alignment`.
pub(crate) fn encoded_len_aligned(&self, version: Version, alignment: usize) -> usize {
let l = if uses_long_length(version, &self.key) { 8 } else { 4 };
let p = self.padding.as_ref().map_or(Self::default_padding(self.data.len(), alignment), Vec::len);
(8 + l + p).saturating_add(self.data.len())
/// Serialized size in bytes as [`Self::write`] writes it with `alignment`. In `u64`: a
/// deferred PSB block can be longer than a 32-bit `usize`.
pub(crate) fn encoded_len_aligned(&self, version: Version, alignment: usize) -> u64 {
let l: u64 = if uses_long_length(version, &self.key) { 8 } else { 4 };
let n = self.encoded_data_len();
let p = self.padding.as_ref().map_or(Self::default_padding((n % alignment as u64) as usize, alignment), Vec::len);
(8 + l + p as u64).saturating_add(n)
}
}
@@ -499,7 +559,7 @@ pub(crate) fn read_blocks_with(
Some(pad_bytes)
};
if !consume(blocks.len(), signature, key, data, &padding) {
blocks.push(TaggedBlock { signature, key, data: data.to_vec(), padding });
blocks.push(TaggedBlock { signature, key, data: data.to_vec(), padding, stored: None });
}
}
}
@@ -523,12 +583,14 @@ pub(crate) fn write_blocks_to<W: std::io::Write + std::io::Seek + ?Sized>(
out.small(|o| {
o.put(&b.signature);
o.put(&b.key);
o.put_len(b.data.len() as u64, uses_long_length(version, &b.key))
o.put_len(b.encoded_data_len(), uses_long_length(version, &b.key))
})?;
out.put(&b.data)?;
// A block left in the source is copied from it piece by piece.
out.put_encoded(&b.data, b.stored.as_ref())?;
match &b.padding {
Some(p) => out.put(p)?,
None => out.zeros(TaggedBlock::default_padding(b.data.len(), alignment))?,
// From the length's remainder: a PSB block can be longer than a 32-bit `usize`.
None => out.zeros(TaggedBlock::default_padding((b.encoded_data_len() % alignment as u64) as usize, alignment))?,
}
}
Ok(())
+2 -2
View File
@@ -185,7 +185,7 @@ impl ImageSourceData {
let pad = TaggedBlock::default_padding(m.data.len(), 4);
len = len.saturating_add(8 + len_field(b"LMsk")).saturating_add(m.data.len() as u64).saturating_add(pad as u64);
}
len = self.global_blocks.iter().fold(len, |n, b| n.saturating_add(b.encoded_len_aligned(version, 4) as u64));
len = self.global_blocks.iter().fold(len, |n, b| n.saturating_add(b.encoded_len_aligned(version, 4)));
len = len.saturating_add(self.trailing.len() as u64);
let no_memory = || PsdError::OutOfMemory("not enough memory for the layer data");
let mut body = Vec::new();
@@ -290,7 +290,7 @@ impl ImageSourceData {
#[cfg(test)]
fn padded_block(key: [u8; 4], data: Vec<u8>) -> TaggedBlock {
let pad = (4 - data.len() % 4) % 4;
TaggedBlock { signature: *b"8BIM", key, data, padding: Some(vec![0; pad]) }
TaggedBlock { signature: *b"8BIM", key, data, padding: Some(vec![0; pad]), stored: None }
}
/// Parses a 34377 tag (image resources). Little-endian resource blocks (`MIB8`) are converted;
+1 -1
View File
@@ -906,7 +906,7 @@ fn psb_foreign_order_long_key_roundtrips() {
let d = ImageSourceData {
version: Version::Psb,
byte_order: ByteOrder::Little,
global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }],
global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None, stored: None }],
..Default::default()
};
let (little, w) = d.to_bytes(ByteOrder::Little).unwrap();