diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ad61a94e..5d0ec6fa 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -44,6 +44,7 @@ pub mod matting; pub mod quantize; pub mod stack; pub mod tone; +pub mod trap; pub mod transform; pub mod warp; pub mod liquify; diff --git a/crates/algo/src/trap.rs b/crates/algo/src/trap.rs new file mode 100644 index 00000000..5a05ff52 --- /dev/null +++ b/crates/algo/src/trap.rs @@ -0,0 +1,125 @@ +//! Image › Trap (prepress). Trapping spreads each ink slightly into neighbouring inked areas so +//! that, if the press misregisters the plates, the colours still overlap at an edge instead of +//! leaving a white gap. We spread every ink by `width` px (grayscale dilation) but only *under* +//! already-inked pixels, so internal colour edges get a trap band while the object-vs-paper edge is +//! left alone. Clean-room approximation of Photoshop's Trap, which works on a flattened CMYK image. +//! +//! Operates on an interleaved buffer of `ch` channels per pixel (CMYK = inks 0..4, plus alpha). + +use crate::photo_util::par_rows; + +/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px. +fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec { + let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c]; + // Horizontal pass. + let mut tmp = vec![0.0f32; w * h]; + par_rows(&mut tmp, w, 1, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let lo = x.saturating_sub(r); + let hi = (x + r).min(w - 1); + let mut m = 0.0f32; + for xx in lo..=hi { + m = m.max(get(src, xx, y)); + } + *o = m; + } + }); + // Vertical pass over tmp. + let mut out = vec![0.0f32; w * h]; + par_rows(&mut out, w, 1, |y, row| { + let lo = y.saturating_sub(r); + let hi = (y + r).min(h - 1); + for (x, o) in row.iter_mut().enumerate() { + let mut m = 0.0f32; + for yy in lo..=hi { + m = m.max(tmp[yy * w + x]); + } + *o = m; + } + }); + out +} + +/// Trap an interleaved CMYK(+alpha) buffer in place. `width` is the trap in px (>=1). Inks 0..4 are +/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has +/// no ink at all (paper), so only internal colour edges gain a trap band. +pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) { + if width == 0 || w == 0 || h == 0 || ch < 4 { + return; + } + let inks = 4.min(ch); + let dil: Vec> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect(); + par_rows(px, w, ch, |y, row| { + for (x, pixel) in row.chunks_exact_mut(ch).enumerate() { + let i = y * w + x; + // Printed where any ink is present. + let inked = (0..inks).any(|c| pixel[c] > 1e-4); + if !inked { + continue; + } + for c in 0..inks { + pixel[c] = pixel[c].max(dil[c][i]); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two solid ink blocks meeting at x=4: cyan (C) on the left, magenta (M) on the right. + fn two_blocks() -> (Vec, usize, usize) { + let (w, h, ch) = (8usize, 4usize, 4usize); + let mut px = vec![0.0f32; w * h * ch]; + for y in 0..h { + for x in 0..w { + let p = &mut px[(y * w + x) * ch..][..ch]; + if x < 4 { + p[0] = 1.0; // cyan + } else { + p[1] = 1.0; // magenta + } + } + } + (px, w, h) + } + + #[test] + fn traps_the_internal_colour_edge() { + let (mut px, w, h) = two_blocks(); + let ch = 4; + trap(&mut px, w, h, ch, 1); + // At the seam, cyan and magenta now overlap (both inks > 0) in a 1px band on each side. + let at = |x: usize, y: usize, c: usize| px[(y * w + x) * ch + c]; + assert!(at(3, 0, 1) > 0.5, "magenta spread left into the cyan edge"); + assert!(at(4, 0, 0) > 0.5, "cyan spread right into the magenta edge"); + // Deep inside each block is unchanged (single ink). + assert_eq!(at(0, 0, 1), 0.0, "far cyan has no magenta"); + assert_eq!(at(7, 0, 0), 0.0, "far magenta has no cyan"); + } + + #[test] + fn leaves_paper_untouched() { + let (w, h, ch) = (6usize, 3usize, 4usize); + let mut px = vec![0.0f32; w * h * ch]; + // One cyan pixel in the middle, rest paper. + px[(1 * w + 3) * ch] = 1.0; + let before = px.clone(); + trap(&mut px, w, h, ch, 1); + // Paper pixels (no ink) stay paper — no ink bleeds into white. + for i in 0..w * h { + if before[i * ch] == 0.0 { + assert_eq!(px[i * ch], 0.0, "paper pixel {i} gained cyan"); + } + } + } + + #[test] + fn width_zero_is_identity() { + let (mut px, w, h) = two_blocks(); + let orig = px.clone(); + trap(&mut px, w, h, 4, 0); + assert_eq!(px, orig); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ed254ea6..d6647df7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -838,6 +838,7 @@ fn build() -> Vec { v.extend(crate::pick_cmds::specs()); v.extend(crate::frame_cmds::specs()); v.extend(crate::migrate_cmds::specs()); + v.extend(crate::trap_cmds::specs()); v.extend(crate::variables_cmds::specs()); v } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf68e28a..7f6a5def 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,6 +57,7 @@ pub mod comps_cmds; pub mod automate_cmds; mod frame_cmds; mod migrate_cmds; +mod trap_cmds; mod variables_cmds; pub mod pick_cmds; pub mod print_cmds; diff --git a/crates/engine/src/trap_cmds.rs b/crates/engine/src/trap_cmds.rs new file mode 100644 index 00000000..f3657ace --- /dev/null +++ b/crates/engine/src/trap_cmds.rs @@ -0,0 +1,108 @@ +//! Image › Trap. Trapping is a CMYK prepress step (see `photocraft_algo::trap`). Photoshop requires +//! a flattened CMYK image; we trap the active pixel layer's inks over the canvas. One history step. + +use photocraft_color::ColorMode; +use photocraft_doc::LayerContent; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn enabled(s: &Session) -> std::result::Result<(), String> { + match s.active() { + Some(d) if d.doc.mode == ColorMode::Cmyk => Ok(()), + Some(_) => Err("Trap needs a CMYK document (Image › Mode › CMYK Color)".into()), + None => Err("no document".into()), + } +} + +fn trap(s: &mut Session, p: &Value) -> Result { + let cmd = "image.trap"; + let width = p.get("width").and_then(Value::as_f64).unwrap_or(1.0).round().max(0.0) as usize; + if s.active().ok_or(EngineError::NoDocument)?.doc.mode != ColorMode::Cmyk { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: "Trap needs a CMYK document".into() }); + } + s.edit("Trap", |doc, active| { + let id = active.ok_or(EngineError::NoDocument)?; + let bounds = doc.bounds(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Raster(surf) = &mut l.content else { + return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("Trap needs a pixel layer (active layer is a {})", l.content.kind_name()) }); + }; + if bounds.is_empty() { + return Ok(()); + } + let ch = surf.channels(); + let (w, h) = (bounds.width() as usize, bounds.height() as usize); + let mut data = surf.read_region(bounds); + photocraft_algo::trap::trap(&mut data, w, h, ch, width); + surf.write_region(bounds, &data); + Ok(()) + })?; + Ok(json!({"trapped": true, "width": width})) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: "image.trap", + label: "Trap…", + menu: &["Image"], + shortcut: None, + params: r#"{width:px>=1=1} → {trapped,width}: spread inks at colour edges (CMYK only)"#, + enabled, + journal: true, + run: |s, p| trap(s, &p), + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_geom::Rect; + + #[test] + fn traps_a_cmyk_document() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap(); + // Paint cyan | magenta halves on the Background. + s.edit("paint", |doc, active| { + let id = active.unwrap(); + let fmt = doc.pixel_format(); + let l = doc.layer_mut(id).unwrap(); + if let LayerContent::Raster(surf) = &mut l.content { + let ch = fmt.channels(); + let mut data = vec![0.0f32; 8 * 4 * ch]; + for y in 0..4 { + for x in 0..8 { + let px = &mut data[(y * 8 + x) * ch..][..ch]; + if x < 4 { px[0] = 1.0; } else { px[1] = 1.0; } + if fmt.alpha { px[ch - 1] = 1.0; } + } + } + surf.write_region(Rect::new(0, 0, 8, 4), &data); + } + Ok(()) + }).unwrap(); + + let r = s.execute("image.trap", json!({"width": 1})).unwrap(); + assert_eq!(r["trapped"], true); + // The seam now has overlapping inks. + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + if let LayerContent::Raster(surf) = &st.doc.layer(id).unwrap().content { + let ch = surf.channels(); + let d = surf.read_region(Rect::new(0, 0, 8, 4)); + assert!(d[(3) * ch + 1] > 0.5, "magenta trapped into the cyan edge"); + assert!(d[(4) * ch + 0] > 0.5, "cyan trapped into the magenta edge"); + } + // One history step. + assert_eq!(st.history.entries().last().map(|e| e.as_str()), Some("Trap")); + } + + #[test] + fn rejects_non_cmyk() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 4, "mode": "rgb"})).unwrap(); + assert!(s.execute("image.trap", json!({"width": 1})).is_err()); + } +} diff --git a/crates/ui-egui/src/parity.rs b/crates/ui-egui/src/parity.rs index 69bb198e..a52ac33c 100644 --- a/crates/ui-egui/src/parity.rs +++ b/crates/ui-egui/src/parity.rs @@ -7,7 +7,7 @@ use crate::menu_catalog::CATALOG; use crate::menus::is_live; /// Minimum number of live catalog items. Raise it when parity grows; never lower it. -pub const FLOOR: usize = 602; +pub const FLOOR: usize = 603; /// One top-level menu's coverage. #[derive(Clone, Debug, serde::Serialize)] diff --git a/docs/parity.md b/docs/parity.md index a94550e8..f801be97 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Photoshop's menu tree) and the live command registry. Do not edit by hand. -**607 of 625 menu items live (97.1%).** +**608 of 625 menu items live (97.3%).** | Menu | Live | Total | % | |---|---:|---:|---:| | File | 47 | 50 | 94% | | Edit | 75 | 75 | 100% | -| Image | 60 | 61 | 98% | +| Image | 61 | 61 | 100% | | Layer | 147 | 160 | 92% | | Type | 44 | 44 | 100% | | Select | 25 | 25 | 100% | @@ -24,10 +24,6 @@ Generated by `cargo xtask parity` from `crates/ui-egui/src/menu_catalog.rs` (Pho - Import › Video Frames to Layers… (`file.import.videoFramesToLayers`) - Import › WIA Support… (`file.import.wiaSupport`) -### Image (1) - -- Trap… (`image.trap`) - ### Layer (13) - Video Layers › New Blank Video Layer (`layer.videoLayers.newBlankVideoLayer`)