From 90efa9a114d87797ccb83ea8c64b766a7da2a572 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:29:59 +0100 Subject: [PATCH 1/3] Add an export benchmark for .pcraft, PSD and flat formats `cargo run --release -p photocraft-io --example bench_export` builds a 36 MP photo document (the opened photo, a painted layer, a soft half-transparent layer) and times `photocraft_io::export` for .psd, .pcraft (with and without the per-document export cache the app's Save uses), TIFF, PNG and JPEG: median and min of --reps runs. --out DIR writes each format's bytes so two builds' output can be compared. Co-Authored-By: Claude Opus 5.5 --- crates/io/examples/bench_export.rs | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 crates/io/examples/bench_export.rs diff --git a/crates/io/examples/bench_export.rs b/crates/io/examples/bench_export.rs new file mode 100644 index 0000000000..bd5685ecc7 --- /dev/null +++ b/crates/io/examples/bench_export.rs @@ -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.` 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 { + 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 = 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 = (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"); + } + } +} From 86245d0719c374b39cd982bedcff2185aa7e7e2c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:29:59 +0100 Subject: [PATCH 2/3] Encode .pcraft previews while the bundle is prepared and compressed A ZIP save encoded the thumbnail and the 1024 px composite preview as PNG on the calling thread before any tile was compressed. The composite alone is a ~200 ms single-threaded deflate on a 36 MP document, about 40 % of a full save. The previews are now encoded on their own threads while the document is collected and its new objects are compressed on all cores; the bundle is assembled afterwards in the same order, so the bytes are unchanged. Directory saves encode them up front as before. wasm stays sequential. bench_export, 36 MP, 3 layers, 16 interleaved rounds of 7 reps (median of medians / min), Ryzen 16 threads, Linux: .pcraft 517.9 / 471.1 ms -> 319.3 / 287.8 ms (-38 %) .pcraft with cache 360.1 / 334.1 ms -> 277.5 / 268.3 ms (-23 %) Co-Authored-By: Claude Opus 5.5 --- crates/format/src/store.rs | 110 +++++++++++++++++++++++++++++++------ 1 file changed, 94 insertions(+), 16 deletions(-) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index fb73819e32..e9c1bf1022 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -64,6 +64,34 @@ fn png(img: &Rgba8Image) -> Result> { photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).map_err(|e| FormatError::Unsupported(format!("preview: {e}"))) } +fn previews(opts: &SaveOptions) -> Result)>> { + 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(parallel: bool, a: impl FnOnce() -> Result + Send, b: impl FnOnce() -> B) -> (Result, 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, objects: BTreeMap, - previews: Vec<(&'static str, Vec)>, 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,23 +932,33 @@ 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, 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>> = 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 z = ZipWriter::new(); z.add(MANIFEST, &p.manifest)?; - for (name, data) in &p.previews { + for (name, data) in &previews { z.add(name, data)?; } 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>> = par_compress(&todo)?.into_iter().collect(); for (path, obj) in &p.objects { let (key, data) = match self.compressed.remove_entry(path) { Some((key, data)) => { @@ -976,6 +1006,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() }); @@ -1035,7 +1066,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. @@ -1062,7 +1093,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); } } @@ -1190,6 +1221,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() { From b23eabf60cb1fd02d43ae2db2398e68d3d61f3e5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:29:59 +0100 Subject: [PATCH 3/3] Write the PSD merged image and 8-bit layer planes in place merged_planes scanned each composite band twice on the calling thread for alpha, encoded every range into per-channel Vecs one push at a time, then copied them into the planes. The workers now write their slice of every plane directly, check alpha as they go, and run a loop monomorphised per sample type. The 8-bit deinterleave of layer pixels used one thread per channel with a strided iterator; it now splits the pixels into one band per core and writes each plane's slice in place. Same arithmetic, same bytes; a test pins merged_planes against the previous per-band implementation for RGB, Gray, Lab and CMYK at 8, 16 and 32 bits, with and without the matte. bench_export .psd, 36 MP, 3 layers, 16 interleaved rounds of 7 reps (median of medians / min), Ryzen 16 threads, Linux: 709.4 / 413.7 ms -> 540.8 / 318.9 ms (-24 % / -23 %) Co-Authored-By: Claude Opus 5.5 --- crates/io/src/pixels.rs | 27 ++++- crates/io/src/psd_export/merged.rs | 188 ++++++++++++++++++++++++----- 2 files changed, 181 insertions(+), 34 deletions(-) diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 15e648df1a..9dff11fcce 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -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> = 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; diff --git a/crates/io/src/psd_export/merged.rs b/crates/io/src/psd_export/merged.rs index da3afe0597..51b2face36 100644 --- a/crates/io/src/psd_export/merged.rs +++ b/crates/io/src/psd_export/merged.rs @@ -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![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> = 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 { } planes } + +#[cfg(test)] +mod tests { + use super::*; + + fn previous_merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) -> (Vec, 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}"); + } + } + } + } + } +}