diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 50e8e220aca..17d8f9faf72 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -13,6 +13,7 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-vector = { workspace = true } +photocraft-cms = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } @@ -21,7 +22,6 @@ rayon = { workspace = true } proptest = { workspace = true } criterion = { workspace = true } photocraft-paint = { workspace = true } -photocraft-cms = { workspace = true } [[bench]] name = "compose" diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 4eedc88619b..ea6c11b5b00 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -1,49 +1,215 @@ -//! Display of Multichannel documents: every spot (ink) channel printed over white paper. +//! Spot inks printed over a picture: every spot (ink) channel of a Multichannel document over +//! white paper, and the visible spot channels of an RGB or CMYK document over its composite +//! (the canvas preview and the PSD thumbnail; never `flatten`, export or the merged image). //! //! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity //! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is -//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document -//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. -//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. +//! how the reference editor previews spot channels. Inks print in Channels panel order. An +//! alpha-type channel of a Multichannel document (selected / masked areas) still prints, as a +//! transparent black ink, like the reference editor shows it. Its value is stored as-is +//! (1 = selected, white in the Channels panel), so its ink is `1 − value`. -use photocraft_doc::Document; +//! This is a screen approximation, not a press proof: stored RGB alternate colours are taken as +//! sRGB and, like Lab/gray alternates, converted into an RGB document's own profile; CMYK +//! alternates use the document CMYK profile. No spectral ink or paper interaction is modelled. +//! At full coverage, solidity 0 multiplies the underlying colour and solidity 1 replaces it. +//! Previewing borrows the document and only changes the supplied display buffer. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, ColorSpace, Profile, Transform, TransformOptions}; +use photocraft_color::convert::CmykSpace; +use photocraft_color::{Color, ColorMode}; +use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; use crate::Buffer; -/// The printed inks of `doc` over `rect` (opaque). -pub fn inks(doc: &Document, rect: Rect) -> Buffer { - let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); - let mut vals = Vec::new(); - for ch in &doc.channels { - let alpha = ch.spot.is_none(); - let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - // An empty alpha channel (all black) is solid black ink, so it can't be skipped. - if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { - continue; - } - let c = ink.to_rgb(); - let s = solidity.clamp(0.0, 1.0); - let n = ch.surface.channels(); - ch.surface.read_region_into(rect, &mut vals); - for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); - if v <= 0.0 { +/// One channel printed as an ink: its display colour in the composite's colour space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ink { + pub rgb: [f32; 3], + pub solidity: f32, + /// An alpha-type channel: black ink where the channel is unselected (`1 − value`). + pub alpha: bool, +} + +impl Ink { + /// The ink of channel `ch` of `doc`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. Printing several inks, build one [`InkSpace`] and use [`InkSpace::ink`]. + pub fn of(doc: &Document, ch: &AlphaChannel) -> Ink { + InkSpace::of(doc).ink(ch) + } + + /// Print this ink with channel values `values` (one per pixel) over the opaque `px`. + pub fn apply(&self, px: &mut [[f32; 4]], values: impl IntoIterator) { + let (c, s) = (self.rgb, self.solidity); + for (p, v) in px.iter_mut().zip(values) { + let v = if self.alpha { 1.0 - v } else { v }; + if v.is_nan() || v <= 0.0 { continue; } + let v = v.min(1.0); let keep = 1.0 - s * v; for j in 0..3 { p[j] = keep * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]; } } } +} + +/// How spot colours of one document map into its composite space. Build it once per render: +/// the document's profiles are parsed here, not per ink or per damage rect. +pub struct InkSpace { + cmyk: Option>, + /// sRGB → an RGB document's own profile, when that differs from sRGB. + to_own: Option>, +} + +impl InkSpace { + pub fn of(doc: &Document) -> InkSpace { + let to_own = if doc.mode == ColorMode::Rgb { + doc.icc_profile + .as_ref() + .and_then(|b| Profile::parse(b).ok()) + .filter(|p| p.color_space == ColorSpace::Rgb && !p.same_colors(Builtin::Srgb.profile())) + .and_then(|own| photocraft_cms::transform::cached(Builtin::Srgb.profile(), &own, TransformOptions::default()).ok()) + } else { + None + }; + InkSpace { cmyk: crate::cmyk_space(doc), to_own } + } + + /// The ink of channel `ch`: a spot channel's colour and solidity, an alpha channel's + /// transparent black. + pub fn ink(&self, ch: &AlphaChannel) -> Ink { + match ch.spot { + Some((color, solidity)) => Ink { rgb: self.rgb(color), solidity: solidity.clamp(0.0, 1.0), alpha: false }, + None => Ink { rgb: [0.0; 3], solidity: 0.0, alpha: true }, + } + } + + /// The display colour of spot colour `color` in the composite space: CMYK colours through the + /// document's CMYK profile (the built-in coated CMYK otherwise), and RGB (taken as sRGB), CMYK, + /// Lab and gray colours of an RGB document converted from sRGB into its own profile, so an ink + /// keeps its colour in a linear (32-bit), Adobe RGB or ProPhoto document. + pub fn rgb(&self, color: Color) -> [f32; 3] { + if matches!(color.mode, ColorMode::Indexed | ColorMode::Multichannel) { + return color.to_rgb(); + } + let mut rgb = photocraft_color::convert::with_cmyk_space(self.cmyk.as_ref(), || color.to_rgb()); + if let Some(t) = &self.to_own { + t.apply(&mut rgb, 3); + } + rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }) + } +} + +/// Print the channels `visible(i)` picks from `doc.channels` (in panel order) as inks over the +/// opaque `buf`, reading them over `buf.rect`. +pub fn overprint(buf: &mut Buffer, doc: &Document, visible: impl Fn(usize) -> bool) { + let rect = buf.rect; + let space = InkSpace::of(doc); + let mut vals = Vec::new(); + for (i, ch) in doc.channels.iter().enumerate() { + if !visible(i) { + continue; + } + let ink = space.ink(ch); + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !ink.alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { + continue; + } + let n = ch.surface.channels().max(1); + ch.surface.read_region_into(rect, &mut vals); + ink.apply(&mut buf.px, vals.chunks_exact(n).map(|v| v[0])); + } +} + +/// Whether channel `i` of `doc` is a spot ink that prints over an RGB or CMYK composite. +pub fn is_overprint_spot(doc: &Document, i: usize) -> bool { + matches!(doc.mode, ColorMode::Rgb | ColorMode::Cmyk | ColorMode::Grayscale | ColorMode::Lab) && doc.channels.get(i).is_some_and(|c| c.spot.is_some()) +} + +/// The printed inks of `doc` over `rect` (opaque). +pub fn inks(doc: &Document, rect: Rect) -> Buffer { + let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); + overprint(&mut buf, doc, |_| true); buf } /// The starting backdrop of a document render: the inks for Multichannel, else transparent. pub(crate) fn backdrop(doc: &Document, rect: Rect) -> Buffer { - if doc.mode == photocraft_color::ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } + if doc.mode == ColorMode::Multichannel { inks(doc, rect) } else { Buffer::transparent(rect) } +} + +/// [`crate::thumbnail_buffer`] of an RGB or CMYK document with its spot channels printed over it +/// (matted over white, so opaque, where there is ink): the document's preview, as the reference +/// editor saves it. +/// Unchanged when no spot channel has ink. +pub fn thumbnail_with_inks(doc: &Document, max_side: u32) -> Buffer { + let mut buf = crate::thumbnail_buffer(doc, max_side); + let spots: Vec = (0..doc.channels.len()) + .filter(|&i| is_overprint_spot(doc, i) && doc.channels.get(i).is_some_and(|c| !c.surface.content_bounds().intersect(&doc.bounds()).is_empty())) + .collect(); + let (w, h) = (buf.rect.width(), buf.rect.height()); + if spots.is_empty() || w == 0 || h == 0 || buf.px.len() != w as usize * h as usize { + return buf; + } + let space = InkSpace::of(doc); + let inks: Vec<(Ink, Vec)> = + spots.into_iter().filter_map(|i| doc.channels.get(i)).map(|ch| (space.ink(ch), reduce(&ch.surface, doc.bounds(), w, h))).collect(); + // Only pixels with ink are matted over white (the inks print on paper); the rest keep their + // transparency. + for (k, p) in buf.px.iter_mut().enumerate() { + if inks.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0)) { + let a = if p[3].is_nan() { 0.0 } else { p[3].clamp(0.0, 1.0) }; + *p = [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0]; + } + } + for (ink, vals) in inks { + ink.apply(&mut buf.px, vals); + } + buf +} + +/// Samples per side of a thumbnail pixel that [`reduce`] averages at most. +const REDUCE_SAMPLES: i32 = 4; + +/// The first channel of `s` over `area`, averaged down to `w`×`h`, one output row at a time. +/// A box up to [`REDUCE_SAMPLES`] pixels a side is averaged whole; a bigger one at that many +/// evenly spaced rows and columns, so a huge document costs about `h × 4` row reads, not a scan. +fn reduce(s: &photocraft_raster::Surface, area: Rect, w: u32, h: u32) -> Vec { + let (sw, sh) = (u64::from(area.width()), u64::from(area.height())); + let n = s.channels().max(1); + let edge = |i: u32, out: u32, len: u64| (u64::from(i) * len / u64::from(out.max(1))) as i32; + // The offsets from a box's start that are sampled. + let picks = |len: i32| -> Vec { + if len <= REDUCE_SAMPLES { (0..len).collect() } else { (0..REDUCE_SAMPLES).map(|j| ((2 * j + 1) * len) / (2 * REDUCE_SAMPLES)).collect() } + }; + let mut out = Vec::with_capacity(w as usize * h as usize); + let mut row = Vec::new(); + let mut sums = vec![(0.0f32, 0u32); w as usize]; + for y in 0..h { + let (y0, y1) = (edge(y, h, sh), edge(y + 1, h, sh)); + sums.iter_mut().for_each(|t| *t = (0.0, 0)); + for dy in picks((y1 - y0).max(1)) { + let sy = area.y0 + y0 + dy; + s.read_region_into(Rect::new(area.x0, sy, area.x1, sy + 1), &mut row); + for (x, t) in sums.iter_mut().enumerate() { + let (x0, x1) = (edge(x as u32, w, sw), edge(x as u32 + 1, w, sw)); + for dx in picks((x1 - x0).max(1)) { + if let Some(v) = row.get((x0 + dx) as usize * n) { + t.0 += v; + t.1 += 1; + } + } + } + } + out.extend(sums.iter().map(|&(sum, count)| if count > 0 { sum / count as f32 } else { 0.0 })); + } + out } #[cfg(test)] @@ -70,7 +236,7 @@ mod tests { assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is - // unselected, as Photoshop shows it. + // unselected, as the reference editor shows it. let mut k = Surface::new(fmt); k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); @@ -83,4 +249,169 @@ mod tests { doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); } + + /// One spot channel of `depth` holding `vals` (a row), ink `rgb` at `solidity`. + fn spot(depth: SampleType, name: &str, rgb: [f32; 3], solidity: f32, vals: &[f32]) -> AlphaChannel { + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_region(Rect::new(0, 0, vals.len() as i32, 1), vals); + AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), solidity)), ..AlphaChannel::new(name, s) } + } + + /// The ink formula written out for one ink over `p`. + fn print(p: [f32; 3], c: [f32; 3], s: f32, v: f32) -> [f32; 3] { + std::array::from_fn(|j| (1.0 - s * v) * p[j] * (1.0 - v + v * c[j]) + s * v * c[j]) + } + + fn close(a: [f32; 4], b: [f32; 3], tol: f32) -> bool { + a[3] == 1.0 && a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) + } + + /// Spot channels never reach the composite of an RGB or CMYK document: flatten, the merged + /// image and export are the same with and without them. + #[test] + fn spots_stay_out_of_rgb_and_cmyk_flatten() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("d", Size::new(4, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let plain = crate::flatten(&doc); + doc.channels.push(spot(depth, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 0.5, 0.0, 1.0])); + doc.channels.push(spot(depth, "pink", [1.0, 0.3, 0.6], 1.0, &[1.0, 1.0, 1.0, 1.0])); + assert_eq!(crate::flatten(&doc).px, plain.px, "{mode:?} {depth:?}"); + let mut bare = doc.clone(); + bare.channels.clear(); + assert_eq!(crate::render(&doc, doc.bounds()).px, crate::render(&bare, doc.bounds()).px, "{mode:?} {depth:?}"); + } + } + } + + /// Two overlapping inks over a coloured picture, at every solidity and coverage, in both + /// orders and at 8 and 16 bits: the second ink prints over the first. + #[test] + fn overprint_follows_the_ink_formula_in_panel_order() { + let (a, b) = ([0.0, 0.6, 0.65], [0.9, 0.2, 0.5]); + let under = [0.8, 0.7, 0.3]; + for depth in [SampleType::U8, SampleType::U16] { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for sa in [0.0, 0.5, 1.0] { + for sb in [0.0, 0.5, 1.0] { + let cover = [0.0, 0.5, 1.0]; + let mut doc = Document::new("o", Size::new(3, 1), mode, depth); + doc.channels.push(spot(depth, "a", a, sa, &cover)); + doc.channels.push(spot(depth, "b", b, sb, &[1.0, 0.5, 0.0])); + for swapped in [false, true] { + if swapped { + doc.channels.reverse(); + } + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |_| true); + let vals: Vec<(f32, f32)> = vec![(0.0, 1.0), (0.5, 0.5), (1.0, 0.0)]; + for (x, &(va, vb)) in vals.iter().enumerate() { + let want = if swapped { print(print(under, b, sb, vb), a, sa, va) } else { print(print(under, a, sa, va), b, sb, vb) }; + // 0.5 is stored as 128/255 at 8 bits. + assert!(close(buf.px[x], want, 0.006), "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}: {:?} vs {want:?}", buf.px[x]); + } + if swapped { + doc.channels.reverse(); + } + } + // Only the channels `visible` picks print. + let mut buf = Buffer::filled(doc.bounds(), [under[0], under[1], under[2], 1.0]); + overprint(&mut buf, &doc, |i| i == 0); + assert!(close(buf.px[2], print(under, a, sa, 1.0), 1e-5)); + } + } + } + } + } + + /// A solid transparent ink over white shows its own colour; a solid opaque one covers any colour. + #[test] + fn solid_inks_show_their_colour() { + let teal = [0.0, 157.0 / 255.0, 165.0 / 255.0]; + let mut doc = Document::new("s", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); + doc.channels.push(spot(SampleType::U8, "teal", teal, 0.0, &[1.0])); + let mut buf = Buffer::filled(doc.bounds(), [1.0; 4]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + doc.channels[0].spot = Some((Color::rgb(teal[0], teal[1], teal[2]), 1.0)); + let mut buf = Buffer::filled(doc.bounds(), [0.2, 0.9, 0.1, 1.0]); + overprint(&mut buf, &doc, |_| true); + assert!(close(buf.px[0], teal, 1e-6), "{:?}", buf.px[0]); + } + + #[test] + fn hidden_spot_leaves_the_process_composite_unchanged() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + let mut doc = Document::with_background("hidden", Size::new(1, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + doc.channels.push(spot(depth, "opaque", [0.0, 0.6, 0.65], 1.0, &[1.0])); + let before = crate::flatten(&doc); + let mut preview = before.clone(); + overprint(&mut preview, &doc, |_| false); + assert_eq!(preview.px, before.px); + overprint(&mut preview, &doc, |_| true); + assert!(close(preview.px[0], [0.0, 0.6, 0.65], 1e-6)); + assert_eq!(crate::flatten(&doc).px, before.px); + assert_eq!(doc.channels[0].spot, Some((Color::rgb(0.0, 0.6, 0.65), 1.0))); + } + } + } + + /// A CMYK spot colour displays through the document's CMYK profile, as the CMYK composite does. + #[test] + fn cmyk_ink_colours_go_through_the_document_profile() { + let doc = Document::new("c", Size::new(1, 1), ColorMode::Cmyk, SampleType::U8); + let cyan = Color { mode: ColorMode::Cmyk, c: [1.0, 0.0, 0.0, 0.0], alpha: 1.0 }; + let space = crate::cmyk_space(&doc); + let want = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cyan.to_rgb()); + assert_eq!(InkSpace::of(&doc).rgb(cyan), want.map(|v| v.clamp(0.0, 1.0))); + // RGB inks are taken as stored. + assert_eq!(InkSpace::of(&doc).rgb(Color::rgb(0.1, 0.2, 0.3)), [0.1, 0.2, 0.3]); + } + + /// An RGB spot colour is sRGB, like the other alternates: an sRGB document takes it as stored, + /// a linear (32-bit) one gets its linear values, which the canvas encodes back to the colour. + #[test] + fn rgb_ink_colours_convert_into_the_document_profile() { + let teal = Color::rgb(0.0, 0.6, 0.65); + let mut doc = Document::new("l", Size::new(1, 1), ColorMode::Rgb, SampleType::F32); + assert_eq!(InkSpace::of(&doc).rgb(teal), [0.0, 0.6, 0.65]); + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let got = InkSpace::of(&doc).rgb(teal); + let want = [0.0, 0.6, 0.65].map(photocraft_color::convert::srgb_to_linear); + assert!(got.iter().zip(&want).all(|(g, w)| (g - w).abs() <= 0.005), "{got:?} vs {want:?}"); + } + + /// The thumbnail prints the spot inks over the composite (matted over white). + #[test] + fn thumbnail_prints_spot_inks() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut doc = Document::new("t", Size::new(4, 1), mode, SampleType::U8); + let plain = thumbnail_with_inks(&doc, 160); + assert_eq!(plain.px, crate::thumbnail_buffer(&doc, 160).px, "no ink: unchanged"); + doc.channels.push(spot(SampleType::U8, "teal", [0.0, 0.6, 0.65], 0.0, &[1.0, 1.0, 0.0, 0.0])); + let t = thumbnail_with_inks(&doc, 160); + assert!(close(t.px[0], [0.0, 0.6, 0.65], 0.003), "{mode:?} {:?}", t.px[0]); + // No ink: the transparent pixel stays transparent (the writer mattes it later). + assert_eq!(t.px[3], plain.px[3], "{mode:?}"); + // Halved: each thumbnail pixel averages two document pixels. + let t = thumbnail_with_inks(&doc, 2); + assert!(close(t.px[0], print([1.0; 3], [0.0, 0.6, 0.65], 0.0, 1.0), 0.003), "{:?}", t.px); + assert_eq!(t.px[1], crate::thumbnail_buffer(&doc, 2).px[1], "no ink: unchanged"); + } + } + + /// A box bigger than the sample grid averages evenly spaced samples: ink on the left half of + /// a 64-pixel box reads half covered. + #[test] + fn reduce_samples_big_boxes() { + let mut sf = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + let vals: Vec = (0..64).map(|x| if x < 32 { 1.0 } else { 0.0 }).collect(); + sf.write_region(Rect::new(0, 0, 64, 1), &vals); + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 1, 1); + assert_eq!(r, [0.5]); + // Small boxes stay exact. + let r = reduce(&sf, Rect::new(0, 0, 64, 1), 16, 1); + assert_eq!(r, (0..16).map(|x| if x < 8 { 1.0 } else { 0.0 }).collect::>()); + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index e0b68a6b51a..aa56917f403 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -21,16 +21,33 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); let w = x1.saturating_sub(x0).max(0) as usize; - let mut row = vec![0u8; w * bpp]; - for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); - for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. - if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { - px.copy_from_slice(from); + // Rows are read in parallel, a chunk at a time, so the scratch stays small. + const CHUNK_ROWS: i32 = 64; + let mut chunk = Vec::new(); + let mut y = y0; + while y < y1 { + let end = y.saturating_add(CHUNK_ROWS).min(y1); + chunk.clear(); + chunk.resize(w * (end - y) as usize * bpp, 0u8); + let fill = |(row, oy): (&mut [u8], i32)| { + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); + for (i, px) in row.chunks_exact_mut(bpp).enumerate() { + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } + }; + let rows = (w * bpp).max(1); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + chunk.par_chunks_mut(rows).zip(y..end).for_each(fill); } - out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); + #[cfg(target_arch = "wasm32")] + chunk.chunks_mut(rows).zip(y..end).for_each(fill); + out.write_interleaved(Rect::new(x0, y, x1, end), &chunk); + y = end; } out } diff --git a/crates/engine/src/cmd/channel_cmds/tests.rs b/crates/engine/src/cmd/channel_cmds/tests.rs index a640a7b45b7..b7f2074b988 100644 --- a/crates/engine/src/cmd/channel_cmds/tests.rs +++ b/crates/engine/src/cmd/channel_cmds/tests.rs @@ -1186,6 +1186,25 @@ fn layers_panel_mask_target_isolates_the_curve_channel() { assert_eq!((row.name.as_str(), row.gray), ("Quick Mask", Some(GraySurface::QuickMask))); } +/// An opened RGB or CMYK document shows its spot channels (they print over the composite) and +/// hides its alpha channels; a Multichannel document's inks are its composite, so their eyes stay off. +#[test] +fn opened_documents_show_their_spot_channels() { + use photocraft_color::{Color, ColorMode, PixelFormat}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Multichannel] { + let mut d = Document::new("spots", photocraft_doc::Size::new(4, 4), mode, SampleType::U8); + let fmt = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + d.channels.push(AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(fmt))); + d.channels.push(AlphaChannel { spot: Some((Color::rgb(0.0, 0.6, 0.65), 0.0)), ..AlphaChannel::new("teal", photocraft_raster::Surface::new(fmt)) }); + let mut s = Session::new(); + s.open_document(d, None); + let v = &s.active().unwrap().channel_view; + let spot_on = mode != ColorMode::Multichannel; + assert_eq!((v.alpha_shown(0), v.alpha_shown(1)), (false, spot_on), "{mode:?}"); + assert_eq!(v.visible_colors(color_count(&s.active().unwrap().doc)), color_count(&s.active().unwrap().doc), "{mode:?}: the composite stays on"); + } +} + /// Split Channels closes the original, so as in the reference application it splits only an /// image with a single layer: two layers (even with one hidden) or a lone group are refused and /// the document stays open with its layers; a flattened image or a lone normal layer still diff --git a/crates/engine/src/cmd/color_cmds.rs b/crates/engine/src/cmd/color_cmds.rs index 5fb2d7d0f2e..a93c580829d 100644 --- a/crates/engine/src/cmd/color_cmds.rs +++ b/crates/engine/src/cmd/color_cmds.rs @@ -397,16 +397,16 @@ impl ColorState { pub fn display_transform_for(&self, doc: &Document, display: Option) -> Result> { let src = self.canvas_display_for(doc, display)?.source.clone(); let dst = self.monitor_for(display); - let pv = self.proof(doc.id); - let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); + // Borrowed: the canvas asks per frame, and the default proof state allocates a profile. + let pv = self.proof_ref(doc.id).filter(|pv| pv.enabled); + let proof = pv.map(|pv| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); let key = (src.content_hash(), dst.content_hash(), proof); if let Some(t) = self.display_cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return Ok(t.clone()); } - let t = if pv.enabled { - Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper) - } else { - Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC) + let t = match pv { + Some(pv) => Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper), + None => Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC), } .map_err(cms_err)?; let t = Arc::new(t); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a8f50c30840..e10f5be9844 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -82,6 +82,11 @@ impl DocState { let mut history = History::default(); let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }; history.set_current_layers(target.clone()); + // Spot channels open visible, printing over the composite; alpha channels open hidden. + let channel_view = channel_cmds::ChannelView { + alpha_visible: (0..doc.channels.len()).map(|i| photocraft_compose::multichannel::is_overprint_spot(&doc, i)).collect(), + ..Default::default() + }; let doc = Arc::new(doc); // Photoshop's first snapshot: the document as opened, named after it, so the History // panel's top row can always bring it back (#2966). @@ -100,7 +105,7 @@ impl DocState { view_revision: 0, last_damage: None, coalesce: None, - channel_view: Default::default(), + channel_view, isolated_layers: Vec::new(), symmetry: None, fx_collapsed: Vec::new(), diff --git a/crates/io/src/psd_thumbnail.rs b/crates/io/src/psd_thumbnail.rs index f2165dbb87b..e40c782ef0a 100644 --- a/crates/io/src/psd_thumbnail.rs +++ b/crates/io/src/psd_thumbnail.rs @@ -20,10 +20,11 @@ pub const MAX_SIDE: u32 = 160; /// Size of the header in front of the JFIF data. pub const HEADER_LEN: usize = 28; -/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`], converted to -/// sRGB through the document's profile, matted over white and JPEG-compressed. +/// The 1036 resource for `doc`: its composite reduced to at most [`MAX_SIDE`] with its spot inks +/// printed over it, converted to sRGB through the document's profile, matted over white and +/// JPEG-compressed. pub(crate) fn resource(doc: &Document) -> Result { - let mut buf = photocraft_compose::thumbnail_buffer(doc, MAX_SIDE); + let mut buf = photocraft_compose::multichannel::thumbnail_with_inks(doc, MAX_SIDE); let (w, h) = (buf.rect.width(), buf.rect.height()); if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || buf.px.len() != (w as usize) * (h as usize) { return Err(IoError::Unsupported("the image preview could not be rendered".into())); diff --git a/crates/io/tests/it/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs index 7445bbf7574..be6fbd360bd 100644 --- a/crates/io/tests/it/psd_thumbnail.rs +++ b/crates/io/tests/it/psd_thumbnail.rs @@ -151,3 +151,29 @@ fn the_document_profile_is_honoured() { let mid = px[25 * 50 + 25]; assert!(near(mid, [0.735; 3], 0.03), "{mid:?}"); } + +/// The thumbnail shows the document's spot inks over its composite, as the reference editor's +/// does (psd-tools cmyk-alpha-spot.psd: its spot-only text is in the preview, not in the merged +/// image). +#[test] +fn spot_inks_print_in_the_thumbnail() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut d = doc(mode, SampleType::U8, 64, 32, [1.0, 1.0, 1.0, 1.0], 1.0); + let fmt = photocraft_color::PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut ink = photocraft_raster::Surface::new(fmt); + ink.fill_rect(photocraft_geom::Rect::new(0, 0, 32, 32), &[1.0]); + let teal = photocraft_color::Color::rgb(0.0, 157.0 / 255.0, 165.0 / 255.0); + d.channels.push(photocraft_doc::AlphaChannel { spot: Some((teal, 0.0)), ..photocraft_doc::AlphaChannel::new("light teal", ink) }); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let (w, _, px) = thumbnail(&bytes).unwrap(); + let (inked, bare) = (px[(16 * w + 8) as usize], px[(16 * w + 56) as usize]); + assert!(near(inked, [0.0, 157.0 / 255.0, 165.0 / 255.0], 0.06), "{mode:?} {inked:?}"); + assert!(near(bare, [1.0, 1.0, 1.0], 0.04), "{mode:?} {bare:?}"); + // The merged process planes stay free of the ink: white (no CMYK ink is stored as 255 too). + let merged = PsdFile::from_bytes(&bytes).unwrap().decode_merged().unwrap(); + let plane = 64 * 32; + for c in 0..mode.color_channels() { + assert_eq!(merged[c * plane + 16 * 64 + 8], 255, "{mode:?} merged channel {c}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 51c97140759..3cdd87d26b3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -506,7 +506,7 @@ fn paint_canvas( } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). - if let Some(tex) = crate::channel_view::ensure(app, ctx, idx) { + if let Some(tex) = crate::channel_view::ensure(app, ctx, idx, output) { // Rendered from the live preview, so over the area the preview is shown in. let a = shown_area; if view.rotation.abs() > 0.05 { @@ -2220,12 +2220,12 @@ mod rotation_preview_isolation_tests { .unwrap(); let before = app.session.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); ensure_filter_preview(&mut app, 0, &ctx).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; let st = app.session.active().unwrap(); @@ -2235,16 +2235,16 @@ mod rotation_preview_isolation_tests { delta.clear(); assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; assert_eq!(image.pixels, original); delta.clear(); app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); ctx.tex_manager().write().take_delta().clear(); crate::dialogs::cancel(&mut app, dialog).unwrap(); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; assert_eq!(image.pixels, original); @@ -2382,11 +2382,11 @@ mod rotation_preview_isolation_tests { view.set_color_visible(2, false); } let ctx = egui::Context::default(); - let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); - crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); let mut delta = ctx.tex_manager().write().take_delta(); let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; crate::dialogs::confirm(&mut app, dialog).unwrap(); diff --git a/crates/ui-egui/src/canvas/channel_view.rs b/crates/ui-egui/src/canvas/channel_view.rs index 4d7602e4a1a..6a15fb9bdd7 100644 --- a/crates/ui-egui/src/canvas/channel_view.rs +++ b/crates/ui-egui/src/canvas/channel_view.rs @@ -1,11 +1,18 @@ -//! Channel display on the canvas: alpha / spot / Quick Mask colour overlays, single colour -//! channel views (grayscale) and hidden colour channels. +//! Channel display on the canvas: alpha / Quick Mask colour overlays, spot inks printed over the +//! composite, single colour channel views (grayscale) and hidden colour channels. //! //! The document composite (GPU or CPU) is untouched. When the Channels panel shows anything other //! than the plain composite, one extra texture is drawn over it: transparent overlay pixels when -//! the composite stays visible, an opaque picture otherwise. The texture is keyed by document -//! revision and view state and updated per damage rectangle while painting, so nothing is -//! recomposited per frame. +//! the composite stays visible, an opaque picture with hidden colour channels. Visible spot +//! channels of an RGB or CMYK document print over the sampled composite where they have ink +//! (opaque, through the canvas display transform); elsewhere the texture stays transparent, so the +//! normal canvas shows. This is a screen approximation, not a press proof: each ink is its stored +//! display colour blended by the Multichannel overprint formula (multiply at solidity 0, cover at +//! solidity 1) over the sampled composite. A solo spot (all colour channels hidden) shows as its +//! grayscale values instead. The texture is keyed by document revision, view state and display, and +//! updated per damage rectangle while painting, so nothing is recomposited per frame. + +use std::borrow::Cow; use egui::{Color32, TextureOptions}; use photocraft_doc::{AlphaChannel, ColorMode, Document}; @@ -33,6 +40,8 @@ struct SampledComposite { struct CompositeSource<'a> { cached: Option<&'a [[f32; 4]]>, fresh: &'a mut Option>, + /// What printed spot inks go through on their way to the screen. + screen: &'a Screen, } pub struct Cache { @@ -44,7 +53,8 @@ pub struct Cache { /// False while the plain composite shows: the texture is kept (so toggling a view back on /// reuses its allocation) but its pixels are stale. valid: bool, - tex: egui::TextureHandle, + /// `None` while the view draws nothing over the canvas (visible spot channels without ink). + tex: Option, source: Option, } @@ -90,9 +100,80 @@ impl Look { } } +/// What the canvas does to composite values on their way to the screen: the sRGB encoding of +/// linear composites, then the monitor transform (with the soft proof under View › Proof Colors). +/// Spot inks printed over the composite go through it, so they match the canvas around them. +#[derive(Clone, Default)] +pub struct Screen { + pub encode_srgb: bool, + pub transform: Option>, +} + +impl Screen { + /// How the canvas shows `doc` on `display` (`None`: the main window's). + pub fn of(app: &PhotocraftApp, doc: &Document, display: Option) -> Screen { + let color = &app.session.color; + let display = display.or(color.main_display); + let Ok(d) = color.canvas_display_for(doc, display) else { return Screen::default() }; + let proof = color.proof_ref(doc.id).is_some_and(|p| p.enabled); + let transform = if proof || d.transform.is_some() { color.display_transform_for(doc, display).ok() } else { None }; + Screen { encode_srgb: d.encode_srgb, transform } + } + + /// A key that changes with [`Screen::of`]. + pub fn key(app: &PhotocraftApp, doc: &Document, display: Option) -> u64 { + let color = &app.session.color; + color.display_signature_for(doc, display.or(color.main_display)) + } + + /// Opaque composite pixels `px` as monitor values. + fn apply(&self, px: &mut [[f32; 4]]) { + if self.encode_srgb { + for p in px.iter_mut() { + for v in &mut p[..3] { + *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0)); + } + } + } + if let Some(t) = &self.transform { + t.apply(px.as_flattened_mut(), 4); + } + } +} + /// Composite RGBA (over white, like Photoshop's channel views) at `factor` stride. /// Render in bands instead of allocating a full-resolution float composite to sample a preview. fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { + let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); + // A downsampled document (#10249): the whole canvas at `factor` from a proxy picks the same + // pixels (`downsample` keeps every factor-th one) for ~1/factor² of the compositing. + if factor > 1 && r == doc.bounds() && proxy_matches(doc) { + // Only Multichannel composites read the extra channels; the selection and Quick Mask + // never show in a composite. Left out, they cost nothing to shrink. + let mut layers_only = doc.clone(); + if doc.mode != ColorMode::Multichannel { + layers_only.channels.clear(); + } + layers_only.selection = None; + layers_only.quick_mask = None; + let p = photocraft_compose::proxy::proxy_document(&layers_only, factor); + let mut out = Vec::with_capacity((w * h) as usize); + let _ = photocraft_compose::render_bands(&p, Rect::new(0, 0, w as i32, h as i32), 0, |band| -> Result<(), ()> { + out.extend(band.px.iter().map(|p| { + let a = p[3].clamp(0.0, 1.0); + [p[0] * a + 1.0 - a, p[1] * a + 1.0 - a, p[2] * a + 1.0 - a, 1.0] + })); + Ok(()) + }); + if out.len() == (w * h) as usize { + return out; + } + } + composite_sampled(doc, r, factor) +} + +/// [`composite`] sampled from the full-resolution composite, rendered in bands. +fn composite_sampled(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let mut out = Vec::with_capacity((w * h) as usize); let f = factor as i32; @@ -110,11 +191,86 @@ fn composite(doc: &Document, r: Rect, factor: u32) -> Vec<[f32; 4]> { out } +/// The samples of `r` (see [`composite`]): `cached` when it matches, else rendered, and kept in +/// `computed` for the cache when that is not too big. +fn composite_px<'a>(doc: &Document, r: Rect, factor: u32, cached: Option<&'a [[f32; 4]]>, computed: &mut Option>) -> Cow<'a, [[f32; 4]]> { + let n = r.width().div_ceil(factor) as usize * r.height().div_ceil(factor) as usize; + if let Some(px) = cached.filter(|px| px.len() == n) { + return Cow::Borrowed(px); + } + let px = composite(doc, r, factor); + if n <= MAX_COMPOSITE_CACHE_PIXELS { + *computed = Some(px.clone()); + } + Cow::Owned(px) +} + +/// The samples of `r` (see [`composite`]) at the grid indices `inked` (ascending), in that order. +/// A few inked pixels of a big document render just their bounding box; when that costs no less +/// than the whole composite (a proxy renders it at about 1/factor² of the pixels), the whole one +/// is used, from `cached` or rendered and kept in `computed` for the cache. +fn composite_at(doc: &Document, r: Rect, factor: u32, inked: &[usize], cached: Option<&[[f32; 4]]>, computed: &mut Option>) -> Vec<[f32; 4]> { + let w = r.width().div_ceil(factor) as usize; + let n = w * r.height().div_ceil(factor) as usize; + let pick = |px: &[[f32; 4]]| -> Vec<[f32; 4]> { inked.iter().filter_map(|&k| px.get(k).copied()).collect() }; + if let Some(px) = cached.filter(|px| px.len() == n) { + return pick(px); + } + let (Some(&first), Some(&last)) = (inked.first(), inked.last()) else { return Vec::new() }; + if w == 0 { + return Vec::new(); + } + // The inked samples' bounding box, in samples, then in document pixels (on the same grid). + let (bx0, bx1) = inked.iter().fold((usize::MAX, 0), |(lo, hi), &k| (lo.min(k % w), hi.max(k % w + 1))); + let (by0, by1) = (first / w, last / w + 1); + let f = factor as usize; + let at = |o: i32, s: usize| o.saturating_add((s * f) as i32); + let area = Rect::new(at(r.x0, bx0), at(r.y0, by0), at(r.x0, bx1 - 1).saturating_add(1).min(r.x1), at(r.y0, by1 - 1).saturating_add(1).min(r.y1)); + let proxy = factor > 1 && r == doc.bounds() && proxy_matches(doc); + let whole_cost = r.size().area() / if proxy { u64::from(factor) * u64::from(factor) } else { 1 }; + if area.size().area() < whole_cost { + let (px, bw) = (composite_sampled(doc, area, factor), bx1 - bx0); + return inked.iter().filter_map(|&k| px.get((k / w - by0) * bw + k % w - bx0).copied()).collect(); + } + pick(&composite_px(doc, r, factor, None, computed)) +} + +/// Whether `v` prints spot inks of `doc` over its composite, through the [`Screen`]. +fn prints_inks(doc: &Document, v: &ChannelView) -> bool { + let colors = color_count(doc); + v.visible_colors(colors) == colors && (0..doc.channels.len()).any(|i| v.alpha_shown(i) && photocraft_compose::multichannel::is_overprint_spot(doc, i)) +} + +/// Whether a proxy composite picks the full composite's pixels: no layer effects or vector masks +/// ([`proxy_faithful`](photocraft_compose::proxy::proxy_faithful)), no feathered masks, whose +/// radius is in document pixels, and no pattern fills, whose tiles keep their size on a proxy +/// unless a cache of the same fill is shrunk with it. +fn proxy_matches(doc: &Document) -> bool { + use photocraft_doc::{Fill, LayerContent}; + // A stack, not recursion: groups can nest deeply. Hidden layers (and what they hold) are not + // composited, so they don't count. + let mut stack: Vec<&photocraft_doc::Layer> = doc.layers.iter().filter(|l| l.visible).collect(); + while let Some(l) = stack.pop() { + if photocraft_compose::masks::has_feather(l) { + return false; + } + if let LayerContent::Fill(fill @ Fill::Pattern { .. }) = &l.content + && l.fill_cache.as_ref().is_none_or(|c| c.fill != *fill) + { + return false; + } + if let LayerContent::Group(g) = &l.content { + stack.extend(g.children.iter().filter(|c| c.visible)); + } + } + photocraft_compose::proxy::proxy_faithful(doc) +} + /// The channel view of `r` (document pixels, sampled every `factor` px) as premultiplied pixels, /// or `None` when the plain composite is all there is to show. pub fn render(doc: &Document, v: &ChannelView, r: Rect, factor: u32, look: Look, active: Option) -> Option> { let mut unused = None; - render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused }) + render_with_source(doc, v, r, factor, look, active, CompositeSource { cached: None, fresh: &mut unused, screen: &Screen::default() }) } fn render_with_source( @@ -161,16 +317,41 @@ fn render_with_source( let vis = v.visible_colors(colors); let (w, h) = (r.width().div_ceil(factor), r.height().div_ceil(factor)); let n = (w * h) as usize; - let mut overlays: Vec<&AlphaChannel> = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).map(|i| &doc.channels[i]).collect(); + let mut overlays: Vec = (0..doc.channels.len()).filter(|i| v.alpha_shown(*i)).collect(); let gray = |s: &Surface| sample(s, r, factor).into_iter().map(|g| [g, g, g, 1.0]).collect(); + // The sampled composite over white: the cached one when it matches, else rendered here (and + // handed back to the cache). + let mut computed = None; + // Visible spot channels of an RGB or CMYK document print over the picture as inks, in panel + // order, before the alpha channel and Quick Mask tints. + let spots: Vec<(photocraft_compose::multichannel::Ink, Vec)> = if vis > 0 { + let spot_ids: Vec = overlays.iter().copied().filter(|&i| photocraft_compose::multichannel::is_overprint_spot(doc, i)).collect(); + overlays.retain(|i| !spot_ids.contains(i)); + // The ink colours are converted once per render, and only when there are inks to print. + let space = (!spot_ids.is_empty()).then(|| photocraft_compose::multichannel::InkSpace::of(doc)); + space.iter().flat_map(|space| spot_ids.iter().filter_map(|&i| doc.channels.get(i)).map(|ch| (space.ink(ch), sample(&ch.surface, r, factor)))).collect() + } else { + Vec::new() + }; + // Over the whole composite, spot inks print (#10249) only where they have ink. + let inked: Vec = if vis == colors && !spots.is_empty() { + (0..n).filter(|&k| spots.iter().any(|(_, vals)| vals.get(k).is_some_and(|v| *v > 0.0))).collect() + } else { + Vec::new() + }; + // No ink, and no alpha channel, Quick Mask or layer mask shown over the composite: nothing to + // draw, so no all-transparent texture either. + if vis == colors && inked.is_empty() && overlays.is_empty() && (doc.quick_mask.is_none() || v.quick_mask_hidden) && shown_mask.is_none() { + return None; + } // Premultiplied RGBA accumulator. With no colour channel left, the channel being edited - // (alpha, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. + // (alpha, spot, Quick Mask, or layer mask) is the picture — grayscale, not a rubylith over nothing. let mut solo_quick = false; let mut solo_mask = false; let mut out: Vec<[f32; 4]> = if vis == 0 { if !overlays.is_empty() { let first = overlays.remove(0); - gray(&first.surface) + gray(&doc.channels.get(first)?.surface) } else if let Some(q) = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden) { solo_quick = true; gray(&q.surface) @@ -186,12 +367,10 @@ fn render_with_source( let fmt = doc.pixel_format(); let mode = fmt.mode; let shown: Vec = (0..colors).filter(|k| v.color_visible(*k)).collect(); - let computed = if source.cached.is_some_and(|px| px.len() == n) { None } else { Some(composite(doc, r, factor)) }; - let pixels = source.cached.filter(|px| px.len() == n).or(computed.as_deref())?; - let converted = pixels + let pixels = composite_px(doc, r, factor, source.cached, &mut computed); + pixels .iter() - .copied() - .map(|rgba| { + .map(|&rgba| { let mut native = from_rgba(&fmt, rgba); if shown.len() == 1 && !in_color { let x = native[shown[0]]; @@ -203,21 +382,40 @@ fn render_with_source( if !v.color_visible(k) { // Hidden channels contribute nothing (Lab: neutral a/b, and a neutral // Lightness under Show Channels in Color, so an isolated a/b channel - // tints over mid gray instead of black, as Photoshop does). + // tints over mid gray instead of black, as the reference editor does). *x = if mode == ColorMode::Lab && (k > 0 || in_color) { 0.5 } else { 0.0 }; } } let c = to_rgba(&fmt, &native); [c[0], c[1], c[2], 1.0] }) - .collect(); - if n <= MAX_COMPOSITE_CACHE_PIXELS { - *source.fresh = computed; - } - converted + .collect() } else { vec![[0.0; 4]; n] }; + if !inked.is_empty() { + // The inked pixels become the opaque printed picture, the rest stay transparent so the + // normal canvas (display LUT, proofing, full resolution) shows. Only the inked area of the + // composite is rendered. + let mut printed = composite_at(doc, r, factor, &inked, source.cached, &mut computed); + if printed.len() == inked.len() { + for (ink, vals) in &spots { + ink.apply(&mut printed, inked.iter().map(|&k| vals.get(k).copied().unwrap_or(0.0))); + } + source.screen.apply(&mut printed); + for (&k, p) in inked.iter().zip(printed) { + if let Some(o) = out.get_mut(k) { + *o = p; + } + } + } + } else if vis < colors { + for (ink, vals) in &spots { + ink.apply(&mut out, vals.iter().copied()); + } + } + *source.fresh = computed; + let overlays: Vec<&AlphaChannel> = overlays.into_iter().filter_map(|i| doc.channels.get(i)).collect(); // Rubylith: `color` at `opacity` × coverage over the accumulated pixels. let mut tint = |values: Vec, color: photocraft_color::Color, opacity: f32, coverage: &dyn Fn(f32) -> f32| { let c = color.to_rgb(); @@ -231,8 +429,9 @@ fn render_with_source( }; let quick = doc.quick_mask.as_ref().filter(|_| !v.quick_mask_hidden && !solo_quick); for ch in overlays.into_iter().chain(quick) { + // Spot channels reach here only in Multichannel documents, whose composite already prints + // them: they show more solid as solidity rises. let (color, opacity) = match ch.spot { - // Spot inks show more solid as solidity rises. Some((ink, solidity)) => (ink, 0.5 + 0.5 * solidity), None => (ch.color, ch.opacity), }; @@ -284,8 +483,9 @@ fn view_key(v: &ChannelView, look: Look) -> u64 { s.bytes().fold(look.in_color as u64 + 1, |h, b| h.wrapping_mul(1_099_511_628_211).wrapping_add(b as u64)) } -/// Make the channel-view texture of document `idx` current; `None` = nothing to draw. -pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { +/// Make the channel-view texture of document `idx`, shown on `display` (`None`: the main +/// window's), current; `None` = nothing to draw. +pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, display: Option) -> Option { let st = app.session.documents().get(idx)?; let view = st.channel_view.clone(); let active = st.active_layer; @@ -306,7 +506,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // not just the panel thumbnails (#2385). The view key carries the flag and the Layer Mask // Display Options, so changing them rebuilds the texture. let look = Look::of(&app.session); - let key = view_key(&view, look); + // Printed spot inks go through the display transform, so its key is part of the view's. + let inks = prints_inks(&doc, &view); + let screen_key = if inks { Screen::key(app, &doc, display) } else { 0 }; + let key = view_key(&view, look) ^ screen_key.rotate_left(17); // Never exceed what the GPU accepts (egui panics on oversized textures). let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE); let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1); @@ -326,14 +529,17 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio && c.revision == revision && c.preview_key == preview_key { - return Some(c.tex.id()); + return c.tex.as_ref().map(|t| t.id()); } + // The display transform itself is looked up only when the view is redrawn. + let screen = if inks { Screen::of(app, &doc, display) } else { Screen::default() }; let damage = app.channel_views.get(&id).and_then(|c| crate::canvas::damage_since(app, idx, (c.revision, c.preview_key), (revision, preview_key), 0, last_damage)); if let Some(c) = app.channel_views.get_mut(&id) && c.key == key && c.factor == factor && c.valid + && let Some(tex) = c.tex.as_mut() && let Some(d) = damage { // Re-render just the damage, snapped to the sampling grid so a downsampled texture @@ -341,26 +547,48 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio let f = factor as i32; let d = d.intersect(&doc.bounds()); let r = Rect::new(d.x0.div_euclid(f) * f, d.y0.div_euclid(f) * f, d.x1, d.y1).intersect(&doc.bounds()); - if !r.is_empty() - && let Some(px) = render(&doc, &view, r, factor, look, active) - { - let img = egui::ColorImage::new([r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize], px); - c.tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], img, TextureOptions::LINEAR); + if !r.is_empty() { + let size = [r.width().div_ceil(factor) as usize, r.height().div_ceil(factor) as usize]; + // Nothing to draw there (the ink erased) still clears what the texture showed. + let px = render_with_source(&doc, &view, r, factor, look, active, CompositeSource { cached: None, fresh: &mut None, screen: &screen }) + .unwrap_or_else(|| vec![Color32::TRANSPARENT; size[0] * size[1]]); + tex.set_partial([(r.x0 / f) as usize, (r.y0 / f) as usize], egui::ColorImage::new(size, px), TextureOptions::LINEAR); } + let tex_id = tex.id(); c.revision = revision; c.preview_key = preview_key; - return Some(c.tex.id()); + return Some(tex_id); } let b = doc.bounds(); let cached = app.channel_views.get(&id).and_then(|c| c.source.as_ref()).map(|s| s.pixels.as_slice()); let mut fresh = None; - let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh })?; + let px = render_with_source(&doc, &view, b, factor, look, active, CompositeSource { cached, fresh: &mut fresh, screen: &screen }); + let Some(px) = px else { + // Nothing to draw (visible spot channels without ink): remembered, so the next frames + // don't look again, and the texture is dropped. + match app.channel_views.get_mut(&id) { + Some(c) => { + c.tex = None; + c.revision = revision; + c.preview_key = preview_key; + c.key = key; + c.factor = factor; + c.valid = true; + } + None => { + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: None, source: None }); + } + } + return None; + }; let new_source = fresh.is_some(); let size = [b.width().div_ceil(factor) as usize, b.height().div_ceil(factor) as usize]; let img = egui::ColorImage::new(size, px); match app.channel_views.get_mut(&id) { - Some(c) if c.tex.size() == size => { - c.tex.set(img, TextureOptions::LINEAR); + Some(c) if c.tex.as_ref().is_some_and(|t| t.size() == size) => { + if let Some(t) = c.tex.as_mut() { + t.set(img, TextureOptions::LINEAR); + } c.revision = revision; c.preview_key = preview_key; c.key = key; @@ -372,8 +600,10 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }); - app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); + // A composite still current (checked above) is kept when none was rendered. + let kept = app.channel_views.remove(&id).and_then(|c| c.source); + let source = fresh.map(|pixels| SampledComposite { revision, content, preview_key, factor, pixels }).or(kept); + app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex: Some(tex), source }); } } if new_source { @@ -383,7 +613,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio } } } - app.channel_views.get(&id).map(|c| c.tex.id()) + app.channel_views.get(&id).and_then(|c| c.tex.as_ref()).map(|t| t.id()) } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/channel_view/tests.rs b/crates/ui-egui/src/canvas/channel_view/tests.rs index d8581ebbbb9..fa5d84e1861 100644 --- a/crates/ui-egui/src/canvas/channel_view/tests.rs +++ b/crates/ui-egui/src/canvas/channel_view/tests.rs @@ -41,7 +41,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { app.sync_views(); let ctx = egui::Context::default(); app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let id = app.session.active().unwrap().doc.id.0; let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); let original_sample = first.pixels[0]; @@ -49,19 +49,19 @@ fn channel_eyes_reuse_composite_until_pixels_change() { first.pixels[0] = [0.0, 1.0, 0.0, 1.0]; app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); assert_eq!(reused.pixels[0], [0.0, 1.0, 0.0, 1.0], "visibility does not recompose the pixels"); app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); // A partial damage upload discards the source; a full redraw may replace it. // In either case, cached samples must never refer to the pre-edit pixels. if let Some(source) = app.channel_views.get(&id).unwrap().source.as_ref() { assert_ne!(source.pixels[0], original_sample, "a full redraw replaces stale pixels"); } app.run("channel.setVisible", json!({"channel": "red", "visible": true})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let refreshed = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); let st = app.session.active().unwrap(); assert_eq!(refreshed.revision, st.revision); @@ -75,7 +75,7 @@ fn channel_eyes_reuse_composite_until_pixels_change() { 1, Look::default(), st.active_layer, - CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused }, + CompositeSource { cached: Some(&refreshed.pixels), fresh: &mut unused, screen: &Screen::default() }, ) .unwrap(); assert_eq!(actual, expected, "reused pixels must preserve channel colours"); @@ -285,15 +285,15 @@ fn ensure_honours_show_channels_in_color() { app.sync_views(); app.run("channel.target", json!({"channel": "green"})).unwrap(); let ctx = egui::Context::default(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_off = app.channel_views.values().next().unwrap().key; app.session.execute("prefs.set", json!({"values": {"interface.showChannelsInColor": true}})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); let key_on = app.channel_views.values().next().unwrap().key; assert_ne!(key_off, key_on, "the preference changes the cached view key"); // So do the Layer Mask Display Options. app.session.execute("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 70})).unwrap(); - assert!(ensure(&mut app, &ctx, 0).is_some()); + assert!(ensure(&mut app, &ctx, 0, None).is_some()); assert_ne!(app.channel_views.values().next().unwrap().key, key_on, "the overlay options change the cached view key"); } @@ -349,3 +349,426 @@ fn rgb_off_shows_alpha_quick_mask_or_the_layer_mask() { let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).unwrap(); assert_eq!(px[0], Color32::BLACK, "the hidden mask reads black, not the red layer"); } + +/// A white-backed `mode`/`depth` document, 3×1, with a 0.8/0.4/0.2 picture and two overlapping +/// spot inks (`a` covers 1, ½, 0 of the pixels; `b` the reverse). +fn spot_doc(mode: ColorMode, depth: photocraft_color::SampleType, sa: f32, sb: f32) -> Document { + use photocraft_color::{Color, PixelFormat}; + let mut doc = Document::with_background("spots", photocraft_geom::Size::new(3, 1), mode, depth, Color::rgb(0.8, 0.4, 0.2)); + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + for (name, rgb, s, vals) in [("a", [0.0, 0.6, 0.65], sa, [1.0, 0.5, 0.0]), ("b", [0.9, 0.2, 0.5], sb, [0.0, 0.5, 1.0])] { + let mut sf = Surface::new(fmt); + sf.write_region(Rect::new(0, 0, 3, 1), &vals); + doc.channels.push(AlphaChannel { spot: Some((Color::rgb(rgb[0], rgb[1], rgb[2]), s)), ..AlphaChannel::new(name, sf) }); + } + doc +} + +/// Visible spot channels of RGB and CMYK documents print over the composite as inks (#10249), +/// the same as the compositor's overprint, in panel order; not as a tint. +#[test] +fn spot_eyes_overprint_the_composite() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [photocraft_color::SampleType::U8, photocraft_color::SampleType::U16] { + for (sa, sb) in [(0.0, 0.0), (0.5, 1.0), (1.0, 0.5)] { + for swapped in [false, true] { + let mut doc = spot_doc(mode, depth, sa, sb); + if swapped { + doc.channels.reverse(); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |_| true); + for (x, (got, w)) in px.iter().zip(&want.px).enumerate() { + assert_eq!(got.a(), 255, "{mode:?} {depth:?}: opaque"); + let w = [q(w[0]), q(w[1]), q(w[2])]; + assert_eq!([got.r(), got.g(), got.b()], w, "{mode:?} {depth:?} s {sa}/{sb} swapped {swapped} x {x}"); + } + // Only the visible ink prints; where it has none the canvas shows through. + v.set_alpha_visible(1, false); + let one = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { rect: doc.bounds(), px: composite(&doc, doc.bounds(), 1) }; + photocraft_compose::multichannel::overprint(&mut want, &doc, |i| i == 0); + let (solid, bare) = if swapped { (2, 0) } else { (0, 2) }; + let w = want.px[solid]; + assert_eq!([one[solid].r(), one[solid].g(), one[solid].b()], [q(w[0]), q(w[1]), q(w[2])]); + assert_eq!(one[bare], Color32::TRANSPARENT); + } + } + } + } +} + +/// Only inked pixels replace the canvas: where every visible spot is 0 the overlay stays +/// transparent, so the normal (colour-managed, full-resolution) canvas shows there, and an empty +/// spot channel draws nothing and renders no composite at all. +#[test] +fn uninked_pixels_leave_the_canvas_alone() { + for factor in [1, 2] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.size = photocraft_geom::Size::new(4, 2); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(0, 0, &[1.0]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let px = render(&doc, &v, doc.bounds(), factor, Look::default(), None).unwrap(); + assert_eq!(px[0], Color32::from_rgb(0, q(0.6), q(0.65)), "/{factor}: solid teal where the ink is"); + assert!(px[1..].iter().all(|p| *p == Color32::TRANSPARENT), "/{factor}: {px:?}"); + // Only the empty spot shown: nothing to print, and no composite rendered for it. + v.set_alpha_visible(0, false); + let mut fresh = None; + let px = render_with_source( + &doc, + &v, + doc.bounds(), + factor, + Look::default(), + None, + CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }, + ); + assert!(px.is_none(), "/{factor}: an empty spot draws no texture"); + assert!(fresh.is_none(), "/{factor}: an empty spot needs no composite"); + } +} + +/// A few inked pixels composite only their bounding box, not the whole document, and print the +/// same as over the whole composite; ink everywhere renders the whole one, kept for the cache. +#[test] +fn sparse_inks_composite_only_their_bounding_box() { + use photocraft_doc::{Layer, LayerContent}; + for factor in [1, 2, 3] { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(60, 40); + let mut ramp = Surface::new(doc.pixel_format()); + for y in 0..40 { + for x in 0..60 { + ramp.write_pixel(x, y, &[x as f32 / 59.0, y as f32 / 39.0, 0.5, 1.0]); + } + } + doc.layers.push(Layer::new("ramp", LayerContent::Raster(ramp))); + let fmt = doc.channels[0].surface.format(); + let mut sparse = Surface::new(fmt); + sparse.write_pixel(12, 0, &[1.0]); + sparse.write_pixel(18, 6, &[0.5]); + doc.channels[0].surface = sparse; + doc.channels[1].surface = Surface::new(fmt); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let b = doc.bounds(); + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + let got = render_with_source(&doc, &v, b, factor, Look::default(), None, source).unwrap(); + assert!(fresh.is_none(), "/{factor}: two inked pixels render no whole composite"); + let whole = composite_sampled(&doc, b, factor); + let ink = photocraft_compose::multichannel::Ink::of(&doc, &doc.channels[0]); + let w = 60usize.div_ceil(factor as usize); + let f = factor as usize; + let inked = [(12 / f, 0), (18 / f, 6 / f)]; + for (k, px) in got.iter().enumerate() { + let Some(&(x, y)) = inked.iter().find(|&&(x, y)| y * w + x == k) else { + assert_eq!(*px, Color32::TRANSPARENT, "/{factor} px {k}"); + continue; + }; + let mut want = [whole[k]]; + ink.apply(&mut want, [doc.channels[0].surface.pixel((x * f) as i32, (y * f) as i32)[0]]); + let want = Color32::from_rgb(q(want[0][0]), q(want[0][1]), q(want[0][2])); + assert_eq!(*px, want, "/{factor} ({x}, {y})"); + } + // Ink everywhere: the bounding box is the whole document, rendered once for the cache. + let mut solid = Surface::new(fmt); + solid.fill_rect(b, &[1.0]); + doc.channels[1].surface = solid; + let mut fresh = None; + let source = CompositeSource { cached: None, fresh: &mut fresh, screen: &Screen::default() }; + assert!(render_with_source(&doc, &v, b, factor, Look::default(), None, source).is_some()); + assert!(fresh.is_some(), "/{factor}: ink everywhere renders the whole composite"); + } +} + +/// A visible spot channel without ink draws no texture over the canvas, and remembers that; a +/// spot with ink shown later draws one. +#[test] +fn uninked_spot_draws_no_texture() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + // No selection: an empty spot channel, shown over the composite. + app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let st = app.session.active().unwrap(); + assert!(prints_inks(&st.doc, &st.channel_view), "the composite and the spot are both shown"); + assert!(ensure(&mut app, &ctx, 0, None).is_none(), "no ink: nothing drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_none() && c.valid), "and remembered"); + assert!(ensure(&mut app, &ctx, 0, None).is_none()); + // A second spot made from a selection has ink: the texture shows it. + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("channel.newSpot", json!({"color": "#ff00ff", "solidity": 0})).unwrap(); + assert!(ensure(&mut app, &ctx, 0, None).is_some(), "ink: drawn"); + assert!(app.channel_views.get(&id).is_some_and(|c| c.tex.is_some())); +} + +/// Printed inks go through the canvas display transform (monitor profile, Proof Colors), as the +/// canvas around them does. +#[test] +fn printed_inks_follow_the_display_transform() { + use photocraft_cms::{Builtin, TransformOptions}; + let doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let plain = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + let t = photocraft_cms::transform::cached(Builtin::Srgb.profile(), Builtin::DisplayP3.profile(), TransformOptions::default()).unwrap(); + let screen = Screen { encode_srgb: false, transform: Some(t.clone()) }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &screen }).unwrap(); + // x 0: solid teal; x 2: no ink of channel 0, so transparent either way. + let mut teal = [0.0f32, 0.6, 0.65, 1.0]; + t.apply(&mut teal, 4); + assert_ne!(plain[0], shown[0]); + assert_eq!(shown[0], Color32::from_rgb(q(teal[0]), q(teal[1]), q(teal[2]))); + assert_eq!((plain[2], shown[2]), (Color32::TRANSPARENT, Color32::TRANSPARENT)); +} + +/// A linear (32-bit) document's composite is sRGB-encoded on its way to the screen. Its inks are +/// sRGB colours converted into the linear profile, so a solid ink shows its stored colour, not a +/// brightened one. +#[test] +fn printed_inks_keep_their_colour_in_a_linear_document() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::F32, 1.0, 1.0); + doc.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + let encoded = Screen { encode_srgb: true, transform: None }; + let shown = + render_with_source(&doc, &v, doc.bounds(), 1, Look::default(), None, CompositeSource { cached: None, fresh: &mut None, screen: &encoded }).unwrap(); + let (got, want) = ([shown[0].r(), shown[0].g(), shown[0].b()], [0, q(0.6), q(0.65)]); + assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "{got:?} vs the stored {want:?}"); +} + +/// Alpha channels and Quick Mask still tint, over the printed inks. +#[test] +fn alpha_tint_draws_over_the_inks() { + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + let mut sel = Surface::new(photocraft_color::PixelFormat::new(ColorMode::Grayscale, photocraft_color::SampleType::U8, false)); + sel.write_region(Rect::new(0, 0, 3, 1), &[0.0, 0.0, 0.0]); + doc.channels.push(AlphaChannel::new("Alpha 1", sel)); + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(2, true); + let px = render(&doc, &v, doc.bounds(), 1, Look::default(), None).unwrap(); + // Solid opaque teal at x 0, then the default red rubylith at 50 % over it. + let teal = [0.0f32, 0.6, 0.65]; + let red = AlphaChannel::DEFAULT_COLOR.to_rgb(); + let want: Vec = (0..3).map(|j| q(red[j] * 0.5 + teal[j] * 0.5)).collect(); + assert_eq!(vec![px[0].r(), px[0].g(), px[0].b()], want); +} + +/// Editing a spot (colour, solidity, visibility) or the picture under it refreshes the canvas texture. +#[test] +fn spot_edits_refresh_the_overlay() { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 8, "background": "white"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#00ffff", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + let id = app.session.active().unwrap().doc.id.0; + let pixel = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).map(|px| px[0]) + }; + assert!(ensure(&mut app, &ctx, 0, None).is_some()); + assert_eq!(pixel(&app), Some(Color32::from_rgb(0, 255, 255)), "solid transparent cyan over white"); + let edits = [ + ("channel.options", json!({"channel": spot, "color": "#ff00ff"})), + // A gray picture first: a solid ink over white looks the same at any solidity. + ("edit.fill", json!({"color": "#808080"})), + ("channel.options", json!({"channel": spot, "solidity": 100})), + ("channel.setVisible", json!({"channel": spot, "visible": false})), + ]; + let mut last = pixel(&app); + for (cmd, params) in edits { + app.run(cmd, params.clone()).unwrap(); + let shown = ensure(&mut app, &ctx, 0, None); + let now = pixel(&app); + assert_ne!(now, last, "{cmd} {params}"); + let revision = app.session.active().unwrap().revision; + match shown { + Some(_) => assert_eq!(app.channel_views.get(&id).unwrap().revision, revision, "{cmd} {params}"), + None => assert!(now.is_none(), "a hidden spot leaves the plain composite"), + } + last = now; + } +} + +/// The first draw of a big document composites a proxy, not the full canvas (#10249): it must +/// pick the same pixels the full-resolution composite gives, in RGB and CMYK at 8 and 16 bit, +/// with the inks printed over it the same way. +#[test] +fn proxy_composite_matches_the_full_resolution_one() { + use photocraft_color::{BlendMode, SampleType}; + use photocraft_doc::{Layer, LayerContent, LayerMask}; + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + for depth in [SampleType::U8, SampleType::U16] { + for factor in [2, 3, 4] { + let mut doc = spot_doc(mode, depth, 0.5, 1.0); + doc.size = photocraft_geom::Size::new(37, 23); + let mut s = Surface::new(doc.pixel_format()); + for y in 0..23 { + for x in 0..37 { + let (fx, fy) = (x as f32 / 36.0, y as f32 / 22.0); + s.write_pixel(x, y, &[fx, fy, (fx * fy * 7.0).fract(), 0.3 + 0.7 * fy]); + } + } + let mut layer = Layer::new("pattern", LayerContent::Raster(s)); + layer.blend = BlendMode::Multiply; + layer.opacity = 0.8; + let mut mask = LayerMask::reveal_all(); + mask.surface.fill_rect(Rect::new(5, 3, 20, 15), &[0.25]); + layer.mask = Some(mask); + doc.layers.push(layer); + for ch in &mut doc.channels { + let mut sf = Surface::new(ch.surface.format()); + for y in 0..23 { + for x in 0..37 { + sf.write_pixel(x, y, &[((x * 3 + y * 5) % 11) as f32 / 10.0]); + } + } + ch.surface = sf; + } + assert!(proxy_matches(&doc)); + let b = doc.bounds(); + let (proxy, full) = (composite(&doc, b, factor), composite_sampled(&doc, b, factor)); + assert_eq!(proxy.len(), full.len()); + for (i, (p, f)) in proxy.iter().zip(&full).enumerate() { + assert!(p.iter().zip(f).all(|(p, f)| (p - f).abs() < 1e-4), "{mode:?} {depth:?} /{factor} px {i}: {p:?} != {f:?}"); + } + let mut v = ChannelView::default(); + v.set_alpha_visible(0, true); + v.set_alpha_visible(1, true); + let got = render(&doc, &v, b, factor, Look::default(), None).unwrap(); + let mut want = photocraft_compose::Buffer { + rect: Rect::new(0, 0, 37usize.div_ceil(factor as usize) as i32, 23usize.div_ceil(factor as usize) as i32), + px: full, + }; + let mut small = doc.clone(); + small.channels.iter_mut().for_each(|c| c.surface = photocraft_compose::proxy::downsample(&c.surface, factor)); + photocraft_compose::multichannel::overprint(&mut want, &small, |_| true); + let sw = want.rect.width(); + let inked = |i: usize| { + let (x, y) = ((i as u32 % sw) as i32, (i as u32 / sw) as i32); + small.channels.iter().any(|c| c.surface.pixel(x, y).first().is_some_and(|v| *v > 0.0)) + }; + for (i, (g, w)) in got.iter().zip(&want.px).enumerate() { + let w = if inked(i) { Color32::from_rgba_premultiplied(q(w[0]), q(w[1]), q(w[2]), 255) } else { Color32::TRANSPARENT }; + assert!(g.to_array().iter().zip(w.to_array()).all(|(a, b)| a.abs_diff(b) <= 1), "{mode:?} {depth:?} /{factor} px {i}: {g:?} != {w:?}"); + } + } + } + } +} + +/// Feathered masks blur by document pixels, which a proxy would not scale: those documents keep +/// the full-resolution composite. +#[test] +fn feathered_masks_keep_the_full_resolution_composite() { + use photocraft_doc::{Layer, LayerContent, LayerMask}; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + assert!(proxy_matches(&doc)); + let mut mask = LayerMask::reveal_all(); + mask.feather = 4.0; + let mut inner = Layer::new("inner", LayerContent::Raster(Surface::new(doc.pixel_format()))); + inner.mask = Some(mask); + doc.layers.push(Layer::group("group", vec![inner])); + assert!(!proxy_matches(&doc)); + // Hidden layers are not composited: a hidden group (or a hidden layer in it) doesn't count. + let last = doc.layers.len() - 1; + doc.layers[last].visible = false; + assert!(proxy_matches(&doc)); + doc.layers[last].visible = true; + if let LayerContent::Group(g) = &mut doc.layers[last].content { + g.children[0].visible = false; + } + assert!(proxy_matches(&doc)); +} + +/// A pattern fill's tiles keep their size on a proxy, so it would sample other pattern pixels: +/// such documents keep the full-resolution composite unless the fill's own cache is shrunk with +/// the proxy. +#[test] +fn uncached_pattern_fills_keep_the_full_resolution_composite() { + use photocraft_doc::{Fill, FillCache, Layer, LayerContent}; + let pattern = Fill::Pattern { name: "p".into(), scale: 1.0, id: "p".into(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + let mut doc = spot_doc(ColorMode::Rgb, photocraft_color::SampleType::U8, 1.0, 1.0); + doc.layers.push(Layer::group("group", vec![Layer::new("pattern", LayerContent::Fill(pattern.clone()))])); + assert!(!proxy_matches(&doc)); + let mut stale = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + stale.fill_cache = Some(FillCache { fill: Fill::Solid(photocraft_color::Color::WHITE), surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(stale); + assert!(!proxy_matches(&doc)); + let mut cached = Layer::new("pattern", LayerContent::Fill(pattern.clone())); + cached.fill_cache = Some(FillCache { fill: pattern, surface: Surface::new(doc.pixel_format()) }); + doc.layers.pop(); + doc.layers.push(cached); + assert!(proxy_matches(&doc)); +} + +/// Solo spot channel view is grayscale and updates with edits on an RGB or CMYK document with +/// no layer mask. +#[test] +fn solo_spot_is_grayscale_and_edits_without_layer_mask() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + let mode_str = if mode == ColorMode::Rgb { "rgb" } else { "cmyk" }; + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode_str})).unwrap(); + // Plain document has a background layer and no layer mask. + assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.mask.is_none())); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + // Target the spot channel alone (Channels panel row click). + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer) + .expect("solo spot channel must render without a layer mask"); + // Selected half (x 0..4) has ink (1.0 -> white in grayscale tone), unselected half is black (0.0). + assert_eq!(px[0], Color32::WHITE, "{mode:?}: inked half is white tone"); + assert_eq!(px[7], Color32::BLACK, "{mode:?}: uninked half is black tone"); + + // Editing the targeted spot channel updates the solo preview. + app.run("select.rect", json!({"x": 4, "y": 0, "width": 4, "height": 4})).unwrap(); + app.run("edit.fill", json!({"color": "#ffffff"})).unwrap(); + let st = app.session.active().unwrap(); + let px2 = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("edited spot channel must render"); + assert_eq!(px2[7], Color32::WHITE, "{mode:?}: newly filled area is now white"); + } +} + +#[test] +fn solo_spot_wins_over_an_unrelated_layer_mask() { + for mode in ["rgb", "cmyk"] { + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 4, "mode": mode})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap(); + let spot = app.run("channel.newSpot", json!({"color": "#ff0000", "solidity": 0})).unwrap()["channel"].as_u64().unwrap(); + app.sync_views(); + app.run("channel.target", json!({"channel": spot})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.channel_view.visible_colors(color_count(&st.doc)), 0); + let px = render(&st.doc, &st.channel_view, st.doc.bounds(), 1, Look::default(), st.active_layer).expect("solo spot renders"); + // A revealed layer mask is all white; the spot has ink on the left half only. + assert_eq!(px[0], Color32::WHITE, "{mode}: inked half"); + assert_eq!(px[7], Color32::BLACK, "{mode}: uninked half shows the spot, not the mask"); + } +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6f63a2d2001..7882fc43c9d 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -143,7 +143,7 @@ fn a_channel_overlay_follows_a_size_changing_preview() { canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), view.clone(), true); }); output.textures_delta.clear(); - let overlay = crate::channel_view::ensure(&mut app, &ctx, 0).expect("the channel overlay"); + let overlay = crate::channel_view::ensure(&mut app, &ctx, 0, None).expect("the channel overlay"); let mesh = output .shapes .iter() @@ -1729,7 +1729,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); ctx.input_mut(|i| i.max_texture_side = 256); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let revision = app.session.documents()[0].revision; let steps = app.session.documents()[0].history.past_len(); @@ -1740,7 +1740,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { tool_event(&mut app, ToolEvent::Move { x, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; assert!(shown.layer(layer).unwrap().mask.as_ref().unwrap().value(x as i32, 30) < 0.1); - assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0), Some(tex)); + assert_eq!(crate::channel_view::ensure(&mut app, &ctx, 0, None), Some(tex)); let mut delta = ctx.tex_manager().write().take_delta(); let uploads = delta.set.get(&tex).unwrap_or_else(|| panic!("{depth}-bit {mode}: live mask texture must upload before release at {x}")); assert_eq!(uploads.len(), 1); @@ -1765,7 +1765,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(app.session.documents()[0].revision, revision); assert_eq!(app.session.documents()[0].history.past_len(), steps); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); assert!(ctx.tex_manager().write().take_delta().set.is_empty(), "idle frame does not upload"); } tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, egui::Modifiers::NONE); @@ -1773,7 +1773,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { assert_eq!(st.history.past_len(), steps + 1); assert!(st.doc.layer(layer).unwrap().mask.as_ref().unwrap().value(50, 30) < 0.1); assert_eq!(st.doc.layer(layer).unwrap().content, original.content); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); // The commit takes the live stroke's pixels, which the texture already shows: at // most a partial upload, never the whole mask again. @@ -1781,7 +1781,7 @@ fn mask_view_uploads_each_live_stroke_before_release() { delta.clear(); app.session.undo(); assert_eq!(app.session.documents()[0].doc.layer(layer).unwrap(), &original); - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; let egui::ImageData::Color(image) = &upload.image; @@ -1826,14 +1826,14 @@ fn channel_views_show_live_targets_and_discarded_strokes() { app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "smoothing": {"amount": 0.0}}})).unwrap(); app.ui.tool = Tool::Brush; let ctx = egui::Context::default(); - let tex = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let tex = crate::channel_view::ensure(&mut app, &ctx, 0, None).unwrap(); ctx.tex_manager().write().take_delta().clear(); let original = app.session.documents()[0].doc.clone(); let steps = app.session.documents()[0].history.past_len(); tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let shown = display_doc(&mut app, 0).0; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).expect("live channel upload")[0]; let pos = upload.pos.expect("partial channel upload"); @@ -1872,7 +1872,7 @@ fn channel_views_show_live_targets_and_discarded_strokes() { // document revision did not change. There is no committed damage rectangle to use. app.drag = None; app.live_stroke = None; - crate::channel_view::ensure(&mut app, &ctx, 0); + crate::channel_view::ensure(&mut app, &ctx, 0, None); let mut delta = ctx.tex_manager().write().take_delta(); let upload = &delta.set.get(&tex).unwrap()[0]; assert!(upload.pos.is_none(), "discarding a preview refreshes the view"); diff --git a/docs/gaps.md b/docs/gaps.md index 2044af01d4c..70e41e698d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint on the canvas and in the PSD thumbnail, FILE-215-17 narrows to PDF Separation, #10249; G2: compositor compilation moved off the first document refresh, with startup measurements pending, #9157; G11: Playback Options dialog with Step by Step and Pause For, #5212; G11: Stop step messages with Continue/Stop and nested resume, #8386; adjustment-layer Make keeps name, blend mode and clipping, #8384; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed; G10: Amiga IFF write; G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink, #7415 split; G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026) Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -392,10 +392,13 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), DICOM, EPS/DCS, Pixar, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation - (FILE-215-17); .atn, + Raw; spot plates beyond PSD and TIFF: PDF Separation (FILE-215-17); .atn, .shc, .csh presets (.asl landed 2026-10-20) (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-30, #10249):** visible spot channels of RGB and CMYK documents print over + the canvas composite as inks (the Multichannel overprint formula, in Channels panel order), open + visible, and print in the PSD thumbnail; flatten, export and the merged image stay free of them. + Open: ink order at overlaps and the look at solidity 0.5 and 1 against reference files. - **Progress (2026-10-23, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.16.0, which stored spot ink the other way round, keep their ink. @@ -477,6 +480,8 @@ what an agent can't finish alone. | 2026-10-28 | minor | G2: prepare the GPU compositor in the background; startup performance baseline pending (#9157) | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-11-01 | minor | G9: 11 of 183 preferences do nothing (was 12): Graphics Adapter (`performance.gpuAdapter`) always worked, but startup read it by raw JSON key, which the scorecard prefs audit can't see; it now reads the typed Performance section; no estimate changed | +| 2026-10-30 | minor | G10: visible spot channels of RGB and CMYK documents overprint the canvas composite and the PSD thumbnail, and open visible (#10249); FILE-215-17 stays partial, narrowed to PDF Separation; estimate unchanged | +| 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-27 | minor | G9: 12 of 181 preferences do nothing, the scorecard's count with integration batch 9 (#9775) on main; no estimate changed | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/docs/scorecard.md b/docs/scorecard.md index c225a85a94e..bf6ebcfb9b5 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 539dbc7e590..f693728b8aa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (RGB and CMYK documents preview their spot inks as an overprint, FILE-215-17 narrows to PDF Separation, #10249; tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking; no per-camera colour for non-DNG cameras); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; recording uses implicit active layers, unique layer names, whole-text Type steps, bundled Move-tool nudges, relative keyboard document switches and canvas-relative point text (AUTO-219-1, #4514; duplicate names and paragraph boxes remain open); Actions panel remains partial (AUTO-219-2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -597,6 +597,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | 2026-11-01 | minor | Automation Batch: recursive folders, open documents, extended naming, stop/error log and folder-job progress/cancel; AUTO-219-5 done (#4579); percentages and estimates unchanged | | 2026-11-01 | minor | UI / UX and Workspace rows: 11 of 183 preferences do nothing (was 12); Graphics Adapter always worked, but startup read it by raw JSON key, which the scorecard audit can't see. Percentages and estimates unchanged | | 2026-10-30 | minor | Art History Brush Opacity on the options bar, #10263; no estimate changed | +| 2026-10-30 | minor | Colour management: RGB and CMYK spot channels preview as inks over the composite and in the PSD thumbnail (#10249); FILE-215-17 narrows to PDF Separation; percentages unchanged | | 2026-10-27 | minor | Smooth/Feather command reads and replacement use selection bounds plus blur reach; full-canvas reference and release comparisons extended; percentages and scorecard budgets unchanged | | 2026-10-26 | minor | Expand/Contract/Border distance work, mask reads and replacement cropped to nonzero bounds; bounded distances for radii ≤5; 36 MP exact-output comparisons improve 1.54–37.82× across eight cases; percentages, estimates and scorecard budgets unchanged | | 2026-10-23 | minor | Frame Tool interaction and persistence work (#8864); documented remaining native Photoshop frame metadata and independent-oracle limits; no percentage changed | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3a762050a71..e8195e6b513 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 PSDs (straight planes) keep their ink. RGB/CMYK documents preview visible spot channels as inks printed over the canvas composite (compose multichannel::overprint, the Multichannel formula, in Channels panel order; ui-egui canvas/channel_view.rs), open with spot eyes on, and print them in the PSD thumbnail (multichannel::thumbnail_with_inks); flatten, export and the merged image stay free of them (#10249). Missing: PDF Separation" [[item]] id = "FILE-215-18"