Files
photocraft/crates/engine/src/trap_cmds.rs
T
363d385114 Trap: refuse locked layers (#5623) (#5624)
Image > Trap wrote the active layer's pixels without checking locks, so a
layer under Lock All or the pixel lock (its own or its group's) was trapped
anyway. Check check_pixels_unlocked before writing, like the fill commands.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 03:06:40 -04:00

153 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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)?;
crate::commands::check_pixels_unlocked(doc, id)?;
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 {} {})", l.content.article(), 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;
/// Paints cyan | magenta halves on the active layer.
fn paint_halves(s: &mut Session) {
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();
}
#[test]
fn traps_a_cmyk_document() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap();
paint_halves(&mut s);
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.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 refuses_locked_layers() {
// Lock All or the pixel lock, on the layer itself or on its group: Trap refuses, leaving the
// pixels and the history as they were.
for lock in [json!({"all": true}), json!({"pixels": true})] {
for on_group in [false, true] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 4, "mode": "cmyk"})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
paint_halves(&mut s);
let layer = s.active().unwrap().active_layer.unwrap();
if on_group {
s.execute("layer.new.groupFromLayers", json!({})).unwrap();
}
s.execute("layer.lockLayers", lock.clone()).unwrap();
s.execute("layer.select", json!({"layer": layer.0})).unwrap();
let locks = s.active().unwrap().doc.effective_locks(layer);
assert!(locks.all || locks.pixels, "the lock reaches the layer ({lock}, group: {on_group})");
let steps = s.active().unwrap().history.past_len();
let before = s.active().unwrap().doc.layer(layer).unwrap().surface().cloned();
let r = s.execute("image.trap", json!({"width": 1}));
assert!(r.is_err(), "trapped a layer locked with {lock} (group: {on_group}): {r:?}");
let st = s.active().unwrap();
assert_eq!(st.doc.layer(layer).unwrap().surface().cloned(), before, "{lock} group: {on_group}");
assert_eq!(st.history.past_len(), steps, "{lock} group: {on_group}: no history step");
}
}
}
#[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());
}
}