From e4e2607c85eb8c293633fc566b96f040e4152da7 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 6 Nov 2026 04:04:12 +0530 Subject: [PATCH] Preview spot inks over the RGB/CMYK composite as an overprint (#10276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PSD: a flat file's named extra channels are not its transparency A PSD without layer records had its first extra channel read as the merged transparency, so the first spot or alpha channel of a flattened document vanished and its ink punched a transparent hole in the image (psd-tools cmyk-alpha-spot.psd, cmyk-spot.psd). When the alpha channel names (1045, or 1006 when 1045 is missing) name every extra channel and the first is not "Transparency", there is none. The import also reads channel names from 1006 when 1045 is missing, and a spot whose DisplayInfo colour space is unknown takes its ink from the Alternate Spot Colors (1067). * channel_map: read 1067 records with as_chunks * Preview spot inks over the RGB/CMYK composite as an overprint Visible spot channels of RGB and CMYK documents print over the canvas composite with the Multichannel ink formula, in Channels panel order, instead of a tint; alpha channels and Quick Mask still tint over them. Spot channels open visible, and the PSD thumbnail (1036) prints them. flatten, export and the merged image stay free of spot inks. Ink colours go through the document's CMYK profile, and into an RGB document's own profile. Refs #10249 * channel_view: test solidity over a gray picture * Thumbnail: matte only inked pixels; test the merged planes directly Review feedback on #10276: pixels without spot ink keep their transparency in the thumbnail buffer, and the thumbnail test checks the saved merged process planes instead of a re-imported flatten. * Thumbnail spot inks: sample big boxes on a 4x4 grid Review feedback on #10276: reducing each spot channel no longer scans the whole document; a thumbnail pixel averages at most 4x4 samples, so a huge document costs about h x 4 row reads per spot. * Spot overlay: composite a proxy for the first draw of big documents The overprint needs the composite under the inks. Rendering the full-resolution composite to sample every factor-th pixel cost ~1 s for a 24 MP CMYK document. A proxy document picks the same pixels (nearest-neighbour downsample) for about 1/factor² of the work. Documents with layer effects, vector masks or feathered masks keep the full-resolution path. * Proxy: downsample rows in parallel; the overlay proxy leaves out unused channels The spot overlay's proxy shrank the spot channels, selection and Quick Mask that an RGB or CMYK composite never reads: about two thirds of its cost. downsample now reads its rows in parallel, 64 at a time. * Spot overlay proxy: keep the full-resolution composite under uncached pattern fills A pattern fill renders its tiles at their own size on a proxy, so the proxy would sample other pattern pixels. Unless the fill's matching cache is shrunk with the proxy, use the full-resolution composite. * Spot overlay proxy: hidden layers don't decide the composite path Hidden layers and the contents of hidden groups are not composited, so a hidden feathered mask or pattern fill no longer forces the full-resolution composite (the same rule as proxy_faithful). * Update crates/compose/src/multichannel.rs Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> * Spot overlay: only inked pixels replace the canvas; inks through the display transform Where every visible spot is 0 the overlay stays transparent, so the normal canvas (display LUT, proofing, 32-bit preview, full resolution) shows; an empty spot renders no composite. Printed pixels go through the canvas display transform (sRGB encoding of linear composites, monitor profile, Proof Colors), keyed into the texture. Ink colours come from one InkSpace per render: the document's profiles are parsed once, not per spot per damage rect. * test(compose): cover hidden spot preview and document approximation * fix(ui-egui): preserve solo spot channel display when process colours are hidden Clicking a spot channel row in the Channels panel targets the channel alone and hides all process colour channels (`vis == 0`). Overprint inks were previously extracted from `overlays` before checking `vis == 0`, leaving `overlays` empty and causing the solo channel view to fall back to an active layer mask (or return `None` if the layer had no mask). Keep the spot channel in `overlays` so `vis == 0` can select it as the solo grayscale picture, separating overprint inks only when process colours are visible (`vis > 0`). Adds a regression test targeting an existing spot channel in RGB and CMYK on an unmasked layer and editing it. * channel_view: document the spot overprint preview as an approximation * test(ui-egui): solo spot channel wins over an unrelated layer mask * channel_view: sRGB spot inks, lazy display transform, inked-area composite Review fixes for the spot overprint preview: - RGB spot colours are sRGB, like the CMYK, Lab and gray ones: InkSpace now converts them into an RGB document's own profile too. In a linear (EXR/HDR), Adobe RGB or ProPhoto document an RGB ink showed the wrong colour (brightened in a linear one once the canvas sRGB-encoded it). - The display transform is looked up only when the view is redrawn and inks are shown, not every frame before the cache check; display_transform_for borrows the proof state (proof_ref) instead of cloning a default CMYK profile. - Visible spot channels without ink, with nothing else shown, draw no texture: render returns None and ensure remembers it, instead of uploading an all-transparent document-sized texture. A damage re-render that finds nothing clears its rect, so erased ink never lingers. - Printing a few inked pixels composites only their bounding box when that is cheaper than the whole (proxy) composite, and a cached composite is borrowed instead of copied. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/compose/Cargo.toml | 2 +- crates/compose/src/multichannel.rs | 381 ++++++++++++++- crates/compose/src/proxy.rs | 33 +- crates/engine/src/cmd/channel_cmds/tests.rs | 19 + crates/engine/src/cmd/color_cmds.rs | 12 +- crates/engine/src/doc_state.rs | 7 +- crates/io/src/psd_thumbnail.rs | 7 +- crates/io/tests/it/psd_thumbnail.rs | 26 ++ crates/ui-egui/src/canvas.rs | 16 +- crates/ui-egui/src/canvas/channel_view.rs | 306 ++++++++++-- .../ui-egui/src/canvas/channel_view/tests.rs | 439 +++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 18 +- docs/gaps.md | 11 +- docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 2 +- 16 files changed, 1172 insertions(+), 114 deletions(-) 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"