craft-psd: float pixels, Lab composites and salvaging a damaged layer section (#1006)

* 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: float pixels, Lab composites and salvaging a damaged layer section

From EffectCraft's effectcraft-psd (storytold/effectcraft f334bcf3,
crates/psd/src/pixels.rs, limits.rs and placed.rs):

- `float`: `PsdFile::composite_f32` and `layer_f32` decode to straight
  RGBA f32 in every colour mode and depth: CMYK naively, indexed through
  the palette, Lab through D50 to sRGB, 32-bit encoded to sRGB, a layer's
  raster mask applied and a solid colour fill layer filled. One image is at
  most 2^28 pixels and a decode refuses before allocating when what it
  holds at once is over its budget (4 GiB; `_within` takes a limit).
  `SmartObject::render_placed` bakes a warped or perspective placement,
  bounded in pixels and in rasterising work.
- `composite_rgba8` and `Layer::rgba8` read Lab.
- `PsdFile::from_bytes_salvaging`: a file whose layer and mask section
  cannot be read opens as its merged image, with the error it skipped.

* 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.

---------

Co-authored-by: Omar Saidahmed <osaidahm@uwaterloo.ca>
This commit is contained in:
Dylan M. TaylorandOmar Saidahmed authored and GitHub committed 2026-11-11 20:27:10 -05:00
1 parent 8693b9181c
commit 417680b824
5 files changed
+893 -3

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ A standalone reader and writer for Adobe Photoshop **PSD** (version 1) and **PSB
PsdFile::from_bytes(&[u8]) -> Result<PsdFile>
PsdFile::from_bytes_and_merged(&[u8], want) -> Result<(PsdFile, Option<Result<Vec<u8>>>)>
// + decode_merged() when want(&file): ZIP inflated once
PsdFile::from_bytes_salvaging(&[u8]) -> Result<(PsdFile, Option<PsdError>)> // damaged layers: flattened, with the skipped error
PsdFile::composite_f32() / layer_f32(i, canvas) -> Result<FloatImage> // straight RGBA f32, every mode and depth, within a budget
PsdFile::from_source(Source) -> Result<PsdFile> // channel data and merged image left in the source
PsdFile::from_source_and_merged_recovering(Source, want, recover)
PsdFile::to_bytes(&self) -> Result<Vec<u8>>
+76
View File
@@ -114,6 +114,47 @@ impl PsdFile {
Self::from_bytes_and_merged(data, |_| false).map(|(file, _)| file)
}
/// Parses like [`Self::from_bytes`], but a file whose layer and mask section cannot be read
/// still opens as its merged image: the file without layers, global blocks or global mask,
/// with the error that was skipped. For readers that show a damaged document flattened rather
/// than not at all. A damaged header, colour mode data, image resources or merged image is
/// still an error (the first one found).
///
/// Writing a salvaged file writes a flattened document: the unreadable layers are not in it.
pub fn from_bytes_salvaging(data: &[u8]) -> Result<(Self, Option<PsdError>)> {
let error = match Self::from_bytes(data) {
Ok(file) => return Ok((file, None)),
Err(e) => e,
};
let mut r = Reader::new(data);
let flattened = (|| -> Result<Self> {
let header = Header::read(&mut r)?;
let cmd_len = r.u32()?;
let color_mode_data = r.bytes_u64(u64::from(cmd_len))?.to_vec();
let resources = resources::read_section(&mut r)?;
let lm_len = r.len_field(header.version.is_psb())?;
r.bytes_u64(lm_len)?;
let mut file = PsdFile {
header,
color_mode_data,
resources,
layer_info: None,
layer_info_placement: LayerInfoPlacement::Section,
global_layer_mask: None,
global_blocks: Vec::new(),
layer_mask_trailing: Vec::new(),
image_data: ImageData::new(Compression::Raw, Vec::new()),
write_as_psb: false,
};
file.read_merged_section(&mut r, false, |_| false)?;
Ok(file)
})();
match flattened {
Ok(file) => Ok((file, Some(error))),
Err(_) => Err(error),
}
}
/// Parses like [`Self::from_bytes`] and, when `want` asks for it, decodes the merged image
/// (as [`Self::decode_merged`] would) in the same pass. `want` sees the file with everything
/// but the merged image parsed (its `image_data` is still empty). A ZIP merged image is then
@@ -650,6 +691,41 @@ impl PsdFile {
#[cfg(test)]
mod write_tests {
use super::*;
/// The first `8BIM` + blend key of a layer record, after the header.
fn first_layer_signature(bytes: &[u8]) -> usize {
bytes.windows(8).position(|w| w == b"8BIMnorm").unwrap()
}
#[test]
fn salvaging_keeps_the_merged_image_of_a_file_whose_layers_are_damaged() {
let mut b = crate::PsdBuilder::new(3, 2);
b.push_layer(crate::LayerSpec::new("l", 0, 0, 3, 2, crate::PixelData::Rgba8(vec![7; 24])));
b.composite(crate::PixelData::Rgba8(vec![200; 24]));
let good = b.to_bytes().unwrap();
let (file, skipped) = PsdFile::from_bytes_salvaging(&good).unwrap();
assert!(skipped.is_none());
assert_eq!(file, PsdFile::from_bytes(&good).unwrap());
let mut bad = good.clone();
let at = first_layer_signature(&bad);
bad[at] = b'X';
assert!(PsdFile::from_bytes(&bad).is_err());
let (file, skipped) = PsdFile::from_bytes_salvaging(&bad).unwrap();
assert!(skipped.is_some());
assert!(file.layers().is_empty() && file.global_blocks.is_empty());
assert_eq!(file.composite_rgba8().unwrap(), PsdFile::from_bytes(&good).unwrap().composite_rgba8().unwrap());
// What it writes is a flattened document that opens normally.
let flat = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap();
assert!(flat.layers().is_empty());
assert_eq!(flat.decode_merged().unwrap(), file.decode_merged().unwrap());
// Damage outside the layer section is not salvaged: a cut merged image, a bad header.
assert!(PsdFile::from_bytes_salvaging(&bad[..bad.len() - 3]).is_err());
let mut header = good;
header[0] = b'X';
assert!(PsdFile::from_bytes_salvaging(&header).is_err());
}
use crate::{Compression, testgen};
use std::io::{Seek, Write};
+793
View File
@@ -0,0 +1,793 @@
//! Float pixels: the merged image and single layers as straight RGBA `f32` in 0..1, for
//! readers that composite in floating point.
//!
//! Every colour mode and depth comes out display-encoded RGB: CMYK is converted naively
//! (`channel × black`, without a profile), indexed colour goes through the palette, Lab through
//! D50 to sRGB, and 32-bit documents, which are linear, are encoded to sRGB. A layer's raster
//! mask is applied to its alpha, and a solid colour fill layer without pixels is filled.
//!
//! Sizes read from a file are untrusted. One image holds at most [`MAX_DECODE_PIXELS`], and a
//! decode works out everything it will hold at once (the result, the CMYK black plane, the
//! decoded planes, the mask) and refuses with [`PsdError::LimitExceeded`] before allocating
//! when that is over its budget ([`DECODE_BUDGET`], or the limit passed to the `_within`
//! functions). Came from EffectCraft's `effectcraft-psd`.
use crate::error::{PsdError, Result};
use crate::file::PsdFile;
use crate::header::{ColorMode, row_bytes};
use crate::layer::{CHANNEL_TRANSPARENCY, CHANNEL_USER_MASK, LayerMask, LayerRecord, Rect};
use crate::smart::{MAX_BAKE_PIXELS, SmartObject, size_for, square_to_quad};
/// The most pixels one decoded image (the composite, a layer or a mask) may have: 2^28, for
/// example 16 384 × 16 384, 4 GiB as float RGBA.
pub const MAX_DECODE_PIXELS: u64 = 1 << 28;
/// What one decode may hold in memory at once: 4 GiB, 1 GiB where pointers are 32 bits wide.
pub const DECODE_BUDGET: u64 = if cfg!(target_pointer_width = "32") { 1 << 30 } else { 4 << 30 };
/// The bake's rasterising budget, in bounding-box pixels visited over all mesh triangles, as a
/// multiple of the output pixel count. A sane warp visits about 2× the output; a hostile mesh
/// that makes every triangle cover the whole output would visit thousands of times more.
const BAKE_WORK_FACTOR: u64 = 8;
/// The smallest budget, so tiny outputs are never refused for rounding at the triangle edges.
const MIN_BAKE_WORK: u64 = 1 << 20;
/// Decoded pixels: straight (unpremultiplied) RGBA in 0..1, row-major.
#[derive(Clone, Debug, PartialEq)]
pub struct FloatImage {
/// Width in pixels.
pub width: u32,
/// Height in pixels.
pub height: u32,
/// `width × height` pixels.
pub data: Vec<[f32; 4]>,
}
/// `w × h` as a pixel count, or an error when it exceeds [`MAX_DECODE_PIXELS`].
fn pixel_count(w: usize, h: usize) -> Result<usize> {
match (w as u64).checked_mul(h as u64) {
Some(n) if n <= MAX_DECODE_PIXELS => Ok(n as usize),
_ => Err(PsdError::LimitExceeded("image has more pixels than MAX_DECODE_PIXELS")),
}
}
/// Refuses a decode whose buffers would together hold more than `limit` bytes.
fn within(limit: u64, needed: u64) -> Result<()> {
if needed <= limit { Ok(()) } else { Err(PsdError::LimitExceeded("decoding needs more memory than the decode budget")) }
}
/// `n` copies of `value`, or an error when there is no memory for them.
fn filled<T: Clone>(n: usize, value: T) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(n).map_err(|_| PsdError::OutOfMemory("not enough memory for the decoded pixels"))?;
v.resize(n, value);
Ok(v)
}
/// A 32-bit float sample made safe to use: NaN reads as 0, +inf as white (1.0, like the integer
/// depths' maximum), -inf as 0.
fn finite_sample(v: f32) -> f32 {
if v.is_nan() || v == f32::NEG_INFINITY {
0.0
} else if v == f32::INFINITY {
1.0
} else {
v
}
}
/// Hands every sample of the plane `raw` (`w`×`h`, row-major, in order) to `f` as a float.
/// Samples a short `raw` does not have are not reported.
fn for_each_sample(depth: u16, raw: &[u8], w: usize, h: usize, mut f: impl FnMut(usize, f32)) {
let n = w.saturating_mul(h);
match depth {
1 => {
let rowb = w.div_ceil(8);
for y in 0..h {
for x in 0..w {
let Some(b) = raw.get(y * rowb + x / 8) else { return };
// 1 = black.
f(y * w + x, if b & (0x80 >> (x % 8)) != 0 { 0.0 } else { 1.0 });
}
}
}
16 => raw.as_chunks::<2>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, f32::from(u16::from_be_bytes(*c)) / 65535.0)),
32 => raw.as_chunks::<4>().0.iter().take(n).enumerate().for_each(|(i, c)| f(i, finite_sample(f32::from_be_bytes(*c)))),
_ => raw.iter().take(n).enumerate().for_each(|(i, &b)| f(i, f32::from(b) / 255.0)),
}
}
/// The sRGB encoding of a linear value.
pub 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 }
}
/// CIE L*a*b* (D50; L* 0..100, a* and b* about −128..127) to sRGB (D65 through the
/// Bradford-adapted matrix), encoded and clamped to 0..1.
pub fn lab_to_srgb(l: f32, a: f32, b: f32) -> [f32; 3] {
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let f = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
let (x, y, z) = (0.964_22 * f(fx), f(fy), 0.825_21 * f(fz));
// XYZ (D50) to linear sRGB.
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
let bb = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
[r, g, bb].map(|v| srgb_encode(v.clamp(0.0, 1.0)))
}
/// What turns a document's channel values into RGB.
struct ToRgb<'a> {
mode: ColorMode,
depth: u16,
palette: &'a [u8],
}
impl ToRgb<'_> {
fn of(file: &PsdFile) -> ToRgb<'_> {
ToRgb { mode: file.header.color_mode, depth: file.header.depth, palette: &file.color_mode_data }
}
/// Colour channels before alpha; a mode without a fixed count shows its first channel.
fn color_channels(&self) -> usize {
self.mode.color_channels().map_or(1, usize::from)
}
/// Colour values (the mode's channels in 0..1, missing ones 0) to RGB.
fn rgb(&self, v: [f32; 4]) -> [f32; 3] {
let rgb = match self.mode {
ColorMode::Rgb => [v[0], v[1], v[2]],
// Stored inverted (1 = no ink).
ColorMode::Cmyk => [v[0] * v[3], v[1] * v[3], v[2] * v[3]],
ColorMode::Indexed if self.palette.len() >= 768 => {
let idx = (v[0] * 255.0).round() as usize & 255;
let p = |k: usize| self.palette.get(k * 256 + idx).map_or(0.0, |&c| f32::from(c) / 255.0);
[p(0), p(1), p(2)]
}
ColorMode::Lab => lab_to_srgb(v[0] * 100.0, v[1] * 255.0 - 128.0, v[2] * 255.0 - 128.0),
_ => [v[0]; 3],
};
if self.depth == 32 && self.mode != ColorMode::Lab { rgb.map(srgb_encode) } else { rgb }
}
}
/// Where the source rows or columns `s0..s0+n` land in an output starting at `o0` with `on`
/// entries: the source range that is inside, and the offset to subtract (64-bit: file bounds can
/// span the whole `i32` range).
fn span(s0: i32, n: usize, o0: i32, on: usize) -> (std::ops::Range<usize>, i64) {
let off = i64::from(o0) - i64::from(s0);
let lo = off.clamp(0, n as i64);
let hi = (off + on as i64).clamp(lo, n as i64);
(lo as usize..hi as usize, off)
}
/// The geometry of one layer decode: the output rectangle and the source rectangle inside it.
struct Placement {
ow: usize,
oh: usize,
out_rect: Rect,
src: Rect,
sw: usize,
sh: usize,
}
impl Placement {
/// Calls `f(output index, source index)` for every source pixel that lands in the output.
fn for_each(&self, mut f: impl FnMut(usize, usize)) {
let (ys, oy) = span(self.src.top, self.sh, self.out_rect.top, self.oh);
let (xs, ox) = span(self.src.left, self.sw, self.out_rect.left, self.ow);
for y in ys {
for x in xs.clone() {
f((y as i64 - oy) as usize * self.ow + (x as i64 - ox) as usize, y * self.sw + x);
}
}
}
}
fn size_of(r: Rect) -> Result<(usize, usize)> {
if r.is_empty() { Ok((0, 0)) } else { r.size() }
}
impl PsdFile {
/// The merged (flattened) image. Alpha is the first extra channel when
/// [`PsdFile::merged_has_alpha`]; an RGB composite is stored matted against white and has
/// the matte removed, as [`PsdFile::composite_rgba8`] does.
pub fn composite_f32(&self) -> Result<FloatImage> {
self.composite_f32_within(DECODE_BUDGET)
}
/// [`Self::composite_f32`] holding at most `limit` bytes at once: the result (16 bytes a
/// pixel), the CMYK black plane and the decoded planes of every channel.
pub fn composite_f32_within(&self, limit: u64) -> Result<FloatImage> {
let h = &self.header;
h.validate()?;
let (w, hh) = (h.width as usize, h.height as usize);
let n = pixel_count(w, hh)?;
let to_rgb = ToRgb::of(self);
let cc = to_rgb.color_channels();
let nch = usize::from(h.channels);
let alpha = self.merged_has_alpha();
let want = (cc + usize::from(alpha)).min(nch);
let plane = h.row_bytes().checked_mul(hh).ok_or(PsdError::LimitExceeded("plane size overflow"))?;
let cmyk = h.color_mode == ColorMode::Cmyk && want > 3;
let black_bytes = if cmyk { 4 * n as u64 } else { 0 };
within(limit, (16 * n as u64).saturating_add(black_bytes).saturating_add((plane as u64).saturating_mul(nch as u64)))?;
let all = self.decode_merged()?;
// Opaque black until a channel says otherwise.
let mut data = filled(n, [0.0f32, 0.0, 0.0, 1.0])?;
let mut black = if cmyk { filled(n, 0.0f32)? } else { Vec::new() };
for c in 0..want {
let Some(raw) = all.get(c * plane..(c + 1) * plane) else { break };
let slot = if c < cc.min(3) {
c
} else if c == cc && alpha {
3
} else {
// The fourth CMYK channel is the black plate, not a slot of the result.
for_each_sample(h.depth, raw, w, hh, |i, v| {
if let Some(k) = black.get_mut(i) {
*k = v;
}
});
continue;
};
for_each_sample(h.depth, raw, w, hh, |i, v| {
if let Some(p) = data.get_mut(i) {
p[slot] = v;
}
});
}
let unmatte = alpha && h.color_mode == ColorMode::Rgb;
for (i, p) in data.iter_mut().enumerate() {
let a = p[3];
if unmatte && a > 0.0 && a < 1.0 {
for c in p.iter_mut().take(3) {
*c = ((*c + a - 1.0) / a).clamp(0.0, 1.0);
}
}
let [r, g, b] = to_rgb.rgb([p[0], p[1], p[2], black.get(i).copied().unwrap_or(0.0)]);
*p = [r, g, b, a];
}
Ok(FloatImage { width: h.width, height: h.height, data })
}
/// One layer's pixels with its raster mask applied to alpha. A solid colour fill layer
/// without pixels is filled over the canvas (or over its mask's rectangle when the mask
/// hides everything outside it). `canvas`: document-sized with the layer in place;
/// otherwise the layer's own bounds (1×1 for an empty layer).
pub fn layer_f32(&self, index: usize, canvas: bool) -> Result<FloatImage> {
self.layer_f32_within(index, canvas, DECODE_BUDGET)
}
/// [`Self::layer_f32`] holding at most `limit` bytes at once: the result (16 bytes a pixel),
/// then either the channel being decoded (its bytes and its samples as floats) with the CMYK
/// black plane, or the mask.
pub fn layer_f32_within(&self, index: usize, canvas: bool, limit: u64) -> Result<FloatImage> {
let l = self.layers().get(index).ok_or_else(|| PsdError::invalid(format!("no layer {index}")))?;
let h = &self.header;
let doc = Rect::from_xywh(0, 0, h.width, h.height);
let has_pixels = !l.rect.is_empty() && l.channels.iter().any(|c| c.id >= 0);
let fill = l.solid_color().filter(|_| !has_pixels);
let mask = l.layer_mask().filter(|m| !m.disabled());
// Fill layers often have no pixel data: they cover the canvas (or the mask).
let src = if fill.is_some() { l.layer_mask().filter(|m| !m.rect.is_empty() && m.default_color == 0).map_or(doc, |m| m.rect) } else { l.rect };
let out_rect = if canvas {
doc
} else if src.is_empty() {
Rect::from_xywh(0, 0, 1, 1)
} else {
src
};
let (ow, oh) = out_rect.size()?;
let n = pixel_count(ow, oh)?;
let (sw, sh) = size_of(src)?;
let place = Placement { ow, oh, out_rect, src, sw, sh };
let cmyk = h.color_mode == ColorMode::Cmyk;
// The peak: the result, then either the CMYK black plane with the channel being decoded,
// or the mask. Checked before anything is allocated.
let plane = |w: usize, hh: usize| (row_bytes(w, h.depth) as u64).saturating_mul(hh as u64);
let pixels_peak = if has_pixels {
plane(sw, sh).saturating_add(4 * (sw as u64) * (sh as u64)).saturating_add(if cmyk { 4 * n as u64 } else { 0 })
} else {
0
};
let mask_peak = match mask {
Some(m) if l.channel(CHANNEL_USER_MASK).is_some() => size_of(m.rect).map(|(mw, mh)| plane(mw, mh))?,
_ => 0,
};
within(limit, (16 * n as u64).saturating_add(pixels_peak.max(mask_peak)))?;
pixel_count(sw, sh)?;
let mut out = filled(n, [0.0f32; 4])?;
match fill {
Some(f) => place.for_each(|d, _| {
if let Some(p) = out.get_mut(d) {
*p = [f[0] as f32, f[1] as f32, f[2] as f32, 1.0];
}
}),
None => self.layer_channels(l, &place, cmyk, &mut out)?,
}
if let Some(m) = mask {
self.apply_layer_mask(l, m, out_rect, &mut out)?;
}
Ok(FloatImage { width: ow as u32, height: oh as u32, data: out })
}
/// Decodes the layer's colour and transparency channels into `out` and converts to RGB.
fn layer_channels(&self, l: &LayerRecord, place: &Placement, cmyk: bool, out: &mut [[f32; 4]]) -> Result<()> {
let h = &self.header;
let to_rgb = ToRgb::of(self);
let cc = to_rgb.color_channels();
// Opaque unless there is a transparency channel.
place.for_each(|d, _| {
if let Some(p) = out.get_mut(d) {
p[3] = 1.0;
}
});
let mut black: Vec<f32> = Vec::new();
if place.sw > 0 && place.sh > 0 {
for c in &l.channels {
// The fourth CMYK channel is the black plate, not a slot of the result.
let to_black = cmyk && c.id == 3;
let slot = match c.id {
CHANNEL_TRANSPARENCY => 3,
id if id >= 0 && (id as usize) < cc.min(3) => id as usize,
_ if to_black => 0,
_ => continue,
};
let raw = c.decode(place.sw, place.sh, h.depth, h.version)?;
if to_black && black.is_empty() {
black = filled(out.len(), 0.0f32)?;
}
let mut plane = filled(place.sw * place.sh, 0.0f32)?;
for_each_sample(h.depth, &raw, place.sw, place.sh, |i, v| {
if let Some(o) = plane.get_mut(i) {
*o = v;
}
});
drop(raw);
place.for_each(|d, s| {
let Some(&v) = plane.get(s) else { return };
if to_black {
if let Some(k) = black.get_mut(d) {
*k = v;
}
} else if let Some(p) = out.get_mut(d) {
p[slot] = v;
}
});
}
}
place.for_each(|d, _| {
if let Some(p) = out.get_mut(d) {
let [r, g, b] = to_rgb.rgb([p[0], p[1], p[2], black.get(d).copied().unwrap_or(0.0)]);
*p = [r, g, b, p[3]];
}
});
Ok(())
}
/// Multiplies alpha by the raster mask, including its default outside the mask bounds.
fn apply_layer_mask(&self, l: &LayerRecord, m: &LayerMask, out_rect: Rect, out: &mut [[f32; 4]]) -> Result<()> {
let h = &self.header;
let (ow, oh) = out_rect.size()?;
let (mw, mh) = size_of(m.rect)?;
let inverted = m.flags & LayerMask::FLAG_INVERT != 0;
let def = f32::from(m.default_color) / 255.0;
// The factor outside the mask rectangle, or everywhere when there is no mask data.
let outside = if inverted { 1.0 - def } else { def };
let scale = |px: &mut [[f32; 4]], v: f32| {
if v != 1.0 {
for p in px {
p[3] *= v;
}
}
};
let mask = match l.channel(CHANNEL_USER_MASK) {
Some(c) if mw > 0 && mh > 0 => {
pixel_count(mw, mh)?;
c.decode(mw, mh, h.depth, h.version)?
}
_ => {
scale(out, outside);
return Ok(());
}
};
let mut values = filled(mw, def)?;
// The columns of the output that fall inside the mask rectangle (the same for every row).
let lo = (i64::from(m.rect.left) - i64::from(out_rect.left)).clamp(0, ow as i64);
let hi = (i64::from(m.rect.right) - i64::from(out_rect.left)).clamp(lo, ow as i64);
let (lo, hi) = (lo as usize, hi as usize);
let first = (i64::from(out_rect.left) + lo as i64 - i64::from(m.rect.left)) as usize;
let rowb = row_bytes(mw, h.depth);
for (y, row) in out.chunks_exact_mut(ow.max(1)).take(oh).enumerate() {
let py = i64::from(out_rect.top) + y as i64;
if py < i64::from(m.rect.top) || py >= i64::from(m.rect.bottom) {
scale(row, outside);
continue;
}
let my = (py - i64::from(m.rect.top)) as usize;
values.fill(def);
if let Some(raw) = mask.get(my * rowb..(my + 1) * rowb) {
for_each_sample(h.depth, raw, mw, 1, |i, v| {
if let Some(o) = values.get_mut(i) {
*o = v;
}
});
}
let (left, rest) = row.split_at_mut(lo);
let (mid, right) = rest.split_at_mut(hi - lo);
scale(left, outside);
scale(right, outside);
for (i, p) in mid.iter_mut().enumerate() {
let v = values.get(first + i).copied().unwrap_or(def);
p[3] *= if inverted { 1.0 - v } else { v };
}
}
Ok(())
}
}
impl SmartObject {
/// Bakes the content (straight RGBA) as placed: warped and mapped by the quad, rendered at
/// `k` pixels per document pixel (into `size` pixels when given; see
/// [`SmartObject::placed_size`] and [`SmartObject::bake_scale`]). Returns the pixels and
/// their document-space origin. `None` when the content is empty, the baked image would
/// exceed [`MAX_BAKE_PIXELS`], or the mesh needs more rasterising work than eight times the
/// output allows (a hostile mesh whose triangles each cover the whole output).
pub fn render_placed(&self, content: &FloatImage, k: f64, size: Option<(u32, u32)>) -> Option<(FloatImage, [f64; 2])> {
let (cw, ch) = (f64::from(content.width), f64::from(content.height));
if content.width == 0 || content.height == 0 {
return None;
}
// The quad is solved once for the bounds, the size and the whole grid.
let m = square_to_quad(self.quad);
let bb = self.bounds_with(&m, cw, ch);
let (ow, oh) = match size {
Some((w, h)) => (w.max(1), h.max(1)),
None => size_for(bb, k),
};
let (ow, oh) = (ow as usize, oh as usize);
let count = ow.checked_mul(oh).filter(|&c| c as u64 <= MAX_BAKE_PIXELS)?;
// A forward grid, filled triangle by triangle with inverse barycentric sampling.
let n = 48usize;
let mut dst = Vec::with_capacity((n + 1) * (n + 1));
let mut src = Vec::with_capacity((n + 1) * (n + 1));
for j in 0..=n {
for i in 0..=n {
let s = [i as f64 / n as f64 * cw, j as f64 / n as f64 * ch];
let d = self.map_with(&m, s, cw, ch);
dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]);
src.push(s);
}
}
let mut out: Vec<[f32; 4]> = Vec::new();
out.try_reserve_exact(count).ok()?;
out.resize(count, [0.0; 4]);
let sampler = Sampler { content, cw, ch };
let idx = |i: usize, j: usize| j * (n + 1) + i;
// A sane mesh's triangles each span about one grid cell (ow/n by oh/n), rounded out to
// whole pixels. A thin output (16384×1) stacks every grid row onto the same pixel row, so
// allow each of the 2n² triangles about two cells' extent along each axis, with margin.
let rounding = (4 * n as u64).saturating_mul((ow as u64).saturating_add(oh as u64).saturating_add(n as u64));
let budget = (count as u64).saturating_mul(BAKE_WORK_FACTOR).max(MIN_BAKE_WORK).saturating_add(rounding);
let mut work = 0u64;
for j in 0..n {
for i in 0..n {
for tri in [[idx(i, j), idx(i + 1, j), idx(i + 1, j + 1)], [idx(i, j), idx(i + 1, j + 1), idx(i, j + 1)]] {
let corners = |v: &[[f64; 2]]| tri.map(|t| v.get(t).copied().unwrap_or([0.0; 2]));
work = work.saturating_add(fill_triangle(corners(&dst), corners(&src), (ow, oh), &sampler, &mut out));
if work > budget {
return None;
}
}
}
}
// Back to straight alpha.
for p in &mut out {
let a = p[3];
if a > 0.0 {
for c in p.iter_mut().take(3) {
*c /= a;
}
}
}
Some((FloatImage { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]]))
}
}
/// Bilinear, premultiplied sampling of the content at content-pixel coordinates.
struct Sampler<'a> {
content: &'a FloatImage,
cw: f64,
ch: f64,
}
impl Sampler<'_> {
fn at(&self, x: usize, y: usize) -> [f32; 4] {
// `data` is a public field: a short one reads as transparent.
let p = self.content.data.get(y * self.content.width as usize + x).copied().unwrap_or([0.0; 4]);
[p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]
}
fn sample(&self, x: f64, y: f64) -> [f32; 4] {
let (fx, fy) = ((x - 0.5).clamp(0.0, self.cw - 1.0), (y - 0.5).clamp(0.0, self.ch - 1.0));
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(self.content.width as usize - 1), (y0 + 1).min(self.content.height as usize - 1));
let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let (p00, p10, p01, p11) = (self.at(x0, y0), self.at(x1, y0), self.at(x0, y1), self.at(x1, y1));
std::array::from_fn(|c| (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b)
}
}
/// Fills the output pixels inside the triangle `[a, b, c]` (output pixels) with the content
/// sampled at the matching point of the source triangle `s`. Returns the bounding-box pixels
/// visited.
fn fill_triangle(
[a, b, c]: [[f64; 2]; 3],
[sa, sb, sc]: [[f64; 2]; 3],
(ow, oh): (usize, usize),
sampler: &Sampler<'_>,
out: &mut [[f32; 4]],
) -> u64 {
let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
if den.abs() < 1e-12 {
return 0;
}
let (l1y, l1x) = (b[1] - c[1], c[0] - b[0]);
let (l2y, l2x) = (c[1] - a[1], a[0] - c[0]);
let x0 = a[0].min(b[0]).min(c[0]).floor().max(0.0) as usize;
let x1 = (a[0].max(b[0]).max(c[0]).ceil() as usize).min(ow);
let y0 = a[1].min(b[1]).min(c[1]).floor().max(0.0) as usize;
let y1 = (a[1].max(b[1]).max(c[1]).ceil() as usize).min(oh);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let l1 = (l1y * (px - c[0]) + l1x * (py - c[1])) / den;
let l2 = (l2y * (px - c[0]) + l2x * (py - c[1])) / den;
let l3 = 1.0 - l1 - l2;
if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 {
continue;
}
if let Some(o) = out.get_mut(y * ow + x) {
*o = sampler.sample(sa[0] * l1 + sb[0] * l2 + sc[0] * l3, sa[1] * l1 + sb[1] * l2 + sc[1] * l3);
}
}
}
(x1.saturating_sub(x0) as u64).saturating_mul(y1.saturating_sub(y0) as u64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::{LayerSpec, MaskSpec, PixelData, PsdBuilder};
use crate::descriptor::{Descriptor, Value};
use crate::io::WriteExt;
use crate::pixels::f32_to_bytes;
use crate::tagged::TaggedBlock;
use crate::testgen::{merged_only, mode_depths};
use crate::{Compression, ImageData, Version};
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
/// A flattened file whose merged image is `planes` (one per channel, concatenated).
fn flat(mode: ColorMode, depth: u16, w: u32, h: u32, planes: &[u8]) -> PsdFile {
let mut file = merged_only(Version::Psd, mode, depth, Compression::Raw, w, h);
file.image_data = ImageData::encode(Compression::Raw, planes, &file.header).unwrap();
file
}
fn reopen(b: &PsdBuilder) -> PsdFile {
PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap()
}
/// The float composite agrees with the 8-bit one (within a step) in every mode both read,
/// at every depth below 32, which the 8-bit path leaves linear.
#[test]
fn the_float_composite_matches_the_8_bit_one() {
for mode in [ColorMode::Bitmap, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Duotone]
{
for &depth in mode_depths(mode).iter().filter(|&&d| d != 32) {
for compression in Compression::ALL {
let file = merged_only(Version::Psd, mode, depth, compression, 7, 5);
let f = file.composite_f32().unwrap();
let b = file.composite_rgba8().unwrap();
assert_eq!((f.width, f.height), (b.width, b.height));
// The 8-bit path quantizes 16-bit Lab before its non-linear conversion.
let tolerance = if mode == ColorMode::Lab && depth == 16 { 2.01 } else { 1.01 };
for (p, q) in f.data.iter().zip(b.data.as_chunks::<4>().0) {
for c in 0..4 {
assert!((p[c] * 255.0 - f32::from(q[c])).abs() <= tolerance, "{mode:?} {depth} {compression:?}: {p:?} vs {q:?}");
}
}
}
}
}
}
#[test]
fn lab_converts_through_d50_to_srgb_in_both_paths() {
// White, black, and a saturated red-ish Lab colour.
let file = flat(ColorMode::Lab, 8, 3, 1, &[255, 0, 135, 128, 128, 208, 128, 128, 195]);
let f = file.composite_f32().unwrap();
assert!(f.data[0][..3].iter().all(|&v| v > 0.99), "{:?}", f.data[0]);
assert!(f.data[1][..3].iter().all(|&v| v < 0.01), "{:?}", f.data[1]);
assert!(f.data[2][0] > 0.8 && f.data[2][1] < 0.3 && f.data[2][2] < 0.3, "{:?}", f.data[2]);
let b = file.composite_rgba8().unwrap();
assert_eq!(&b.data[..8], &[255, 255, 255, 255, 0, 0, 0, 255]);
assert!(b.data[8] > 200 && b.data[9] < 80 && b.data[11] == 255, "{:?}", &b.data[8..]);
}
#[test]
fn a_32_bit_composite_is_encoded_to_srgb_and_non_finite_samples_are_tamed() {
let planes = [[0.5f32, f32::NAN], [0.5, f32::INFINITY], [0.5, f32::NEG_INFINITY]].iter().flat_map(|p| f32_to_bytes(p)).collect::<Vec<u8>>();
let f = flat(ColorMode::Rgb, 32, 2, 1, &planes).composite_f32().unwrap();
assert!(f.data[0][..3].iter().all(|&v| close(v, srgb_encode(0.5))), "{:?}", f.data[0]);
assert_eq!([f.data[1][0], f.data[1][2], f.data[1][3]], [0.0, 0.0, 1.0]);
assert!(close(f.data[1][1], 1.0));
}
#[test]
fn a_transparent_rgb_composite_loses_its_white_matte() {
// Half-transparent red stored over white: (255, 128, 128) at alpha 128.
let mut file = flat(ColorMode::Rgb, 8, 1, 1, &[255, 128, 128]);
file.header.channels = 4;
file.image_data = ImageData::encode(Compression::Raw, &[255, 128, 128, 128], &file.header).unwrap();
assert!(file.merged_has_alpha());
let p = file.composite_f32().unwrap().data[0];
assert!(close(p[3], 128.0 / 255.0) && close(p[0], 1.0) && p[1] < 0.01 && p[2] < 0.01, "{p:?}");
}
#[test]
fn layers_decode_in_their_bounds_or_on_the_canvas() {
let mut b = PsdBuilder::new(4, 2).depth(16).compression(Compression::ZipPrediction);
let data = vec![65535, 0, 0, 65535, 0, 32768, 0, 32768];
b.push_layer(LayerSpec::new("two", 1, 1, 2, 1, PixelData::Rgba16(data)));
let file = reopen(&b);
let own = file.layer_f32(0, false).unwrap();
assert_eq!((own.width, own.height), (2, 1));
assert_eq!(own.data[0], [1.0, 0.0, 0.0, 1.0]);
assert!(close(own.data[1][1], 32768.0 / 65535.0) && close(own.data[1][3], 32768.0 / 65535.0));
let canvas = file.layer_f32(0, true).unwrap();
assert_eq!((canvas.width, canvas.height), (4, 2));
assert_eq!(canvas.data[5], own.data[0]);
assert_eq!(canvas.data[6], own.data[1]);
assert!(canvas.data.iter().enumerate().filter(|&(i, _)| i != 5 && i != 6).all(|(_, p)| p[3] == 0.0));
assert!(file.layer_f32(9, false).is_err());
}
#[test]
fn a_layer_mask_scales_alpha_inside_and_outside_its_rectangle() {
let mut b = PsdBuilder::new(4, 1);
let mut spec = LayerSpec::new("masked", 0, 0, 4, 1, PixelData::Rgba8([255, 0, 0, 255].repeat(4)));
spec.mask = Some(MaskSpec { rect: Rect::from_xywh(1, 0, 2, 1), data: vec![255, 51], default_color: 0, disabled: false });
b.push_layer(spec.clone());
let alpha = |file: &PsdFile| file.layer_f32(0, true).unwrap().data.iter().map(|p| p[3]).collect::<Vec<f32>>();
assert_eq!(alpha(&reopen(&b)), [0.0, 1.0, 0.2, 0.0]);
spec.mask = Some(MaskSpec { rect: Rect::from_xywh(1, 0, 2, 1), data: vec![255, 51], default_color: 255, disabled: true });
let mut b = PsdBuilder::new(4, 1);
b.push_layer(spec);
assert_eq!(alpha(&reopen(&b)), [1.0; 4]);
}
#[test]
fn cmyk_layers_and_composites_convert_ink_to_rgb() {
let mut b = PsdBuilder::new(2, 1).color_mode(ColorMode::Cmyk);
let ink = PixelData::Cmyka8(vec![0, 0, 0, 0, 255, 0, 0, 0, 255, 255]);
b.push_layer(LayerSpec::new("ink", 0, 0, 2, 1, ink.clone()));
b.composite(ink);
let file = reopen(&b);
for img in [file.layer_f32(0, false).unwrap(), file.composite_f32().unwrap()] {
assert_eq!(img.data[0], [1.0, 1.0, 1.0, 1.0]);
assert_eq!(&img.data[1][..3], &[0.0, 0.0, 0.0]);
}
}
#[test]
fn a_fill_layer_without_pixels_fills_the_canvas_or_its_mask() {
let mut file = reopen(&PsdBuilder::new(3, 2));
let rgb = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(127.5)).with("Bl ", Value::Double(0.0));
let mut soco = Vec::new();
soco.put_u32(16);
Descriptor::new("null").with("Clr ", Value::Descriptor(rgb)).write(&mut soco);
file.layers_mut().push(LayerRecord { blocks: vec![TaggedBlock::new(*b"SoCo", soco)], ..Default::default() });
let i = file.layers().len() - 1;
let img = file.layer_f32(i, true).unwrap();
assert!(img.data.iter().all(|p| *p == [1.0, 0.5, 0.0, 1.0]), "{:?}", img.data);
// A mask that hides everything outside its rectangle bounds the fill.
let layer = &mut file.layers_mut()[i];
layer.mask = crate::layer::MaskData::Mask(LayerMask::new(Rect::from_xywh(1, 1, 2, 1), 0, 0));
layer.channels.push(crate::layer::ChannelData::encode(CHANNEL_USER_MASK, Compression::Raw, &[255, 255], 2, 1, 8, Version::Psd).unwrap());
let own = file.layer_f32(i, false).unwrap();
assert_eq!((own.width, own.height), (2, 1));
let canvas = file.layer_f32(i, true).unwrap();
assert_eq!(canvas.data.iter().map(|p| p[3]).collect::<Vec<_>>(), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0]);
}
/// Sizes are untrusted: an image over the pixel cap, or a decode over its memory budget, is
/// refused before anything is allocated for it.
#[test]
fn the_pixel_cap_and_the_memory_budget_refuse_before_allocating() {
let mut big = flat(ColorMode::Rgb, 8, 1, 1, &[1, 2, 3]);
big.header.version = Version::Psb;
(big.header.width, big.header.height) = (300_000, 300_000);
assert!(matches!(big.composite_f32(), Err(PsdError::LimitExceeded(_))));
let mut file = reopen(&PsdBuilder::new(2, 2));
file.layers_mut().push(LayerRecord { rect: Rect::from_xywh(0, 0, 300_000, 300_000), ..Default::default() });
let last = file.layers().len() - 1;
assert!(matches!(file.layer_f32(last, false), Err(PsdError::LimitExceeded(_))));
let mut b = PsdBuilder::new(64, 64);
b.push_layer(LayerSpec::new("l", 0, 0, 64, 64, PixelData::Rgba8(vec![9; 64 * 64 * 4])));
let file = reopen(&b);
let n = 64 * 64u64;
// The composite: the result, plus the decoded planes of every channel.
let peak = 16 * n + u64::from(file.header.channels) * n;
assert!(file.composite_f32_within(peak).is_ok());
assert!(matches!(file.composite_f32_within(peak - 1), Err(PsdError::LimitExceeded(_))));
// The layer: the result, plus one channel's bytes and its samples as floats.
assert!(file.layer_f32_within(0, true, 16 * n + 5 * n).is_ok());
assert!(matches!(file.layer_f32_within(0, true, 16 * n + 5 * n - 1), Err(PsdError::LimitExceeded(_))));
assert_eq!(file.layer_f32_within(0, true, 16 * n + 5 * n).unwrap(), file.layer_f32(0, true).unwrap());
}
fn so(quad: [[f64; 2]; 4], warp: Option<crate::smart::Warp>) -> SmartObject {
SmartObject { uuid: "x".into(), quad, size: None, warp }
}
#[test]
fn render_placed_bakes_a_perspective_quad() {
// 2×1 content: red | green, pinned to a trapezoid.
let content = FloatImage { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] };
let (px, origin) = so([[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], None).render_placed(&content, 1.0, None).unwrap();
assert_eq!(origin, [0.0, 0.0]);
assert_eq!((px.width, px.height), (40, 20));
let at = |x: usize, y: usize| px.data[y * 40 + x];
assert!(at(5, 10)[0] > 0.9 && at(5, 10)[3] > 0.99, "{:?}", at(5, 10));
assert!(at(35, 10)[1] > 0.9, "{:?}", at(35, 10));
assert_eq!(at(38, 1)[3], 0.0, "outside the trapezoid");
// Empty content, or a short `data`, bakes nothing or reads as transparent.
assert!(so([[0.0; 2]; 4], None).render_placed(&FloatImage { width: 0, height: 0, data: vec![] }, 1.0, None).is_none());
let short = FloatImage { width: 4, height: 4, data: vec![[1.0; 4]; 3] };
assert!(so([[0.0, 0.0], [8.0, 0.0], [8.0, 8.0], [0.0, 8.0]], None).render_placed(&short, 1.0, None).is_some());
}
#[test]
fn render_placed_refuses_an_output_over_the_pixel_cap() {
let content = FloatImage { width: 2, height: 1, data: vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]] };
let s = so([[0.0, 0.0], [40.0, 5.0], [40.0, 15.0], [0.0, 20.0]], None);
assert!(s.render_placed(&content, 1.0, Some((16384, 16384))).is_none());
assert!(s.render_placed(&content, 1.0, Some((u32::MAX, u32::MAX))).is_none());
}
#[test]
fn a_mesh_that_makes_every_triangle_cover_the_output_is_refused() {
// A 48×48 custom mesh whose patch corners scatter over the four corners of the
// content, so most of the 4608 triangles span the whole 2048² output.
let (cols, rows) = (48usize, 48usize);
let stride = cols * 3 + 1;
let mut mesh = vec![[0.0, 0.0]; stride * (rows * 3 + 1)];
for j in 0..=rows {
for i in 0..=cols {
if let Some(p) = mesh.get_mut(j * 3 * stride + i * 3) {
let h = (i * 7 + j * 13 + i * j * 5) % 4;
*p = [if h & 1 == 0 { 0.0 } else { 16.0 }, if h & 2 == 0 { 0.0 } else { 16.0 }];
}
}
}
let warp = crate::smart::Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 16.0, 16.0], cols, rows, mesh, ..Default::default() };
let s = so([[0.0, 0.0], [2048.0, 0.0], [2048.0, 2048.0], [0.0, 2048.0]], Some(warp));
let content = FloatImage { width: 16, height: 16, data: vec![[1.0, 0.0, 0.0, 1.0]; 256] };
assert!(s.render_placed(&content, 1.0, Some((2048, 2048))).is_none());
}
#[test]
fn a_thin_output_is_not_refused_for_rounding() {
let warp = crate::smart::Warp { style: "warpArc".into(), bounds: [0.0, 0.0, 16.0, 16.0], bend: 20.0, ..Default::default() };
let s = so([[0.0, 0.0], [16384.0, 0.0], [16384.0, 1.0], [0.0, 1.0]], Some(warp));
let content = FloatImage { width: 16, height: 16, data: vec![[1.0, 0.0, 0.0, 1.0]; 256] };
assert!(s.render_placed(&content, 1.0, Some((16384, 1))).is_some());
}
}
+5
View File
@@ -19,6 +19,9 @@
//! * [`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
@@ -66,6 +69,7 @@ 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;
@@ -99,6 +103,7 @@ 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;
+17 -3
View File
@@ -156,6 +156,12 @@ fn interleave(mode: ColorMode, color: &[Cow<'_, [u8]>], alpha: Option<&[u8]>, pa
let p = |k: usize| palette.get(k * 256 + idx).copied().unwrap_or(0);
(p(0), p(1), p(2))
}
ColorMode::Lab => {
let q = |v: f32| (v * 255.0).round() as u8;
let [r, g, b] =
crate::float::lab_to_srgb(f32::from(color[0][i]) * 100.0 / 255.0, f32::from(color[1][i]) - 128.0, f32::from(color[2][i]) - 128.0);
(q(r), q(g), q(b))
}
ColorMode::Cmyk => {
let k = u32::from(color[3][i]);
let f = |c: u8| ((u32::from(c) * k + 127) / 255) as u8;
@@ -173,7 +179,7 @@ fn interleave(mode: ColorMode, color: &[Cow<'_, [u8]>], alpha: Option<&[u8]>, pa
fn color_channel_count(mode: ColorMode) -> Result<usize> {
match mode {
ColorMode::Rgb => Ok(3),
ColorMode::Rgb | ColorMode::Lab => Ok(3),
ColorMode::Cmyk => Ok(4),
ColorMode::Grayscale | ColorMode::Indexed | ColorMode::Bitmap | ColorMode::Duotone => Ok(1),
m => Err(PsdError::Unsupported(format!("rgba8 for color mode {m:?}"))),
@@ -265,7 +271,15 @@ impl<'a> Layer<'a> {
let bytes = self.channel_bytes(id)?;
if depth == 8 { bytes } else { plane_to_u8(&bytes, depth, w, h)? }
} else {
vec![if mode == ColorMode::Cmyk { 255 } else { 0 }; n]
// A missing channel is no ink in CMYK and neutral (128) for Lab's a and b.
vec![
match (mode, id) {
(ColorMode::Cmyk, _) => 255,
(ColorMode::Lab, 1 | 2) => 128,
_ => 0,
};
n
]
};
if plane.len() != n {
return Err(PsdError::invalid("channel size mismatch"));
@@ -319,7 +333,7 @@ impl PsdFile {
}
/// The merged composite as RGBA8. Supports RGB, grayscale, CMYK, indexed,
/// duotone (as gray) and bitmap at all depths. Alpha comes from the first
/// duotone (as gray), bitmap and Lab (D50, to sRGB) at all depths. Alpha comes from the first
/// extra channel when [`PsdFile::merged_has_alpha`] is true. Removes the
/// white matte from RGB composites before reducing precision.
pub fn composite_rgba8(&self) -> Result<RgbaImage> {