Merge main

# Conflicts:
#	crates/format/src/store.rs
This commit is contained in:
David Mann
2026-10-27 19:10:28 +01:00
4 changed files with 373 additions and 54 deletions
+99 -20
View File
@@ -64,6 +64,34 @@ fn png(img: &Rgba8Image) -> Result<Vec<u8>> {
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}")))
}
fn previews(opts: &SaveOptions) -> Result<Vec<(&'static str, Vec<u8>)>> {
let (composite, thumbnail) = beside(
opts.thumbnail.is_some() && opts.composite.is_some(),
|| opts.composite.as_ref().map(png).transpose(),
|| opts.thumbnail.as_ref().map(png).transpose(),
);
let mut out = Vec::new();
if let Some(t) = thumbnail? {
out.push((THUMB, t));
}
if let Some(t) = composite? {
out.push((COMPOSITE, t));
}
Ok(out)
}
fn beside<A: Send, B>(parallel: bool, a: impl FnOnce() -> Result<A> + Send, b: impl FnOnce() -> B) -> (Result<A>, B) {
if !parallel || cfg!(target_arch = "wasm32") {
let a = a();
return (a, b());
}
std::thread::scope(|sc| {
let h = sc.spawn(a);
let b = b();
(h.join().unwrap_or_else(|_| Err(FormatError::Io(std::io::Error::other("preview worker panicked")))), b)
})
}
// ---------------------------------------------------------------------------
// Sources
// ---------------------------------------------------------------------------
@@ -481,7 +509,6 @@ struct FileSignature {
struct Prepared {
manifest: Vec<u8>,
objects: BTreeMap<String, Object>,
previews: Vec<(&'static str, Vec<u8>)>,
stats: SaveStats,
}
@@ -882,13 +909,6 @@ impl PcraftWriter {
convert::check_nesting(&doc.layers)?;
let mut c = Collect { hash_cache: Some(&mut self.hash_cache), blob_hash_cache: Some(&mut self.blob_hash_cache), ..Default::default() };
let document = convert::doc_m(doc, &mut c);
let mut previews = Vec::new();
if let Some(t) = &opts.thumbnail {
previews.push((THUMB, png(t)?));
}
if let Some(t) = &opts.composite {
previews.push((COMPOSITE, png(t)?));
}
let manifest = Manifest {
format_version: FORMAT_VERSION,
generator: format!("photocraft-format {}", env!("CARGO_PKG_VERSION")),
@@ -912,27 +932,38 @@ impl PcraftWriter {
for (h, b) in c.blobs {
objects.insert(blob_path(&h), Object::Blob(b));
}
Ok(Prepared { manifest, objects, previews, stats })
Ok(Prepared { manifest, objects, stats })
}
/// Save as a ZIP bundle in memory.
pub fn save_zip(&mut self, doc: &Document, opts: &SaveOptions) -> Result<(Vec<u8>, SaveStats)> {
let p = self.prepare(doc, opts)?;
// Another thread encodes the previews while the document is collected and its new
// objects are compressed on all cores (zstd dominates a full save).
let (previews, prepared) = beside(
opts.thumbnail.is_some() || opts.composite.is_some(),
|| previews(opts),
|| -> Result<_> {
let p = self.prepare(doc, opts)?;
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
let fresh: HashMap<String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().map(|(path, data)| (path.clone(), data)).collect();
Ok((p, fresh))
},
);
let (p, mut fresh) = prepared?;
let previews = previews?;
let mut stats = p.stats;
let mut next = HashMap::with_capacity(p.objects.len());
let mut cached_bytes = 0usize;
// New objects are compressed on all cores first (zstd dominates a full save).
let todo: Vec<(&String, &Object)> = p.objects.iter().filter(|(path, _)| !self.compressed.contains_key(*path)).collect();
let mut fresh: HashMap<&String, Arc<Vec<u8>>> = par_compress(&todo)?.into_iter().collect();
let entries = std::iter::once((MANIFEST, p.manifest.len())).chain(p.previews.iter().map(|(name, data)| (*name, data.len()))).chain(
p.objects.keys().map(|path| {
// The archive is sized for every entry up front (#7689): previews, then reused and fresh
// objects at their compressed sizes.
let entries =
std::iter::once((MANIFEST, p.manifest.len())).chain(previews.iter().map(|(name, data)| (*name, data.len()))).chain(p.objects.keys().map(|path| {
let size = self.compressed.get(path).or_else(|| fresh.get(path)).map_or(0, |data| data.len());
(path.as_str(), size)
}),
);
}));
let mut z = ZipWriter::with_entries(entries)?;
z.add(MANIFEST, &p.manifest)?;
for (name, data) in &p.previews {
for (name, data) in &previews {
z.add(name, data)?;
}
for (path, obj) in &p.objects {
@@ -982,6 +1013,7 @@ impl PcraftWriter {
validate_bundle_file(dir, &dir.join(path))?;
}
let p = self.prepare(doc, opts)?;
let previews = previews(opts)?;
let mut stats = p.stats;
if self.verified_directory.as_ref().is_none_or(|cache| cache.directory != canonical_dir) {
self.verified_directory = Some(DirectoryVerificationCache { directory: canonical_dir.clone(), ..Default::default() });
@@ -1041,7 +1073,7 @@ impl PcraftWriter {
}
}
}
for (name, data) in &p.previews {
for (name, data) in &previews {
write_bundle_file(dir, &dir.join(name), data, fs)?;
}
// Pin all GC directories before removing stale previews or objects.
@@ -1068,7 +1100,7 @@ impl PcraftWriter {
// Omitted previews belong to the previous manifest until the new one commits.
// After commit, a locked leftover is only housekeeping, just like unused objects.
for stale in [THUMB, COMPOSITE] {
if !p.previews.iter().any(|(n, _)| *n == stale) {
if !previews.iter().any(|(n, _)| *n == stale) {
let _ = remove_bundle_file(dir, &dir.join(stale), &gc);
}
}
@@ -1196,6 +1228,53 @@ fn compressed_cache_budget_limits_retained_capacity_without_changing_bytes() {
assert!(writer.compressed.is_empty());
}
#[cfg(test)]
#[test]
fn previews_follow_the_manifest_with_the_bytes_of_a_serial_encode() {
use photocraft_doc::{Layer, LayerContent};
use photocraft_geom::Size;
use photocraft_raster::Surface;
let mut doc = Document::new("previews", Size::new(600, 3), photocraft_color::ColorMode::Rgb, SampleType::U8);
let mut surface = Surface::new(doc.pixel_format());
for x in 0..600 {
surface.write_pixel(x, 1, &[x as f32 / 600.0, 0.5, 0.25, 1.0]);
}
doc.layers.push(Layer::new("pixels", LayerContent::Raster(surface)));
let image = |w: u32, h: u32, k: u8| {
let mut i = Rgba8Image::new(w, h);
i.pixels.iter_mut().enumerate().for_each(|(j, p)| *p = (j as u8).wrapping_mul(k));
i
};
let (thumb, composite) = (image(4, 3, 7), image(9, 5, 13));
for (t, c) in [(true, true), (true, false), (false, true), (false, false)] {
let opts = SaveOptions { thumbnail: t.then(|| thumb.clone()), composite: c.then(|| composite.clone()) };
let (bytes, _) = PcraftWriter::new().save_zip(&doc, &opts).unwrap();
let zip = ZipReader::new(&bytes).unwrap();
let names: Vec<&str> = zip.entries.iter().map(|e| e.name.as_str()).collect();
let mut want = vec![MANIFEST];
want.extend(t.then_some(THUMB));
want.extend(c.then_some(COMPOSITE));
assert_eq!(names[..want.len()], want[..]);
assert!(names[want.len()..].iter().all(|n| n.starts_with("tiles/") || n.starts_with("blobs/")), "{names:?}");
for (name, img) in [(THUMB, opts.thumbnail.as_ref()), (COMPOSITE, opts.composite.as_ref())] {
assert_eq!(zip.read_by_name(name, usize::MAX).ok(), img.map(|i| png(i).unwrap()), "{name}");
}
}
let bad = SaveOptions { thumbnail: Some(Rgba8Image { width: 4, height: 3, pixels: vec![0; 5] }), composite: Some(composite.clone()) };
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
let bad = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(Rgba8Image { width: 9, height: 5, pixels: vec![0; 5] }) };
assert!(matches!(PcraftWriter::new().save_zip(&doc, &bad), Err(FormatError::Unsupported(m)) if m.starts_with("preview")));
let dir = std::env::temp_dir().join(format!("pcraft-previews-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let opts = SaveOptions { thumbnail: Some(thumb.clone()), composite: Some(composite.clone()) };
PcraftWriter::new().save_dir(&doc, &dir, &opts).unwrap();
assert_eq!(std::fs::read(dir.join(THUMB)).unwrap(), png(&thumb).unwrap());
assert_eq!(std::fs::read(dir.join(COMPOSITE)).unwrap(), png(&composite).unwrap());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(all(test, unix))]
#[test]
fn gc_pinned_directory_handles_resist_subdirectory_swap() {
+93
View File
@@ -0,0 +1,93 @@
//! Export timings on a realistic photo document (default 7360×4912 = 36 MP): the opened photo,
//! a painted layer covering part of the canvas, and a soft half-transparent layer.
//!
//! ```sh
//! cargo run --release -p photocraft-io --example bench_export -- [--size 7360x4912] [--reps 7] [--only pcraft] [--out DIR]
//! ```
//!
//! Each row is the median (and min) of `--reps` runs. `--out DIR` writes each format's bytes to
//! `DIR/out.<ext>` so two builds' output can be compared.
use std::time::Instant;
use photocraft_doc::Layer;
use photocraft_geom::Rect;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn photo(w: u32, h: u32) -> photocraft_codecs::Image {
let mut px = vec![0u8; w as usize * h as usize * 3];
for (y, row) in px.chunks_mut(w as usize * 3).enumerate() {
for x in 0..w as usize {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
let r = 0.55 + 0.35 * (fx * 6.0).sin() * 0.5 + 0.3 * blob + n * 0.06;
let g = 0.45 + 0.25 * (fy * 9.0 + fx * 2.0).cos() * 0.5 + 0.2 * blob + n * 0.06;
let b = 0.35 + 0.4 * fy - 0.2 * blob + n * 0.06;
for (c, v) in [r, g, b].into_iter().enumerate() {
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
}
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).expect("image")
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))).unwrap_or((7360u32, 4912u32));
let reps: usize = arg(&args, "--reps").and_then(|s| s.parse().ok()).unwrap_or(7);
let only = arg(&args, "--only");
let out = arg(&args, "--out");
let png = photocraft_codecs::encode(&photo(w, h), photocraft_codecs::Format::Png, &Default::default()).expect("png");
let mut doc = photocraft_io::import("photo.png", &png).expect("import").document;
let fmt = doc.pixel_format();
let mut paint = Layer::raster("paint", fmt);
if let Some(s) = paint.surface_mut() {
let (pw, ph) = (w / 2, h / 3);
let data: Vec<f32> = (0..pw * ph)
.flat_map(|i| {
let (x, y) = (i % pw, i / pw);
let a = if (x / 64 + y / 64) % 3 == 0 { 0.0 } else { 0.85 };
[0.75, (x % 256) as f32 / 255.0, (y % 256) as f32 / 255.0, a]
})
.collect();
s.write_region(Rect::from_xywh(w as i32 / 8, h as i32 / 4, pw, ph), &data);
}
doc.layers.push(paint);
let mut soft = Layer::raster("soft", fmt);
soft.opacity = 0.6;
if let Some(s) = soft.surface_mut() {
s.fill_rect(Rect::from_xywh(w as i32 / 2, 0, w / 2, h), &[0.1, 0.2, 0.9, 0.5]);
}
doc.layers.push(soft);
println!("document {w}×{h}, {} layers, {reps} reps (median / min ms)", doc.layers.len());
// "pcraft+cache" is the app's Save: one export cache per document, so the first save
// compresses every tile and later ones reuse what fits the cache (timed after a warm-up save).
for ext in ["psd", "pcraft", "pcraft+cache", "tif", "png", "jpg"] {
if only.as_deref().is_some_and(|o| o != ext) {
continue;
}
let cached = ext == "pcraft+cache";
let opts = photocraft_io::ExportOptions { pcraft_cache: cached.then(photocraft_io::PcraftExportCache::default), ..Default::default() };
let name = format!("x.{}", ext.trim_end_matches("+cache"));
if cached {
photocraft_io::export(&doc, &name, &opts).expect("warm-up");
}
let mut times = Vec::with_capacity(reps);
let mut bytes = Vec::new();
for _ in 0..reps {
let t = Instant::now();
bytes = photocraft_io::export(&doc, &name, &opts).expect("export").bytes;
times.push(t.elapsed().as_secs_f64() * 1000.0);
}
times.sort_by(f64::total_cmp);
println!("{ext:>7}: {:9.1} / {:9.1} ({} bytes)", times[times.len() / 2], times[0], bytes.len());
if let Some(dir) = &out {
std::fs::write(format!("{dir}/out.{}", ext.replace('+', "-")), &bytes).expect("write");
}
}
}
+23 -4
View File
@@ -93,11 +93,30 @@ pub fn deinterleave(bytes: &[u8], ch: usize, s: SampleType, invert: &[bool]) ->
let bps = s.bytes();
let n = bytes.len() / (ch * bps).max(1);
if s == SampleType::U8 {
// Fast path: one plane per thread, no per-sample copies.
return par_map((0..ch).collect(), |c| {
let it = bytes.iter().skip(c).step_by(ch).take(n);
if invert[c] { it.map(|v| 255 - v).collect() } else { it.copied().collect() }
// Fast path: one band of pixels per core, written straight into every plane.
let mut planes = vec![vec![0u8; n]; ch];
let bs = bands(n);
let mut slots: Vec<Vec<&mut [u8]>> = bs.iter().map(|_| Vec::with_capacity(ch)).collect();
for plane in &mut planes {
let mut rest = plane.as_mut_slice();
for (slot, band) in slots.iter_mut().zip(&bs) {
let Some((part, tail)) = rest.split_at_mut_checked(band.len()) else { break };
slot.push(part);
rest = tail;
}
}
par_map(bs.into_iter().zip(slots).collect(), |(band, mut outs)| {
let src = bytes.get(band.start * ch..band.end * ch).unwrap_or(&[]);
for (c, out) in outs.iter_mut().enumerate() {
let px = src.chunks_exact(ch).filter_map(|p| p.get(c).copied());
if invert.get(c).copied().unwrap_or(false) {
out.iter_mut().zip(px).for_each(|(o, v)| *o = 255 - v);
} else {
out.iter_mut().zip(px).for_each(|(o, v)| *o = v);
}
}
});
return planes;
}
// One band of rows per core, appended in band order into pre-grown planes.
let row = ch * bps;
+158 -30
View File
@@ -29,39 +29,58 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
if ctl.cancelled() {
return Err(());
}
// Retain alpha whenever it differs from opaque at the stored precision.
has_alpha |= band.px.iter().any(|p| match sample {
SampleType::U8 => q255(p[3]) < 255,
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
SampleType::F32 => p[3] != 1.0,
});
translucent |= band.px.iter().any(|p| p[3] < 1.0);
let start = (band.rect.y0 - canvas.y0) as usize * w;
// Converted and encoded on all cores, then copied into each plane.
let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| {
// The composite came through the document's CMYK profile: convert back through it too.
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
let mut out: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * bps); cc + 1];
let mut v = [0.0f32; 5];
for p in &band.px[range.clone()] {
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
for c in 0..=cc {
// Matte against white like Photoshop (see `pixels::matte`).
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
encode_be(x, sample, &mut out[c]);
}
}
(range.start, out)
let ranges = crate::pixels::bands(band.px.len());
let mut slots: Vec<Vec<&mut [u8]>> = ranges.iter().map(|_| Vec::with_capacity(cc + 1)).collect();
for chunk in planes.chunks_mut(plane.max(1)).take(cc + 1) {
let Some(mut rest) = chunk.get_mut(start * bps..(start + band.px.len()) * bps) else { continue };
for (slot, range) in slots.iter_mut().zip(&ranges) {
let Some((part, tail)) = rest.split_at_mut_checked(range.len() * bps) else { break };
slot.push(part);
rest = tail;
}
}
// Converted and encoded on all cores. The composite came through the document's CMYK
// profile: convert back through it too.
let flags = crate::pixels::par_map(ranges.into_iter().zip(slots).collect(), |(range, mut out)| {
let px = band.px.get(range).unwrap_or(&[]);
let k = Encode { fmt, cc, matte, cmyk, lab16, white: &white };
photocraft_color::convert::with_cmyk_space(space.as_ref(), || match sample {
SampleType::U8 => k.run(
px,
&mut out,
|a| q255(a) < 255,
|x, d, i| {
if let Some(b) = d.get_mut(i) {
*b = (x.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
},
),
SampleType::U16 => k.run(
px,
&mut out,
|a| (a.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
|x, d, i| {
if let Some(b) = d.get_mut(i * 2..i * 2 + 2) {
b.copy_from_slice(&((x.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_be_bytes());
}
},
),
SampleType::F32 => k.run(
px,
&mut out,
|a| a != 1.0,
|x, d, i| {
if let Some(b) = d.get_mut(i * 4..i * 4 + 4) {
b.copy_from_slice(&x.to_be_bytes());
}
},
),
})
});
for (at, part) in parts {
for (c, src) in part.iter().enumerate() {
let o = c * plane + (start + at) * bps;
planes[o..o + src.len()].copy_from_slice(src);
}
for (a, t) in flags {
has_alpha |= a;
translucent |= t;
}
ctl.progress((band.rect.y1 - canvas.y0) as f32 / rows);
Ok(())
@@ -69,6 +88,38 @@ pub(super) fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte
(planes, has_alpha, translucent)
}
struct Encode<'a> {
fmt: &'a PixelFormat,
cc: usize,
matte: bool,
cmyk: bool,
lab16: bool,
white: &'a [f32],
}
impl Encode<'_> {
fn run(&self, px: &[[f32; 4]], out: &mut [&mut [u8]], stored_translucent: impl Fn(f32) -> bool, put: impl Fn(f32, &mut [u8], usize)) -> (bool, bool) {
let Self { fmt, cc, matte, cmyk, lab16, white } = *self;
let (mut has_alpha, mut translucent) = (false, false);
let mut v = [0.0f32; 5];
for (i, p) in px.iter().enumerate() {
// Retain alpha whenever it differs from opaque at the stored precision.
has_alpha |= stored_translucent(p[3]);
translucent |= p[3] < 1.0;
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
for (c, dst) in out.iter_mut().enumerate() {
// Matte against white like Photoshop (see `pixels::matte`).
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white.get(c).copied().unwrap_or(1.0)) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
put(x, dst, i);
}
}
(has_alpha, translucent)
}
}
/// [`merged_planes`] of a CMYK document whose layers are mixed on their ink values
/// (`photocraft_compose::cmyk`), so opaque pixels keep their exact inks; `None` for documents
/// that composite in RGB. Reports progress and stops on cancel like [`merged_planes`].
@@ -141,3 +192,80 @@ pub(super) fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec<u8> {
}
planes
}
#[cfg(test)]
mod tests {
use super::*;
fn previous_merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec<u8>, bool, bool) {
if cmyk && let Some(planes) = merged_ink_planes(doc, fmt.sample, matte, &Interrupt::NONE) {
return planes;
}
let sample = fmt.sample;
let cc = fmt.mode.color_channels();
let bps = sample.bytes();
let plane = doc.size.area() as usize * bps;
let mut planes = vec![0u8; plane * (cc + 1)];
let (mut has_alpha, mut translucent) = (false, false);
let white = photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0]);
let canvas = doc.bounds();
let w = canvas.width() as usize;
let space = photocraft_compose::cmyk_space(doc);
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
has_alpha |= band.px.iter().any(|p| match sample {
SampleType::U8 => q255(p[3]) < 255,
SampleType::U16 => (p[3].clamp(0.0, 1.0) * 65535.0 + 0.5) as u16 != u16::MAX,
SampleType::F32 => p[3] != 1.0,
});
translucent |= band.px.iter().any(|p| p[3] < 1.0);
let start = (band.rect.y0 - canvas.y0) as usize * w;
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
let mut v = [0.0f32; 5];
for (i, p) in band.px.iter().enumerate() {
photocraft_raster::from_rgba_into(fmt, *p, &mut v);
for c in 0..=cc {
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
let mut b = Vec::new();
encode_be(x, sample, &mut b);
let o = c * plane + (start + i) * bps;
planes[o..o + bps].copy_from_slice(&b);
}
}
});
Ok(())
});
(planes, has_alpha, translucent)
}
#[test]
fn merged_planes_match_the_per_band_reference() {
let size = photocraft_geom::Size::new(37, 23);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] {
for sample in SampleType::ALL {
for alpha_floor in [0.0f32, 0.999_9, 1.0] {
let mut doc = Document::new("merged", size, mode, sample);
let fmt = doc.pixel_format();
let mut s = Surface::new(fmt);
for y in 0..23 {
for x in 0..37 {
let a = (alpha_floor + (1.0 - alpha_floor) * ((x * 7 + y * 3) % 11) as f32 / 10.0).min(1.0);
let rgba = [x as f32 / 36.0, y as f32 / 22.0, ((x + y) % 5) as f32 / 4.0, a];
let mut v = [0.0f32; 5];
let n = photocraft_raster::from_rgba_into(&fmt, rgba, &mut v);
s.write_pixel(x, y, &v[..n]);
}
}
doc.layers.push(Layer::new("pixels", LayerContent::Raster(s)));
for matte in [false, true] {
let got = merged_planes(&doc, &fmt, cmyk_of(&fmt), matte, &Interrupt::NONE);
let want = previous_merged_planes(&doc, &fmt, cmyk_of(&fmt), matte);
assert_eq!(got, want, "{mode:?} {sample:?} alpha >= {alpha_floor} matte {matte}");
}
}
}
}
}
}