diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 1c0fd3d4..eff3e167 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -15,6 +15,7 @@ pub mod placed; mod read; pub mod write; +use std::borrow::Cow; use std::sync::Arc; pub use descriptor::{DValue, Descriptor}; @@ -794,9 +795,16 @@ impl Psd { let mut r = Reader::at(&self.data[..end], offset); let comp = r.u16()?; decode_pixels(w, h)?; + let raw = self.plane_bytes(comp, &mut r, w, h)?; + Ok(self.samples(&raw, w, h)) + } + + /// The raw bytes (`row_bytes(w)` × `h`) of one channel whose pixel data follows `r`'s position + /// (after the compression field). Uncompressed data is borrowed, not copied. + fn plane_bytes<'a>(&self, comp: u16, r: &mut Reader<'a>, w: usize, h: usize) -> Result> { let rowb = self.row_bytes(w); - let raw = match comp { - 0 => r.bytes(rowb * h)?.to_vec(), + Ok(match comp { + 0 => Cow::Borrowed(r.bytes(rowb * h)?), 1 => { let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); for _ in 0..h { @@ -806,7 +814,7 @@ impl Psd { for c in counts { unpack_bits(r.bytes(c)?, rowb, &mut out)?; } - out + Cow::Owned(out) } 2 | 3 => { let rest = r.bytes(r.remaining())?; @@ -815,11 +823,20 @@ impl Psd { if comp == 3 { self.unpredict(&mut v, w, h); } - v + Cow::Owned(v) } c => return Err(Error::Unsupported(format!("compression {c}"))), - }; - Ok(self.samples(&raw, w, h)) + }) + } + + /// Plane `c` of the inflated ZIP data of consecutive planes, un-predicted in place (`comp` 3). + fn zip_plane<'v>(&self, comp: u16, v: &'v mut [u8], c: usize, w: usize, h: usize) -> Result<&'v [u8]> { + let rowb = self.row_bytes(w); + let plane = v.get_mut(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?; + if comp == 3 { + self.unpredict(plane, w, h); + } + Ok(plane) } fn row_bytes(&self, w: usize) -> usize { @@ -839,12 +856,16 @@ impl Psd { } } 32 => { + // One scratch row for the whole plane. + let mut planar = vec![0u8; w * 4]; + #[cfg(test)] + counters::bump(&counters::SCRATCH); for row in v.chunks_exact_mut(w * 4).take(h) { for i in 1..row.len() { row[i] = row[i].wrapping_add(row[i - 1]); } // Bytes are stored plane by plane (all first bytes, then all second bytes…). - let planar = row.to_vec(); + planar.copy_from_slice(row); for x in 0..w { for b in 0..4 { row[x * 4 + b] = planar[b * w + x]; @@ -934,13 +955,11 @@ impl Psd { } 2 | 3 => { let rest = r.bytes(r.remaining())?; - let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + let mut v = + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + // Each plane is un-predicted in place in the inflated buffer. for (c, p) in planes.iter_mut().enumerate().take(want) { - let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); - if comp == 3 { - self.unpredict(&mut plane, w, h); - } - *p = Some(self.samples(&plane, w, h)); + *p = Some(self.samples(self.zip_plane(comp, &mut v, c, w, h)?, w, h)); } } c => return Err(Error::Unsupported(format!("compression {c}"))), @@ -1019,19 +1038,41 @@ impl Psd { _ => None, }; let def = m.default_color as f32 / 255.0; - for y in 0..oh { - let py = out_rect.top + y as i32; - for x in 0..ow { - let px = out_rect.left + x as i32; - let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; - let mut v = match (&mask, inside) { - (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], - _ => def, - }; - if m.inverted { - v = 1.0 - v; + // The factor outside the mask rect, or everywhere when there is no mask data. + let outside = if m.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; + } + } + }; + match &mask { + None => scale(&mut out, outside), + Some(mk) => { + // The columns of the output that fall inside the mask rect (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); + for (y, row) in out.chunks_exact_mut(ow.max(1)).take(oh).enumerate() { + let py = i64::from(out_rect.top) + y as i64; + #[cfg(test)] + counters::bump(&counters::INSIDE); + if py < i64::from(m.rect.top) || py >= i64::from(m.rect.bottom) { + scale(row, outside); + continue; + } + let (left, rest) = row.split_at_mut(lo); + let (mid, right) = rest.split_at_mut(hi - lo); + scale(left, outside); + scale(right, outside); + // The mask row under `mid`: its first column is output column `lo`. + let base = (py - i64::from(m.rect.top)) as usize * mw + (i64::from(out_rect.left) + lo as i64 - i64::from(m.rect.left)) as usize; + for (i, p) in mid.iter_mut().enumerate() { + let v = mk.get(base + i).copied().unwrap_or(def); + p[3] *= if m.inverted { 1.0 - v } else { v }; + } } - out[y * ow + x][3] *= v; } } } @@ -1149,3 +1190,29 @@ fn lab_to_rgb(l: f32, a: f32, b: f32) -> [f32; 3] { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_opt; + +/// Test-only call counters (per thread), to prove what the optimised paths avoid doing. +#[cfg(test)] +pub(crate) mod counters { + use std::cell::Cell; + use std::thread::LocalKey; + + thread_local! { + /// Scratch rows allocated by the 32-bit ZIP un-predictor. + pub static SCRATCH: Cell = const { Cell::new(0) }; + /// `inside the mask rect` evaluations of the layer-mask loop. + pub static INSIDE: Cell = const { Cell::new(0) }; + /// `square_to_quad` solves. + pub static QUAD: Cell = const { Cell::new(0) }; + } + + pub fn bump(c: &'static LocalKey>) { + c.with(|c| c.set(c.get() + 1)); + } + /// Read and reset. + pub fn take(c: &'static LocalKey>) -> usize { + c.with(|c| c.replace(0)) + } +} diff --git a/crates/psd/src/placed.rs b/crates/psd/src/placed.rs index 156a8e27..a704d3ac 100644 --- a/crates/psd/src/placed.rs +++ b/crates/psd/src/placed.rs @@ -191,7 +191,9 @@ fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] /// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q` /// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1). -fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { +pub(crate) fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { + #[cfg(test)] + crate::counters::bump(&crate::counters::QUAD); let [p0, p1, p2, p3] = q; let sx = p0[0] - p1[0] + p2[0] - p3[0]; let sy = p0[1] - p1[1] + p2[1] - p3[1]; @@ -206,6 +208,11 @@ fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] { [[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]] } +/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel. +fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) { + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + impl SmartObject { /// Whether the corner quad is a parallelogram (an affine placement). pub fn is_affine(&self) -> bool { @@ -222,11 +229,15 @@ impl SmartObject { /// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad. pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + self.map_with(&square_to_quad(self.quad), p, w, h) + } + + /// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`). + fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] { let p = match &self.warp { Some(wp) => wp.apply(p, w, h), None => p, }; - let m = square_to_quad(self.quad); let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); let z = m[2][0] * u + m[2][1] * v + m[2][2]; let z = if z.abs() < 1e-12 { 1e-12 } else { z }; @@ -235,11 +246,15 @@ impl SmartObject { /// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content. pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] { + self.bounds_with(&square_to_quad(self.quad), w, h) + } + + fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] { let n = 32; let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; for j in 0..=n { for i in 0..=n { - let p = self.map([i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; } } @@ -248,8 +263,7 @@ impl SmartObject { /// The baked size for a `w`×`h` content at `k` pixels per document pixel. pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) { - let bb = self.placed_bounds(w, h); - (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) + size_for(self.placed_bounds(w, h), k) } /// Bake the content (straight RGBA) as placed: warped and mapped by the quad, rendered at @@ -260,10 +274,12 @@ impl SmartObject { if content.width == 0 || content.height == 0 { return None; } - let bb = self.placed_bounds(cw, ch); + // 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 => self.placed_size(cw, ch, k), + None => size_for(bb, k), }; let (ow, oh) = (ow as usize, oh as usize); // A forward grid, filled triangle by triangle with inverse barycentric sampling. @@ -273,7 +289,7 @@ impl SmartObject { 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(s, cw, 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); } @@ -304,6 +320,10 @@ impl SmartObject { if den.abs() < 1e-12 { continue; } + // Loop-invariant per triangle: the source corners and the barycentric coefficients. + let [sa, sb, sc] = tri.map(|t| src[t]); + 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; @@ -311,13 +331,12 @@ impl SmartObject { 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 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; - let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + 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; } - let [sa, sb, sc] = tri.map(|t| src[t]); let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; out[y * ow + x] = sample(sx, sy); diff --git a/crates/psd/src/tests_opt.rs b/crates/psd/src/tests_opt.rs new file mode 100644 index 00000000..1ffb565c --- /dev/null +++ b/crates/psd/src/tests_opt.rs @@ -0,0 +1,649 @@ +//! The optimised decode / mask / placed-bake paths against the implementations they replaced +//! (kept below verbatim as references), and counters for the work they no longer do. + +use super::counters::{self, INSIDE, QUAD, SCRATCH}; +use super::placed::{Warp, square_to_quad}; +use super::write::*; +use super::*; + +// ------------------------------------------------------------------ the replaced code + +fn ref_unpredict(depth: u16, v: &mut [u8], w: usize, h: usize) -> usize { + let mut allocs = 0; + match depth { + 16 => { + for row in v.chunks_exact_mut(w * 2).take(h) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]).wrapping_add(prev); + c.copy_from_slice(&x.to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in v.chunks_exact_mut(w * 4).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + let planar = row.to_vec(); + allocs += 1; + for x in 0..w { + for b in 0..4 { + row[x * 4 + b] = planar[b * w + x]; + } + } + } + } + _ => { + for row in v.chunks_exact_mut(w).take(h) { + for i in 1..row.len() { + row[i] = row[i].wrapping_add(row[i - 1]); + } + } + } + } + allocs +} + +fn ref_decode_channel(p: &Psd, offset: usize, len: usize, w: usize, h: usize) -> Result> { + let end = offset.checked_add(len).filter(|&e| e <= p.data.len()).ok_or(Error::Truncated)?; + let mut r = Reader::at(&p.data[..end], offset); + let comp = r.u16()?; + decode_pixels(w, h)?; + let rowb = p.row_bytes(w); + let raw = match comp { + 0 => r.bytes(rowb * h)?.to_vec(), + 1 => { + let mut counts = Vec::with_capacity(h.min(r.remaining() / 2)); + for _ in 0..h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + let mut out = Vec::with_capacity(rowb * h); + for c in counts { + unpack_bits(r.bytes(c)?, rowb, &mut out)?; + } + out + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let mut v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + v.resize(rowb * h, 0); + if comp == 3 { + ref_unpredict(p.depth, &mut v, w, h); + } + v + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + }; + Ok(p.samples(&raw, w, h)) +} + +fn ref_composite(p: &Psd) -> Result { + let (w, h) = (p.width as usize, p.height as usize); + decode_pixels(w, h)?; + let mut r = Reader::at(&p.data, p.merged_offset); + let comp = r.u16()?; + let nch = p.channels as usize; + let cc = p.mode.color_channels(); + let want = (cc + usize::from(p.merged_alpha)).min(nch); + let rowb = p.row_bytes(w); + let mut planes: Vec>> = vec![None; nch]; + match comp { + 0 => { + for pl in planes.iter_mut().take(want) { + *pl = Some(p.samples(r.bytes(rowb * h)?, w, h)); + } + } + 1 => { + let mut counts = Vec::with_capacity((nch * h).min(r.remaining() / 2)); + for _ in 0..nch * h { + counts.push(if p.psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut out = Vec::with_capacity(rowb * h); + for row in 0..h { + unpack_bits(r.bytes(counts[c * h + row])?, rowb, &mut out)?; + } + *pl = Some(p.samples(&out, w, h)); + } + } + 2 | 3 => { + let rest = r.bytes(r.remaining())?; + let v = miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(rest, want * rowb * h).map_err(|e| Error::Invalid(format!("zip: {e:?}")))?; + for (c, pl) in planes.iter_mut().enumerate().take(want) { + let mut plane = v.get(c * rowb * h..(c + 1) * rowb * h).ok_or(Error::Truncated)?.to_vec(); + if comp == 3 { + ref_unpredict(p.depth, &mut plane, w, h); + } + *pl = Some(p.samples(&plane, w, h)); + } + } + c => return Err(Error::Unsupported(format!("compression {c}"))), + } + let alpha = if p.merged_alpha && nch > cc { planes[cc].take() } else { None }; + let mut data = Vec::with_capacity(w * h); + for i in 0..w * h { + let [r, g, b] = p.to_rgb(&planes, i); + let a = alpha.as_ref().map_or(1.0, |a| a[i]); + data.push([r, g, b, a]); + } + Ok(Pixels { width: w as u32, height: h as u32, data }) +} + +fn ref_layer_pixels(p: &Psd, index: usize, canvas: bool) -> Result { + let l = p.layers.get(index).ok_or_else(|| Error::Invalid(format!("no layer {index}")))?; + let doc = Rect { top: 0, left: 0, bottom: p.height as i32, right: p.width as i32 }; + let fill_rect = + if l.fill.is_some() && !l.has_pixels() { l.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 fill_rect.is_empty() { + Rect { top: 0, left: 0, bottom: 1, right: 1 } + } else { + fill_rect + }; + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); + let mut out = vec![[0.0f32; 4]; decode_pixels(ow, oh)?]; + let src = fill_rect; + let (sw, sh) = (src.width() as usize, src.height() as usize); + if sw > 0 && sh > 0 { + let mut planes: Vec>> = vec![None; p.mode.color_channels()]; + let mut alpha = None; + if l.has_pixels() { + for c in &l.channels { + match c.id { + -1 => alpha = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + id if id >= 0 && (id as usize) < planes.len() => planes[id as usize] = Some(ref_decode_channel(p, c.offset, c.len, sw, sh)?), + _ => {} + } + } + } + let fill = l.fill.map(|c| c.map(|v| v as f32)); + let span = |s0: i32, n: usize, o0: i32, on: usize| { + 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) + }; + let (ys, oy) = span(src.top, sh, out_rect.top, oh); + let (xs, ox) = span(src.left, sw, out_rect.left, ow); + for y in ys { + let dy = (y as i64 - oy) as usize; + for x in xs.clone() { + let dx = (x as i64 - ox) as usize; + let i = y * sw + x; + let (rgb, a) = match fill { + Some(f) if !l.has_pixels() => (f, 1.0), + _ => (p.to_rgb(&planes, i), alpha.as_ref().map_or(1.0, |a| a[i])), + }; + out[dy * ow + dx] = [rgb[0], rgb[1], rgb[2], a]; + } + } + } + if let Some(m) = l.mask.filter(|m| !m.disabled) { + let mc = l.channels.iter().find(|c| c.id == -2); + let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); + let mask = match mc { + Some(c) if mw > 0 && mh > 0 => Some(ref_decode_channel(p, c.offset, c.len, mw, mh)?), + _ => None, + }; + let def = m.default_color as f32 / 255.0; + for y in 0..oh { + let py = out_rect.top + y as i32; + for x in 0..ow { + let px = out_rect.left + x as i32; + let inside = py >= m.rect.top && py < m.rect.bottom && px >= m.rect.left && px < m.rect.right; + let mut v = match (&mask, inside) { + (Some(mk), true) => mk[(py - m.rect.top) as usize * mw + (px - m.rect.left) as usize], + _ => def, + }; + if m.inverted { + v = 1.0 - v; + } + out[y * ow + x][3] *= v; + } + } + } + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) +} + +fn ref_map(so: &SmartObject, p: [f64; 2], w: f64, h: f64) -> [f64; 2] { + let p = match &so.warp { + Some(wp) => wp.apply(p, w, h), + None => p, + }; + let m = square_to_quad(so.quad); + let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9)); + let z = m[2][0] * u + m[2][1] * v + m[2][2]; + let z = if z.abs() < 1e-12 { 1e-12 } else { z }; + [(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z] +} + +fn ref_placed_bounds(so: &SmartObject, w: f64, h: f64) -> [f64; 4] { + let n = 32; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for j in 0..=n { + for i in 0..=n { + let p = ref_map(so, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h); + b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]; + } + } + [b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()] +} + +fn ref_placed_size(so: &SmartObject, w: f64, h: f64, k: f64) -> (u32, u32) { + let bb = ref_placed_bounds(so, w, h); + (((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32) +} + +fn ref_render_placed(so: &SmartObject, content: &Pixels, k: f64, size: Option<(u32, u32)>) -> Option<(Pixels, [f64; 2])> { + let (cw, ch) = (content.width as f64, content.height as f64); + if content.width == 0 || content.height == 0 { + return None; + } + let bb = ref_placed_bounds(so, cw, ch); + let (ow, oh) = match size { + Some((w, h)) => (w.max(1), h.max(1)), + None => ref_placed_size(so, cw, ch, k), + }; + let (ow, oh) = (ow as usize, oh as usize); + 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 = ref_map(so, s, cw, ch); + dst.push([(d[0] - bb[0]) * k, (d[1] - bb[1]) * k]); + src.push(s); + } + } + let mut out = vec![[0.0f32; 4]; ow * oh]; + let sample = |x: f64, y: f64| -> [f32; 4] { + let (fx, fy) = ((x - 0.5).clamp(0.0, cw - 1.0), (y - 0.5).clamp(0.0, ch - 1.0)); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(content.width as usize - 1), (y0 + 1).min(content.height as usize - 1)); + let (a, b) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let at = |x: usize, y: usize| { + let p = content.data[y * content.width as usize + x]; + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] + }; + let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); + let mut o = [0.0; 4]; + for c in 0..4 { + o[c] = (p00[c] * (1.0 - a) + p10[c] * a) * (1.0 - b) + (p01[c] * (1.0 - a) + p11[c] * a) * b; + } + o + }; + let idx = |i: usize, j: usize| j * (n + 1) + i; + 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 [a, b, c] = tri.map(|t| dst[t]); + let den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if den.abs() < 1e-12 { + continue; + } + 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 = ((b[1] - c[1]) * (px - c[0]) + (c[0] - b[0]) * (py - c[1])) / den; + let l2 = ((c[1] - a[1]) * (px - c[0]) + (a[0] - c[0]) * (py - c[1])) / den; + let l3 = 1.0 - l1 - l2; + if l1 < -1e-9 || l2 < -1e-9 || l3 < -1e-9 { + continue; + } + let [sa, sb, sc] = tri.map(|t| src[t]); + let sx = sa[0] * l1 + sb[0] * l2 + sc[0] * l3; + let sy = sa[1] * l1 + sb[1] * l2 + sc[1] * l3; + out[y * ow + x] = sample(sx, sy); + } + } + } + } + } + for p in &mut out { + if p[3] > 0.0 { + for c in 0..3 { + p[c] /= p[3]; + } + } + } + Some((Pixels { width: ow as u32, height: oh as u32, data: out }, [bb[0], bb[1]])) +} + +// ------------------------------------------------------------------ fixtures + +fn bits(p: &Pixels) -> Vec<[u32; 4]> { + p.data.iter().map(|px| px.map(f32::to_bits)).collect() +} + +/// Deterministic samples in 0..1 for a `w`×`h` plane, as file bytes at `depth`. +fn synth_plane(depth: u16, w: usize, h: usize, seed: usize) -> Vec { + let mut out = vec![]; + for i in 0..w * h { + let v = ((i * 7 + seed * 13) % 101) as f32 / 100.0; + match depth { + 8 => out.push((v * 255.0).round() as u8), + 16 => out.extend(((v * 65535.0).round() as u16).to_be_bytes()), + _ => out.extend(v.to_be_bytes()), + } + } + out +} + +/// The delta coding that `comp` 3 undoes (the inverse of `Psd::unpredict`). +fn predict(raw: &[u8], w: usize, depth: u16) -> Vec { + let mut out = raw.to_vec(); + match depth { + 16 => { + for row in out.chunks_exact_mut(w * 2) { + let mut prev = 0u16; + for c in row.as_chunks_mut::<2>().0 { + let x = u16::from_be_bytes([c[0], c[1]]); + c.copy_from_slice(&x.wrapping_sub(prev).to_be_bytes()); + prev = x; + } + } + } + 32 => { + for row in out.chunks_exact_mut(w * 4) { + let inter = row.to_vec(); + for x in 0..w { + for b in 0..4 { + row[b * w + x] = inter[x * 4 + b]; + } + } + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + _ => { + for row in out.chunks_exact_mut(w) { + for i in (1..row.len()).rev() { + row[i] = row[i].wrapping_sub(row[i - 1]); + } + } + } + } + out +} + +/// A pixel-data stream (compression field first) of consecutive `w`×`h` planes. +fn stream(planes: &[Vec], w: usize, h: usize, depth: u16, comp: u16) -> Vec { + let mut out = comp.to_be_bytes().to_vec(); + let rowb = planes.first().map_or(0, |p| p.len() / h.max(1)); + match comp { + 0 => planes.iter().for_each(|p| out.extend(p)), + 1 => { + let rows: Vec> = planes.iter().flat_map(|p| p.chunks(rowb.max(1)).map(pack_bits)).collect(); + rows.iter().for_each(|r| out.extend((r.len() as u16).to_be_bytes())); + rows.iter().for_each(|r| out.extend(r)); + } + _ => { + let all: Vec = planes.iter().flat_map(|p| if comp == 3 { predict(p, w, depth) } else { p.clone() }).collect(); + out.extend(miniz_oxide::deflate::compress_to_vec_zlib(&all, 6)); + } + } + out +} + +/// A small RGB document with a background, a hidden box and a masked layer (8 bit, raw). +fn base() -> Psd { + let mut d = WDoc::new(40, 30); + d.rle = false; + let grad: Vec<[f32; 4]> = (0..1200).map(|i| [(i % 40) as f32 / 39.0, (i / 40) as f32 / 29.0, 0.25, 1.0]).collect(); + let bg = WLayer::pixels("Background", Rect::new(0, 0, 40, 30), grad); + let mut red = WLayer::solid("Red", Rect::new(5, 6, 10, 8), [1.0, 0.0, 0.0, 0.5]); + red.hidden = true; + let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]); + let data = (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.25 }).collect(); + masked.mask = Some(WMask { rect: Rect::new(20, 10, 12, 12), data, default_color: 0, disabled: false }); + d.layers = vec![bg, red, masked]; + Psd::parse(write(&d)).unwrap() +} + +/// `p` with every channel and the merged image re-encoded at `depth` with compression `comp`. +fn reencode(p: &Psd, depth: u16, comp: u16) -> Psd { + let mut z = p.clone(); + z.depth = depth; + let mut data = p.data.to_vec(); + for (n, (l, zl)) in p.layers.iter().zip(&mut z.layers).enumerate() { + for (c, zc) in l.channels.iter().zip(&mut zl.channels) { + let r = if c.id == -2 { l.mask.map(|m| m.rect).unwrap_or(l.rect) } else { l.rect }; + let (w, h) = (r.width() as usize, r.height() as usize); + zc.offset = data.len(); + data.extend(stream(&[synth_plane(depth, w, h, n * 5 + (c.id + 2) as usize)], w, h, depth, comp)); + zc.len = data.len() - zc.offset; + } + } + let (w, h) = (p.width as usize, p.height as usize); + let planes: Vec> = (0..usize::from(p.channels)).map(|c| synth_plane(depth, w, h, 100 + c)).collect(); + z.merged_offset = data.len(); + data.extend(stream(&planes, w, h, depth, comp)); + z.data = data.into(); + z +} + +fn variants() -> impl Iterator { + [8u16, 16, 32].into_iter().flat_map(|d| [0u16, 1, 2, 3].into_iter().map(move |c| (d, c))) +} + +// ------------------------------------------------------------------ decoding + +#[test] +fn unpredict_matches_the_reference_and_allocates_one_scratch_row() { + for depth in [8u16, 16, 32] { + let p = Psd { depth, ..base() }; + let (w, h) = (13usize, 9usize); + let bps = usize::from(depth / 8); + let mut a: Vec = (0..w * h * bps).map(|i| (i * 31 % 251) as u8).collect(); + let mut b = a.clone(); + counters::take(&SCRATCH); + p.unpredict(&mut a, w, h); + let scratch = counters::take(&SCRATCH); + let old_allocs = ref_unpredict(depth, &mut b, w, h); + assert_eq!(a, b, "{depth} bit"); + // The reference copied every row; the new code uses one scratch row (32 bit only). + assert_eq!((scratch, old_allocs), if depth == 32 { (1, h) } else { (0, 0) }, "{depth} bit"); + } +} + +#[test] +fn raw_planes_are_borrowed_and_zip_planes_are_unpredicted_in_place() { + let p = base(); + // Raw: the sample bytes are a view into the file data. + let q = reencode(&p, 8, 0); + let c = q.layers[0].channels[0]; + let mut r = Reader::at(&q.data, c.offset); + let comp = r.u16().unwrap(); + let plane = q.plane_bytes(comp, &mut r, 40, 30).unwrap(); + let Cow::Borrowed(b) = plane else { panic!("raw plane was copied") }; + let file = q.data.as_ptr_range(); + assert!(file.contains(&b.as_ptr()) && b.len() == 1200); + // ZIP: the plane handed out is a sub-slice of the inflated buffer, not a copy. + let mut v: Vec = (0..3 * 40 * 30).map(|i| (i % 255) as u8).collect(); + let range = v.as_ptr_range(); + for comp in [2, 3] { + let got = q.zip_plane(comp, &mut v, 1, 40, 30).unwrap(); + assert_eq!(got.len(), 1200); + assert!(range.contains(&got.as_ptr()), "compression {comp}"); + } + assert!(q.zip_plane(2, &mut v, 3, 40, 30).is_err()); +} + +#[test] +fn decode_and_composite_match_the_reference() { + let p = base(); + for (depth, comp) in variants() { + let q = reencode(&p, depth, comp); + let what = format!("{depth} bit, compression {comp}"); + assert_eq!(bits(&q.composite().unwrap()), bits(&ref_composite(&q).unwrap()), "composite {what}"); + for i in 0..q.layers.len() { + for canvas in [false, true] { + assert_eq!(bits(&q.layer_pixels(i, canvas).unwrap()), bits(&ref_layer_pixels(&q, i, canvas).unwrap()), "layer {i} canvas {canvas}, {what}"); + } + } + } +} + +#[test] +fn errors_match_the_reference_on_damaged_data() { + let p = base(); + for (depth, comp) in variants() { + let mut q = reencode(&p, depth, comp); + // Cut the file in the middle of the merged image / the last channel. + let cut = q.data.len() - 7; + q.data = q.data[..cut].into(); + assert_eq!(q.composite().map(|p| bits(&p)), ref_composite(&q).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + assert_eq!(q.layer_pixels(2, true).map(|p| bits(&p)), ref_layer_pixels(&q, 2, true).map(|p| bits(&p)), "{depth} bit, compression {comp}"); + } +} + +// ------------------------------------------------------------------ the layer mask + +fn rect_at(left: i32, top: i32) -> Rect { + Rect::new(left, top, 12, 12) +} + +#[test] +fn layer_mask_matches_the_reference_for_every_mask_shape() { + let p = base(); + for (depth, comp) in [(8, 1), (16, 2), (32, 3), (8, 0)] { + let q = reencode(&p, depth, comp); + // Moved so it overlaps the layer / canvas partly, wholly, or not at all (the data is 12×12). + for (left, top) in [(20, 10), (14, 4), (-5, -3), (34, 24), (25, 12), (300, 300), (-300, 5)] { + for default_color in [0u8, 255, 90] { + for inverted in [false, true] { + for disabled in [false, true] { + for with_data in [true, false] { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(left, top), default_color, disabled, inverted }); + if !with_data { + r.layers[2].channels.retain(|c| c.id != -2); + } + for canvas in [false, true] { + let what = format!( + "{depth} bit comp {comp}, mask at ({left},{top}) default {default_color} inverted {inverted} disabled {disabled} data {with_data} canvas {canvas}" + ); + assert_eq!(bits(&r.layer_pixels(2, canvas).unwrap()), bits(&ref_layer_pixels(&r, 2, canvas).unwrap()), "{what}"); + } + } + } + } + } + } + } +} + +#[test] +fn layer_mask_evaluates_inside_per_row_and_skips_no_ops() { + let q = reencode(&base(), 8, 0); + let with = |default_color, inverted, data: bool| { + let mut r = q.clone(); + r.layers[2].mask = Some(LayerMask { rect: rect_at(20, 10), default_color, disabled: false, inverted }); + if !data { + r.layers[2].channels.retain(|c| c.id != -2); + } + r + }; + // With mask data: one inside test per output row (the reference tested every pixel: 40 × 30). + counters::take(&INSIDE); + with(0, false, true).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 30); + // Own bounds: 12 rows. + with(0, false, true).layer_pixels(2, false).unwrap(); + assert_eq!(counters::take(&INSIDE), 12); + // No mask data: no per-pixel or per-row test at all (a constant factor, skipped when 1.0). + with(255, false, false).layer_pixels(2, true).unwrap(); + with(0, true, false).layer_pixels(2, true).unwrap(); + with(0, false, false).layer_pixels(2, true).unwrap(); + assert_eq!(counters::take(&INSIDE), 0); + // A no-op mask factor leaves the alpha untouched. + let plain = with(255, false, false).layer_pixels(2, false).unwrap(); + let mut none = q.clone(); + none.layers[2].mask = None; + assert_eq!(bits(&plain), bits(&none.layer_pixels(2, false).unwrap())); +} + +// ------------------------------------------------------------------ placed layers + +fn warps() -> Vec> { + let mut mesh = vec![]; + for j in 0..4 { + for i in 0..4 { + mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 2.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]); + } + } + vec![ + None, + Some(Warp { style: "warpArc".into(), bend: 40.0, bounds: [0.0, 0.0, 20.0, 10.0], ..Default::default() }), + Some(Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() }), + ] +} + +fn content(w: u32, h: u32) -> Pixels { + Pixels { + width: w, + height: h, + data: (0..w * h).map(|i| [(i % w) as f32 / w as f32, (i / w) as f32 / h as f32, 0.5, if i % 7 == 0 { 0.4 } else { 1.0 }]).collect(), + } +} + +#[test] +fn placed_bake_matches_the_reference() { + let quads = [ + [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], + [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], + [[3.0, 4.0], [33.0, 9.0], [28.0, 29.0], [-2.0, 21.0]], + [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]], + ]; + let c = content(20, 10); + for quad in quads { + for warp in warps() { + let so = SmartObject { uuid: "x".into(), quad, size: None, warp }; + let what = format!("{quad:?} {:?}", so.warp.as_ref().map(|w| &w.style)); + assert_eq!(so.placed_bounds(20.0, 10.0), ref_placed_bounds(&so, 20.0, 10.0), "{what}"); + for k in [0.5, 1.0, 2.0] { + assert_eq!(so.placed_size(20.0, 10.0, k), ref_placed_size(&so, 20.0, 10.0, k), "{what} k {k}"); + for size in [None, Some((30, 25)), Some((0, 0))] { + let got = so.render_placed(&c, k, size); + let want = ref_render_placed(&so, &c, k, size); + assert_eq!( + got.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + want.as_ref().map(|(p, o)| (p.width, p.height, bits(p), *o)), + "{what} k {k} size {size:?}" + ); + } + } + for p in [[0.0, 0.0], [10.0, 5.0], [20.0, 10.0], [7.5, 2.25]] { + assert_eq!(so.map(p, 20.0, 10.0), ref_map(&so, p, 20.0, 10.0), "{what} {p:?}"); + } + } + } + assert!(SmartObject { uuid: "x".into(), quad: [[0.0; 2]; 4], size: None, warp: None }.render_placed(&content(0, 0), 1.0, None).is_none()); +} + +#[test] +fn placed_bake_solves_the_quad_once() { + let so = SmartObject { uuid: "x".into(), quad: [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]], size: None, warp: None }; + counters::take(&QUAD); + so.placed_bounds(20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1, "33×33 mapped points, one solve (was 1089)"); + so.placed_size(20.0, 10.0, 1.0); + assert_eq!(counters::take(&QUAD), 1); + // The bounds, the size and the 49×49 grid of one bake share a single solve (was 1089 × 2 + 2401). + so.render_placed(&content(20, 10), 1.0, None).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + so.render_placed(&content(20, 10), 1.0, Some((40, 40))).unwrap(); + assert_eq!(counters::take(&QUAD), 1); + // A single `map` still solves for itself. + so.map([1.0, 1.0], 20.0, 10.0); + assert_eq!(counters::take(&QUAD), 1); +}