Image > Trap (CMYK prepress trapping)

Clean-room trap: per-ink grayscale dilation (separable max filter,
par_rows/wasm-safe) applied only under already-inked pixels, so internal
colour edges get a trap band while object-vs-paper edges stay clean.
Engine image.trap (CMYK-only, active pixel layer, one history step).
algo 3 tests + engine 2 tests. Parity 607 -> 608; FLOOR 603.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 00:44:18 -04:00
parent 61aa2f9748
commit 1c474b8c7e
7 changed files with 239 additions and 7 deletions

View File

@ -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;

125
crates/algo/src/trap.rs Normal file
View File

@ -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<f32> {
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<Vec<f32>> = (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<f32>, 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);
}
}

View File

@ -838,6 +838,7 @@ fn build() -> Vec<CommandSpec> {
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
}

View File

@ -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;

View File

@ -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<Value> {
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<CommandSpec> {
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());
}
}

View File

@ -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)]

View File

@ -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`)