mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
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>
153 lines
6.2 KiB
Rust
153 lines
6.2 KiB
Rust
//! 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());
|
||
}
|
||
}
|