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: 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>
794 lines
37 KiB
Rust
794 lines
37 KiB
Rust
//! 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());
|
||
}
|
||
}
|