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

287 lines
9.7 KiB
Rust

//! Random-access byte sources, for parsing a file without reading all of it.
//!
//! [`PsdFile::from_source`](crate::PsdFile::from_source) reads the structure of a file (header,
//! resources, layer records, tagged blocks) and leaves the bulk of it, the encoded channel data
//! and the merged image, where it is: each [`ChannelData`](crate::ChannelData) records a
//! [`Stored`] range instead of a copy, and the bytes are read when the channel is decoded. A
//! 2 GB layered file then never needs 2 GB of memory just to be opened.
use std::borrow::Cow;
use std::fmt;
use std::sync::Arc;
use crate::error::{PsdError, Result};
/// Bytes that can be read at any offset, from any thread.
///
/// Implemented for byte buffers and, on native targets, for [`std::fs::File`] (as
/// [`FileSource`]). The source must not change while a file parsed from it is in use: a
/// [`Stored`] range is an offset into it.
pub trait ReadAt: Send + Sync {
/// Total length in bytes.
fn len(&self) -> u64;
/// `true` when the source holds no bytes.
fn is_empty(&self) -> bool {
self.len() == 0
}
/// Fills `buf` with the bytes at `offset..offset + buf.len()`. Fails, without a partial
/// result, when the range is not all inside the source or the read fails.
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()>;
}
fn out_of_range(offset: u64, needed: usize, len: u64) -> PsdError {
PsdError::UnexpectedEof {
offset: usize::try_from(offset).unwrap_or(usize::MAX),
needed: needed.saturating_sub(usize::try_from(len.saturating_sub(offset)).unwrap_or(usize::MAX)),
}
}
impl ReadAt for [u8] {
fn len(&self) -> u64 {
<[u8]>::len(self) as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
let src = usize::try_from(offset)
.ok()
.and_then(|start| self.get(start..start.checked_add(buf.len())?))
.ok_or_else(|| out_of_range(offset, buf.len(), ReadAt::len(self)))?;
buf.copy_from_slice(src);
Ok(())
}
}
impl ReadAt for Vec<u8> {
fn len(&self) -> u64 {
self.as_slice().len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
self.as_slice().read_at(offset, buf)
}
}
impl<T: ReadAt + ?Sized> ReadAt for Arc<T> {
fn len(&self) -> u64 {
(**self).len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
(**self).read_at(offset, buf)
}
}
/// Largest single read from a [`FileSource`].
#[cfg(any(unix, windows))]
const MAX_READ: usize = 64 << 20;
/// An open file read at offsets (`pread` on Unix, `seek_read` on Windows), with its length taken
/// when it was opened. Reads from several threads at once need no lock.
#[cfg(any(unix, windows))]
#[derive(Debug)]
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))]
impl FileSource {
/// Opens `path` for reading.
pub fn open(path: &std::path::Path) -> std::io::Result<Self> {
Self::new(std::fs::File::open(path)?)
}
/// Wraps an open file.
pub fn new(file: std::fs::File) -> std::io::Result<Self> {
let meta = file.metadata()?;
Ok(FileSource { file, len: meta.len(), modified: meta.modified().ok() })
}
/// The file.
pub fn file(&self) -> &std::fs::File {
&self.file
}
}
#[cfg(any(unix, windows))]
impl ReadAt for FileSource {
fn len(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<()> {
if offset.checked_add(buf.len() as u64).is_none_or(|end| end > self.len) {
return Err(out_of_range(offset, buf.len(), self.len));
}
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;
while done < n {
let Some(rest) = buf.get_mut(done..n.min(done + MAX_READ)) else { break };
let at = offset + done as u64;
#[cfg(unix)]
let read = {
use std::os::unix::fs::FileExt;
self.file.read_at(rest, at)
};
#[cfg(windows)]
let read = {
use std::os::windows::fs::FileExt;
self.file.seek_read(rest, at)
};
match read {
Ok(0) => return Err(failed(std::io::ErrorKind::UnexpectedEof.into())),
Ok(k) => done += k,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(failed(e)),
}
}
Ok(())
}
}
/// A shared [`ReadAt`]: what [`Stored`] ranges point into. Two handles are equal when they are
/// the same source.
#[derive(Clone)]
pub struct Source(Arc<dyn ReadAt>);
impl Source {
/// Shares `source`.
pub fn new(source: impl ReadAt + 'static) -> Self {
Source(Arc::new(source))
}
/// Shares an already shared source.
pub fn from_arc(source: Arc<dyn ReadAt>) -> Self {
Source(source)
}
/// The source.
pub fn get(&self) -> &dyn ReadAt {
&*self.0
}
/// Reads `len` bytes at `offset` into a new buffer. The buffer is reserved fallibly: a
/// range larger than memory is an error, not an abort.
pub fn read_vec(&self, offset: u64, len: u64) -> Result<Vec<u8>> {
let n = usize::try_from(len).map_err(|_| PsdError::LimitExceeded("length exceeds address space"))?;
if offset.checked_add(len).is_none_or(|end| end > self.0.len()) {
return Err(out_of_range(offset, n, self.0.len()));
}
let mut out = Vec::new();
out.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory to read the data"))?;
out.resize(n, 0);
self.0.read_at(offset, &mut out)?;
Ok(out)
}
}
impl fmt::Debug for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Source({} bytes)", self.0.len())
}
}
impl PartialEq for Source {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for Source {}
/// Encoded bytes left in their [`Source`] by a lazy parse: `len` bytes at `offset`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stored {
/// Where the bytes are.
pub source: Source,
/// Offset of the first byte.
pub offset: u64,
/// Number of bytes.
pub len: u64,
}
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)
}
/// Reads `len` bytes starting `at` bytes into the range.
pub fn read_part(&self, at: u64, len: u64) -> Result<Vec<u8>> {
if at.checked_add(len).is_none_or(|end| end > self.len) {
return Err(PsdError::invalid("read past the end of stored data"));
}
self.source.read_vec(self.offset + at, len)
}
}
/// The bytes of a value that is either in memory (`data`) or still in its source (`stored`).
pub(crate) fn encoded<'a>(data: &'a [u8], stored: Option<&Stored>) -> Result<Cow<'a, [u8]>> {
match stored {
Some(s) => s.read().map(Cow::Owned),
None => Ok(Cow::Borrowed(data)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slice_reads_are_bounds_checked() {
let v = vec![1u8, 2, 3, 4];
let mut b = [0u8; 2];
v.read_at(2, &mut b).unwrap();
assert_eq!(b, [3, 4]);
assert!(v.read_at(3, &mut b).is_err());
assert!(v.read_at(u64::MAX, &mut b).is_err());
let s = Source::new(v);
assert_eq!(s.read_vec(1, 2).unwrap(), vec![2, 3]);
assert!(s.read_vec(1, u64::MAX).is_err());
let st = Stored { source: s.clone(), offset: 1, len: 3 };
assert_eq!(st.read().unwrap(), vec![2, 3, 4]);
assert_eq!(st.read_part(1, 2).unwrap(), vec![3, 4]);
assert!(st.read_part(2, 2).is_err());
assert_eq!(st.source, s);
assert_ne!(st.source, Source::new(vec![1u8, 2, 3, 4]));
}
#[cfg(any(unix, windows))]
#[test]
fn file_source_reads_at_offsets() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("craft-psd-source-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.bin");
std::fs::File::create(&path).unwrap().write_all(&[9, 8, 7, 6, 5]).unwrap();
let f = FileSource::open(&path).unwrap();
assert_eq!(ReadAt::len(&f), 5);
let mut b = [0u8; 3];
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();
}
}