Files
photocraft/crates/engine/src/mode_cmds.rs
T
Brandon ThomasandClaude Opus 5.5 abf785f7b1 Magic Eraser and Background Eraser tools; canvas rotation/flip/size move vector geometry (#70)
* Magic Eraser and Background Eraser engine commands

paint.magicEraser erases the wand region (tolerance, anti-alias,
contiguous, sample all layers, opacity) to transparency, tile by tile in
parallel; paint.backgroundEraser erases colours similar to the one under
the brush hotspot (continuous / once / background swatch sampling,
discontiguous / contiguous / find-edges limits, tolerance, protect
foreground colour). Both compare and erase in the layer's own channels at
any depth, respect the selection, turn the Background into Layer 0 like
Photoshop, and are one undo step. The Magic Eraser paints the background
colour on layers with locked transparency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Magic Eraser and Background Eraser tools in the Eraser group

Shift+E / E cycles Eraser, Background Eraser and Magic Eraser; the
toolbar flyout lists all three. Options bars: Magic Eraser tolerance,
anti-alias, contiguous, sample all layers and opacity; Background Eraser
sampling toggles, limits, tolerance and protect foreground colour. The
Background Eraser cursor always shows its sampling hotspot. Icons are
derived from Lucide's eraser (ISC), recorded in ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image Rotation, Canvas Size, Crop, Trim and Image Size move vector geometry

Canvas-wide operations remapped only pixels: type, shape and smart-object
transforms, vector masks, saved/work paths, guides, slices, notes and
Count/Ruler marks stayed put, so they jumped back on the next re-render.

New engine module canvas_geom applies the canvas map to all non-pixel
geometry. 90°/180°/flip turns remap every pixel surface exactly (incl.
type/shape/smart/fill caches and smart filter masks) and move geometry
through the matching affine; guides swap orientation under 90° turns;
rect/ellipse/line live shapes stay live; gradient fill angles follow.
Canvas Size/Crop/Trim translate geometry (shapes re-render for the new
canvas), Image Size scales it and re-renders vectors, Arbitrary rotation
also moves unlinked vector masks and document marks. Layer effect angles
stay (Global Light), as in Photoshop.

Tests compare the CPU composite of a rotated document (type, shapes,
vector mask, smart object) against the rotated flattened original, also
after re-rendering everything from geometry, at 8/16/32-bit RGB, CMYK
and Gray.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Magic Eraser: edit encoded tile bytes in parallel (24 MP: 1260 -> 375 ms)

Tiles are copied out as encoded bytes and only the touched samples are
decoded, so the parallel workers do all the work and the serial write-back
is a memcpy. The ignored bench prints a phase breakdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:17:39 -04:00

485 lines
21 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 menu remnants: Image Rotation › Arbitrary, and the palette / ink modes — Mode › Indexed
//! Color, Color Table, Bitmap and Duotone.
//!
//! Indexed, Bitmap and Duotone documents keep expanded pixels (RGB 8-bit / Gray 8-bit / Gray, see
//! `Document::pixel_format`): Indexed and Bitmap quantise the flattened image in place and record
//! the palette (`Document::color_table`) or the 1-bit look; Duotone keeps the gray pixels and the
//! inks (`Document::duotone`), which display and flat export render through. Like Photoshop the
//! conversions flatten (Indexed, Bitmap) and require a grayscale source (Bitmap, Duotone).
use photocraft_algo::quantize::{self, BitmapMethod, Dither, Forced, HalftoneShape, PaletteKind};
use photocraft_algo::transform::{Homography, Interp};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
use photocraft_geom::Affine;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn num(p: &Value, key: &str, default: f32) -> f32 {
p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32)
}
fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str {
p.get(key).and_then(Value::as_str).unwrap_or(default)
}
type Enabled = std::result::Result<(), String>;
fn has_doc(s: &Session) -> Enabled {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
fn mode_is(s: &Session, modes: &[ColorMode], what: &str) -> Enabled {
let d = s.active().ok_or("no document open")?;
if modes.contains(&d.doc.mode) { Ok(()) } else { Err(format!("{what} (the document is {:?})", d.doc.mode)) }
}
// ---------- Image Rotation › Arbitrary ----------
/// Canvas size after rotating `size` by `deg` (the canvas grows to fit, as in Photoshop).
pub fn rotated_size(size: Size, deg: f64) -> Size {
let (s, c) = deg.to_radians().sin_cos();
let (w, h) = (f64::from(size.width), f64::from(size.height));
let nw = (w * c.abs() + h * s.abs() - 1e-6).ceil().max(1.0);
let nh = (w * s.abs() + h * c.abs() - 1e-6).ceil().max(1.0);
Size::new(nw as u32, nh as u32)
}
fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.rotation.arbitrary";
let angle = f64::from(num(p, "angle", 0.0));
if !angle.is_finite() || angle.abs() > 3600.0 {
return Err(bad(CMD, "angle out of range"));
}
let deg = match str_or(p, "direction", "cw") {
"cw" => angle,
"ccw" => -angle,
d => return Err(bad(CMD, format!("direction `{d}` (cw|ccw)"))),
};
if deg.rem_euclid(360.0).abs() < 1e-9 {
return Ok(json!({"width": s.active().map(|d| d.doc.size.width), "height": s.active().map(|d| d.doc.size.height)}));
}
let bg = s.tools.background;
let size = s.edit("Rotate Canvas", |doc, _| {
let old = doc.size;
let new = rotated_size(old, deg);
let (sn, cs) = deg.to_radians().sin_cos();
let (cx, cy) = (f64::from(old.width) / 2.0, f64::from(old.height) / 2.0);
let (nx, ny) = (f64::from(new.width) / 2.0, f64::from(new.height) / 2.0);
// Clockwise on screen (y down): x' = c·x − s·y, y' = s·x + c·y about the centres.
let a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] };
let h = Homography([a.m[0], a.m[2], a.m[4], a.m[1], a.m[3], a.m[5], 0.0, 0.0, 1.0]);
let interp = Interp::parse(str_or(p, "interpolation", "bicubic"));
doc.size = new;
let canvas = doc.bounds();
let fmt = doc.pixel_format();
let dpi = doc.resolution_dpi;
for l in doc.layers.iter_mut() {
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
if background && let Some(surf) = l.surface_mut() {
// The Background stays a Background: rotated pixels over the background colour.
let src = surf.content_bounds();
let rotated = photocraft_algo::transform::warp_surface(surf, src, &h, interp);
let mut base = Surface::new(fmt);
let fill = photocraft_raster::from_rgba(&fmt, bg);
base.write_region(canvas, &fill.repeat(canvas.width() as usize * canvas.height() as usize));
crate::transform_cmds::composite_over(&mut base, &rotated);
base.prune();
*surf = base;
if let Some(m) = l.mask.as_mut() {
m.surface = crate::transform_cmds::warp_gray(&m.surface, &h, interp);
}
} else {
// Rotating the whole image moves locked layers too.
let locks = l.locks;
l.locks.position = false;
l.locks.all = false;
crate::transform_cmds::transform_layer(None, l, &h, Some(a), interp)?;
l.locks = locks;
}
// What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards…
crate::canvas_geom::transform_layer_geometry(l, &a, false, dpi);
}
crate::canvas_geom::transform_doc_marks(doc, &a);
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp);
}
if let Some(sel) = &doc.selection {
doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty());
}
// Type, shapes and smart objects re-render from their new geometry.
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All);
doc.guides = Default::default();
Ok(new)
})?;
Ok(json!({"width": size.width, "height": size.height}))
}
// ---------- flatten helpers ----------
/// The flattened image as straight RGBA (over white when `opaque`).
fn composite(doc: &Document, opaque: bool) -> Vec<[f32; 4]> {
let buf = photocraft_compose::flatten(doc);
if opaque { buf.over_background([1.0, 1.0, 1.0]).px } else { buf.px }
}
/// Replace the document's layers by one 8-bit layer holding `px` in `mode`'s storage format.
fn single_layer(doc: &mut Document, active: &mut Option<LayerId>, mode: ColorMode, px: &[[f32; 4]], name: &str, background: bool) {
doc.mode = mode;
doc.depth = SampleType::U8;
let fmt = doc.pixel_format();
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
let mut l = Layer::raster(name, fmt);
if background {
l.locks.transparency = true;
l.locks.position = true;
}
// A new raster layer always has pixels.
if let Some(surf) = l.surface_mut() {
surf.write_region(doc.bounds(), &data);
}
*active = Some(l.id);
doc.layers = vec![l];
for ch in doc.channels.iter_mut() {
let f = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false);
ch.surface = ch.surface.convert(PixelFormat { alpha: ch.surface.format().alpha, ..f });
}
doc.quick_mask = None;
doc.color_table = None;
doc.duotone = None;
}
// ---------- Indexed Color / Color Table ----------
fn parse_forced(s: &str) -> Option<Forced> {
Some(match s {
"none" => Forced::None,
"blackWhite" | "blackAndWhite" => Forced::BlackWhite,
"primaries" => Forced::Primaries,
"web" => Forced::Web,
_ => return None,
})
}
fn parse_dither(s: &str) -> Option<Dither> {
Some(match s {
"none" => Dither::None,
"diffusion" => Dither::Diffusion,
"pattern" => Dither::Pattern,
"noise" => Dither::Noise,
_ => return None,
})
}
fn indexed_color(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.mode.indexedColor";
let kind = PaletteKind::from_id(str_or(p, "palette", "selective"))
.ok_or_else(|| bad(CMD, "palette: exact|systemMac|systemWindows|web|uniform|perceptual|selective|adaptive"))?;
let forced = parse_forced(str_or(p, "forced", "blackWhite")).ok_or_else(|| bad(CMD, "forced: none|blackWhite|primaries|web"))?;
let dither = parse_dither(str_or(p, "dither", "diffusion")).ok_or_else(|| bad(CMD, "dither: none|diffusion|pattern|noise"))?;
let colors = num(p, "colors", 256.0).clamp(2.0, 256.0) as usize;
let amount = num(p, "amount", 75.0).clamp(0.0, 100.0) / 100.0;
let transparency = p.get("transparency").and_then(Value::as_bool).unwrap_or(true);
let (pal_len, transparent) = s.edit("Indexed Color", |doc, active| {
let mut px = composite(doc, !transparency);
let has_clear = transparency && px.iter().any(|q| q[3] < 0.5);
let mut pal = quantize::build_palette(&px, kind, if has_clear { colors.saturating_sub(1).max(2) } else { colors }, forced).map_err(|e| bad(CMD, e))?;
let transparent = if has_clear && pal.len() < 256 {
pal.push([255, 255, 255]);
Some(pal.len() - 1)
} else {
None
};
let w = doc.size.width as usize;
quantize::quantize(&mut px, w, &pal, dither, amount, transparent);
single_layer(doc, active, ColorMode::Indexed, &px, if transparent.is_some() { "Index" } else { "Background" }, transparent.is_none());
doc.color_table = Some(ColorTable { colors: pal.clone(), transparent: transparent.map(|t| t as u8) });
Ok((pal.len(), transparent))
})?;
Ok(json!({"colors": pal_len, "transparentIndex": transparent}))
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn parse_color(v: &Value) -> Option<[u8; 3]> {
match v {
Value::String(s) => {
let s = s.trim_start_matches('#');
let b = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
Some([b(0)?, b(2)?, b(4)?])
}
Value::Array(a) if a.len() >= 3 => {
let c = |i: usize| a[i].as_f64().map(|x| x.round().clamp(0.0, 255.0) as u8);
Some([c(0)?, c(1)?, c(2)?])
}
_ => None,
}
}
/// Built-in Color Table presets.
pub fn preset_table(name: &str) -> Option<Vec<[u8; 3]>> {
let ramp = |f: &dyn Fn(f32) -> [f32; 3]| (0..256).map(|i| f(i as f32 / 255.0).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
Some(match name {
"grayscale" => ramp(&|t| [t; 3]),
// Black → red → yellow → white, the incandescence scale.
"blackBody" => ramp(&|t| [(t * 3.0).min(1.0), (t * 3.0 - 1.0).clamp(0.0, 1.0), (t * 3.0 - 2.0).clamp(0.0, 1.0)]),
"spectrum" => ramp(&|t| {
let h = t * 300.0 / 60.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
match h as i32 {
0 => [1.0, x, 0.0],
1 => [x, 1.0, 0.0],
2 => [0.0, 1.0, x],
3 => [0.0, x, 1.0],
_ => [x, 0.0, 1.0],
}
}),
"systemMac" | "system" => quantize::mac_palette(),
"systemWindows" => quantize::windows_palette(),
"web" => quantize::web_palette(),
_ => return None,
})
}
/// Image › Mode › Color Table: replace entries (pixels keep their index, so their colour follows
/// the table, as in Photoshop) and set the transparent entry. No changes: report the table.
fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.mode.colorTable";
let cur = s.active().ok_or(EngineError::NoDocument)?.doc.color_table.clone().ok_or_else(|| EngineError::Other("the document has no color table".into()))?;
let mut table = cur.clone();
match str_or(p, "table", "custom") {
"custom" => {}
name => table.colors = preset_table(name).ok_or_else(|| bad(CMD, format!("unknown table `{name}`")))?,
}
if let Some(a) = p.get("colors").and_then(Value::as_array) {
let c: Option<Vec<[u8; 3]>> = a.iter().map(parse_color).collect();
let c = c.ok_or_else(|| bad(CMD, "colors: [\"#rrggbb\" | [r, g, b], …]"))?;
if c.is_empty() || c.len() > 256 {
return Err(bad(CMD, "a color table has 1 to 256 entries"));
}
table.colors = c;
}
if let Some(e) = p.get("entries").and_then(Value::as_object) {
for (k, v) in e {
let i: usize = k.parse().map_err(|_| bad(CMD, format!("entry index `{k}`")))?;
let c = parse_color(v).ok_or_else(|| bad(CMD, format!("entry {i}: bad colour")))?;
if i >= table.colors.len() {
return Err(bad(CMD, format!("entry {i} is past the table ({} entries)", table.colors.len())));
}
table.colors[i] = c;
}
}
match p.get("transparent") {
Some(Value::Null) => table.transparent = None,
Some(v) => table.transparent = v.as_u64().filter(|&i| (i as usize) < table.colors.len()).map(|i| i as u8).or(table.transparent),
None => {}
}
let report = |t: &ColorTable| json!({"colors": t.colors.iter().map(|c| hex(*c)).collect::<Vec<_>>(), "transparent": t.transparent});
if table == cur {
return Ok(report(&cur));
}
let out = report(&table);
s.edit("Color Table", |doc, _| {
let old = cur.clone();
for surf in doc.layers.iter_mut().filter_map(Layer::surface_mut) {
let r = surf.content_bounds();
if r.is_empty() {
continue;
}
let fmt = surf.format();
let n = fmt.channels();
let mut data = surf.read_region(r);
for q in data.chunks_exact_mut(n) {
let c = photocraft_raster::to_rgba(&fmt, q);
if c[3] <= 0.0 {
continue;
}
let i = old.nearest([c[0], c[1], c[2]]);
let alpha = if table.transparent == Some(i as u8) { 0.0 } else { c[3] };
let nc = if i < table.colors.len() { table.rgb(i) } else { table.rgb(table.nearest([c[0], c[1], c[2]])) };
let mut enc = [0.0f32; 8];
let m = photocraft_raster::from_rgba_into(&fmt, [nc[0], nc[1], nc[2], alpha], &mut enc);
q.copy_from_slice(&enc[..m]);
}
surf.write_region(r, &data);
surf.prune();
}
doc.color_table = Some(table.clone());
Ok(())
})?;
Ok(out)
}
// ---------- Bitmap ----------
fn bitmap(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.mode.bitmap";
let dpi = s.active().ok_or(EngineError::NoDocument)?.doc.resolution_dpi.max(1.0);
let method = match str_or(p, "method", "diffusion") {
"threshold" | "threshold50" => BitmapMethod::Threshold,
"pattern" => BitmapMethod::Pattern,
"diffusion" => BitmapMethod::Diffusion,
"halftone" => {
let freq = num(p, "frequency", 53.0).clamp(1.0, 999.0);
BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: num(p, "angle", 45.0), shape: HalftoneShape::from_id(str_or(p, "shape", "round")) }
}
m => {
return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)")));
}
};
s.edit("Bitmap", |doc, active| {
let px = composite(doc, true);
let mut gray: Vec<f32> = px.iter().map(|q| photocraft_color::convert::rgb_to_gray([q[0], q[1], q[2]])).collect();
quantize::to_bitmap(&mut gray, doc.size.width as usize, method);
let out: Vec<[f32; 4]> = gray.iter().map(|g| [*g, *g, *g, 1.0]).collect();
single_layer(doc, active, ColorMode::Bitmap, &out, "Background", true);
Ok(())
})?;
Ok(Value::Null)
}
// ---------- Duotone ----------
/// Default inks per type: black first, then warm brown, gold and slate blue (generic names;
/// Photoshop ships Pantone presets, which we don't reproduce).
fn default_inks(n: usize) -> Vec<DuotoneInk> {
let all = [("Black", [0.0, 0.0, 0.0]), ("Warm Brown", [0.62, 0.38, 0.18]), ("Gold", [0.86, 0.68, 0.18]), ("Slate Blue", [0.27, 0.38, 0.58])];
all.iter().take(n).map(|(name, c)| DuotoneInk::new(*name, *c)).collect()
}
fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result<Vec<DuotoneInk>> {
let a = v.as_array().ok_or_else(|| bad(cmd, "inks: [{\"name\",\"color\":\"#rrggbb\",\"curve\":[[in,out],…] (0..100)}, …]"))?;
let defaults = default_inks(n.max(a.len()).min(4));
let mut out = Vec::new();
for (i, ink) in a.iter().take(4).enumerate() {
let mut d = defaults.get(i).cloned().unwrap_or_else(|| DuotoneInk::new(format!("Ink {}", i + 1), [0.0; 3]));
if let Some(name) = ink.get("name").and_then(Value::as_str) {
d.name = name.to_string();
}
if let Some(c) = ink.get("color").and_then(parse_color) {
d.color = c.map(|v| f32::from(v) / 255.0);
}
if let Some(pts) = ink.get("curve").and_then(Value::as_array) {
let mut curve: Vec<CurvePoint> =
pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect();
curve.sort_by(|a, b| a.input.total_cmp(&b.input));
if curve.len() >= 2 {
d.curve = curve;
}
}
out.push(d);
}
Ok(out)
}
fn duotone(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.mode.duotone";
let n = match str_or(p, "type", "duotone") {
"monotone" => 1,
"duotone" => 2,
"tritone" => 3,
"quadtone" => 4,
t => {
return Err(bad(CMD, format!("type `{t}` (monotone|duotone|tritone|quadtone)")));
}
};
let mut inks = match p.get("inks") {
Some(v) => parse_inks(CMD, v, n)?,
None => default_inks(n),
};
if inks.len() < n {
inks.extend(default_inks(n).into_iter().skip(inks.len()));
}
inks.truncate(n);
let names: Vec<String> = inks.iter().map(|i| i.name.clone()).collect();
s.edit("Duotone", |doc, _| {
doc.mode = ColorMode::Duotone;
doc.duotone = Some(Duotone { inks, psd_raw: None });
doc.color_table = None;
Ok(())
})?;
Ok(json!({"inks": names}))
}
macro_rules! spec {
($id:literal, $label:literal, [$($m:literal),*], $params:literal, $en:expr, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: $en, run: $run, journal: true }
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"image.rotation.arbitrary",
"Arbitrary…",
["Image", "Image Rotation"],
r##"{"angle":-359.99..359.99=0,"direction":"cw|ccw"="cw","interpolation":"bicubic|bilinear|nearest"="bicubic"}"##,
has_doc,
rotate_arbitrary
),
spec!(
"image.mode.indexedColor",
"Indexed Color…",
["Image", "Mode"],
r##"{"palette":"selective|perceptual|adaptive|exact|systemMac|systemWindows|web|uniform"="selective","colors":2..256=256,"forced":"blackWhite|none|primaries|web"="blackWhite","transparency":bool=true,"dither":"diffusion|none|pattern|noise"="diffusion","amount":0..100=75} (flattens)"##,
|s| mode_is(
s,
&[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Indexed, ColorMode::Duotone, ColorMode::Cmyk, ColorMode::Lab],
"Indexed Color needs an RGB or Grayscale image"
),
indexed_color
),
spec!(
"image.mode.colorTable",
"Color Table…",
["Image", "Mode"],
r##"{"table":"custom|blackBody|grayscale|spectrum|systemMac|systemWindows|web"="custom","colors":json,"entries":json,"transparent":json} (colors: ["#rrggbb", …]; entries: {"index": "#rrggbb"}; transparent: index|null)"##,
|s| mode_is(s, &[ColorMode::Indexed], "Color Table needs an Indexed Color image"),
color_table
),
spec!(
"image.mode.bitmap",
"Bitmap…",
["Image", "Mode"],
r##"{"method":"diffusion|threshold|pattern|halftone"="diffusion","frequency":1..999=53,"angle":-180..180=45,"shape":"round|ellipse|line|square|diamond|cross"="round"} (halftone frequency in lines/inch; flattens)"##,
|s| mode_is(s, &[ColorMode::Grayscale], "Bitmap needs a Grayscale image (Image › Mode › Grayscale first)"),
bitmap
),
spec!(
"image.mode.duotone",
"Duotone…",
["Image", "Mode"],
r##"{"type":"duotone|monotone|tritone|quadtone"="duotone","inks":json} (inks: [{"name","color":"#rrggbb","curve":[[in,out],…] in 0..100}, …])"##,
|s| mode_is(s, &[ColorMode::Grayscale, ColorMode::Duotone], "Duotone needs a Grayscale image (Image › Mode › Grayscale first)"),
duotone
),
]
}
/// The gradient-map layer that renders Duotone inks over the gray composite.
pub fn duotone_display_layer(d: &Duotone) -> Layer {
Layer::new("Duotone", LayerContent::Adjustment(d.display_adjustment()))
}
/// A copy of `doc` that displays like the printed result (Duotone inks), or None when the
/// document displays as stored.
pub fn display_document(doc: &Document) -> Option<Document> {
let d = doc.duotone.as_ref().filter(|_| doc.mode == ColorMode::Duotone)?;
let mut out = doc.clone();
out.layers.push(duotone_display_layer(d));
Some(out)
}
#[cfg(test)]
mod tests;