mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* 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>
159 lines
7.2 KiB
Rust
159 lines
7.2 KiB
Rust
//! 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]);
|
||
}
|
||
}
|