Merge main

This commit is contained in:
Claude 2026-10-09 14:18:22 +00:00
commit df0e858198
No known key found for this signature in database
25 changed files with 720 additions and 6 deletions

View File

@ -0,0 +1,236 @@
//! Filter › Other › Color to Alpha: removes a known colour from the pixels, turning it into
//! transparency while keeping what was mixed with it.
//!
//! A pixel `P` that is a semi-transparent colour `Q` composited over a background colour `C`
//! obeys `P = a·Q + (1 − a)·C`. Knowing only `P` and `C`, the most transparent solution (the
//! smallest `a` that keeps `Q` inside the 0–1 range) is, per channel `i`,
//!
//! * `a_i = (P_i − C_i) / (1 − C_i)` where `P_i > C_i` (`Q_i` would otherwise overshoot 1),
//! * `a_i = (C_i − P_i) / C_i` where `P_i < C_i` (`Q_i` would otherwise drop below 0),
//! * `a_i = 0` where `P_i = C_i`,
//!
//! and `a = max_i a_i`. Then `Q = C + (P − C) / a`, and the layer's alpha is multiplied by `a`.
//! Composited over `C` again with Normal blending, the result reproduces `P`.
//!
//! Two thresholds shape `a`: distances at or below `transparency` become fully transparent, at or
//! above `opacity` fully opaque, linear in between (`0` and `1` leave `a` as computed).
//!
//! The arithmetic runs on the stored channel values of the document (encoded, not linearised, at
//! every depth). That is the space PhotoCraft's Normal blending composites in, so the unmixed
//! layer over a fill of the removed colour gives back the original pixels exactly.
use photocraft_geom::Rect;
use crate::Ctx;
use crate::fxutil::{MAXC, native, ncol};
use crate::image::Image;
/// The opacity `a` (0–1) a pixel needs to be a mix of some in-range colour and `key`, per the
/// module docs. Out-of-range samples (32-bit HDR values beyond what `key` can reach) need full
/// opacity; non-finite samples are ignored.
pub fn opacity(colour: &[f32], key: &[f32]) -> f32 {
let mut a = 0.0f32;
for (&p, &c) in colour.iter().zip(key) {
let ai = if p > c {
// Past a key at the top of the range (`1 − C = 0`) no in-range colour reaches `P`.
if c < 1.0 { (p - c) / (1.0 - c) } else { 1.0 }
} else if p < c {
if c > 0.0 { (c - p) / c } else { 1.0 }
} else {
0.0
};
if ai.is_finite() {
a = a.max(ai);
}
}
a.clamp(0.0, 1.0)
}
/// Applies the thresholds: `a <= transparency` → 0, `a >= opacity` → 1, linear in between.
pub fn remap(a: f32, transparency: f32, opacity: f32) -> f32 {
if a <= transparency {
0.0
} else if a >= opacity {
1.0
} else {
// Here transparency < a < opacity, so the span is positive.
((a - transparency) / (opacity - transparency)).clamp(0.0, 1.0)
}
}
/// Unmixes `key` from one pixel's colour channels in place and returns the factor its alpha is
/// multiplied by. Fully opaque results keep the colour as it was; fully transparent ones take the
/// key colour (any colour would do, this one keeps the result deterministic).
pub fn unmix(colour: &mut [f32], key: &[f32], transparency: f32, opacity_threshold: f32) -> f32 {
let a = remap(opacity(colour, key), transparency, opacity_threshold);
if a >= 1.0 {
return 1.0;
}
for (p, &c) in colour.iter_mut().zip(key) {
*p = if a <= 0.0 { c } else { (c + (*p - c) / a).clamp(0.0, 1.0) };
}
a.max(0.0)
}
/// The filter kernel: `color` is straight sRGB (its alpha is ignored), converted to the document's
/// colour model. Surfaces without an alpha channel (a channel or Quick Mask target) are returned
/// unchanged, since there is nowhere to put the transparency.
pub(crate) fn color_to_alpha(src: &Image, out: Rect, ctx: &Ctx, color: [f32; 4], transparency: f32, opacity_threshold: f32) -> Vec<f32> {
let n = src.ch;
let mut res = src.crop(out);
if !ctx.alpha || n < 2 {
return res;
}
let cc = ncol(ctx, n).min(MAXC);
let key_all = native(ctx, [color[0], color[1], color[2], 1.0]);
let key = key_all.get(..cc).unwrap_or(&[]);
for px in res.chunks_exact_mut(n) {
let Some((alpha, colour)) = px.split_last_mut() else { continue };
let a = unmix(colour, key, transparency, opacity_threshold);
*alpha *= a;
}
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilterParams, apply, output_area};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
fn c2a(color: [f32; 4], t: f32, o: f32) -> FilterParams {
FilterParams::ColorToAlpha { color, transparency_threshold: t, opacity_threshold: o }
}
fn run(s: &Surface, p: &FilterParams, r: Rect) -> Surface {
let area = output_area(p, s.content_bounds(), r, None);
apply(s, p, area, r, None)
}
#[test]
fn the_key_colour_becomes_fully_transparent() {
let mut px = [1.0, 1.0, 1.0];
assert_eq!(unmix(&mut px, &[1.0, 1.0, 1.0], 0.0, 1.0), 0.0);
let mut px = [0.2, 0.4, 0.6];
assert_eq!(unmix(&mut px, &[0.2, 0.4, 0.6], 0.0, 1.0), 0.0);
}
#[test]
fn grey_on_white_is_black_with_partial_alpha() {
// An antialiased black-on-white edge pixel at 30 % coverage.
let mut px = [0.7, 0.7, 0.7];
let a = unmix(&mut px, &[1.0, 1.0, 1.0], 0.0, 1.0);
assert!(close(a, 0.3, 1e-6), "{a}");
assert!(px.iter().all(|v| close(*v, 0.0, 1e-5)), "{px:?}");
// Pure black stays opaque and black.
let mut px = [0.0, 0.0, 0.0];
assert_eq!(unmix(&mut px, &[1.0, 1.0, 1.0], 0.0, 1.0), 1.0);
assert_eq!(px, [0.0, 0.0, 0.0]);
}
#[test]
fn unmixed_colour_composited_over_the_key_gives_back_the_pixel() {
let key = [0.2, 0.6, 0.9];
for p in [[0.5, 0.5, 0.5], [0.1, 0.9, 0.95], [0.25, 0.6, 0.0], [1.0, 0.0, 0.3]] {
let mut q = p;
let a = unmix(&mut q, &key, 0.0, 1.0);
assert!(q.iter().all(|v| (0.0..=1.0).contains(v)), "{q:?}");
for i in 0..3 {
let back = a * q[i] + (1.0 - a) * key[i];
assert!(close(back, p[i], 1e-5), "{p:?} → {q:?} a={a}");
}
}
}
#[test]
fn thresholds_clip_and_stretch_the_opacity() {
assert_eq!(remap(0.1, 0.2, 0.8), 0.0);
assert_eq!(remap(0.2, 0.2, 0.8), 0.0);
assert_eq!(remap(0.8, 0.2, 0.8), 1.0);
assert!(close(remap(0.5, 0.2, 0.8), 0.5, 1e-6));
// Crossed thresholds never divide by zero.
assert_eq!(remap(0.5, 0.6, 0.4), 0.0);
assert_eq!(remap(0.7, 0.6, 0.4), 1.0);
assert_eq!(remap(0.5, 0.5, 0.5), 0.0);
// A near-white pixel under the transparency threshold disappears completely…
let mut px = [0.95, 0.95, 0.95];
assert_eq!(unmix(&mut px, &[1.0; 3], 0.1, 1.0), 0.0);
// …and a mid grey above the opacity threshold is left exactly as it was.
let mut px = [0.4, 0.4, 0.4];
assert_eq!(unmix(&mut px, &[1.0; 3], 0.0, 0.5), 1.0);
assert_eq!(px, [0.4, 0.4, 0.4]);
}
#[test]
fn hdr_and_non_finite_samples_stay_opaque_and_finite() {
let mut px = [1.5, 1.0, 1.0];
assert_eq!(unmix(&mut px, &[1.0; 3], 0.0, 1.0), 1.0);
assert_eq!(px, [1.5, 1.0, 1.0]);
let mut px = [f32::NAN, 0.5, 0.5];
let a = unmix(&mut px, &[1.0; 3], 0.0, 1.0);
assert!(a.is_finite() && close(a, 0.5, 1e-6));
// A black key: everything brighter is a mix of black and a brighter colour.
let mut px = [0.25, 0.5, 0.0];
let a = unmix(&mut px, &[0.0; 3], 0.0, 1.0);
assert!(close(a, 0.5, 1e-6));
assert!(close(px[0], 0.5, 1e-6) && close(px[1], 1.0, 1e-6) && close(px[2], 0.0, 1e-6), "{px:?}");
}
#[test]
fn works_at_every_depth_and_multiplies_existing_alpha() {
let r = Rect::new(0, 0, 4, 1);
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, st, true));
s.write_region(r, &[1.0, 1.0, 1.0, 1.0, 0.6, 0.6, 0.6, 1.0, 0.0, 0.0, 0.0, 1.0, 0.6, 0.6, 0.6, 0.5]);
let out = run(&s, &c2a([1.0, 1.0, 1.0, 1.0], 0.0, 1.0), r);
let tol = if st == SampleType::U8 { 2.0 / 255.0 } else { 1e-3 };
assert!(close(out.pixel(0, 0)[3], 0.0, tol), "{st:?} white gone");
let edge = out.pixel(1, 0);
assert!(close(edge[3], 0.4, tol) && close(edge[0], 0.0, tol), "{st:?} edge {edge:?}");
assert_eq!(out.pixel(2, 0), s.pixel(2, 0), "{st:?} black kept");
assert!(close(out.pixel(3, 0)[3], 0.2, tol), "{st:?} alpha multiplied");
}
}
#[test]
fn grayscale_uses_the_key_converted_to_gray() {
let r = Rect::new(0, 0, 2, 1);
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, st, true));
s.write_region(r, &[1.0, 1.0, 0.75, 1.0]);
let out = run(&s, &c2a([1.0, 1.0, 1.0, 1.0], 0.0, 1.0), r);
let tol = if st == SampleType::U8 { 2.0 / 255.0 } else { 1e-3 };
assert!(close(out.pixel(0, 0)[1], 0.0, tol), "{st:?}");
let p = out.pixel(1, 0);
assert!(close(p[0], 0.0, tol) && close(p[1], 0.25, tol), "{st:?} {p:?}");
}
}
#[test]
fn coloured_key_on_a_coloured_background() {
// Black text antialiased on a blue background: the blue goes, the ink stays.
let r = Rect::new(0, 0, 2, 1);
let blue = [0.2, 0.4, 0.8, 1.0];
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
// Pixel 1: 50 % black over blue.
s.write_region(r, &[0.2, 0.4, 0.8, 1.0, 0.1, 0.2, 0.4, 1.0]);
let out = run(&s, &c2a(blue, 0.0, 1.0), r);
assert!(out.pixel(0, 0)[3] < 1e-6);
let p = out.pixel(1, 0);
assert!(close(p[3], 0.5, 1e-5) && p[..3].iter().all(|v| close(*v, 0.0, 1e-5)), "{p:?}");
}
#[test]
fn surfaces_without_alpha_are_left_alone() {
let r = Rect::new(0, 0, 2, 2);
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
s.fill_rect(r, &[1.0]);
let out = run(&s, &c2a([1.0; 4], 0.0, 1.0), r);
assert_eq!(out.read_region(r), s.read_region(r));
}
}

View File

@ -23,6 +23,7 @@ mod artistic_fx;
mod blur;
mod blur2;
pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
mod distort;
@ -519,6 +520,14 @@ pub enum FilterParams {
input: HsbModel,
output: HsbModel,
},
/// Removes `color` (straight sRGB RGBA, alpha ignored) by unmixing it from every pixel into
/// transparency (see [`color_to_alpha`]). Thresholds 0–1: opacities at or below
/// `transparency_threshold` become 0, at or above `opacity_threshold` 1, linear in between.
ColorToAlpha {
color: [f32; 4],
transparency_threshold: f32,
opacity_threshold: f32,
},
// ---- Video ----
/// Removes the even (or odd) lines and rebuilds them by interpolation (or duplication).
@ -572,6 +581,13 @@ impl FilterParams {
}
}
/// Whether the filter's job is to make pixels transparent (Color to Alpha): it needs a layer
/// that can hold transparency, so callers turn the Background into a normal layer first and
/// refuse a layer whose transparency is locked instead of putting the old alpha back.
pub fn makes_transparency(&self) -> bool {
matches!(self, FilterParams::ColorToAlpha { .. })
}
/// Whether the filter moves pixels around the reference bounds (its
/// output area is the bounds, not the layer's content grown by the halo).
pub fn is_global(&self) -> bool {
@ -637,6 +653,7 @@ impl FilterParams {
FilterParams::PathBlur { .. } => "Path Blur",
FilterParams::Custom { .. } => "Custom",
FilterParams::HsbHsl { .. } => "HSB/HSL",
FilterParams::ColorToAlpha { .. } => "Color to Alpha",
FilterParams::DeInterlace { .. } => "De-Interlace",
FilterParams::NtscColors => "NTSC Colors",
FilterParams::FilterGallery { effects } => match effects.as_slice() {
@ -656,7 +673,7 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Pointillize { cell_size, .. } => r(cell_size.max(1.0) * 2.0),
FilterParams::Facet => r(3.0),
FilterParams::Fragment => r(4.0),
FilterParams::Mezzotint { .. } | FilterParams::HsbHsl { .. } | FilterParams::NtscColors => Halo::Radius(0),
FilterParams::Mezzotint { .. } | FilterParams::HsbHsl { .. } | FilterParams::ColorToAlpha { .. } | FilterParams::NtscColors => Halo::Radius(0),
FilterParams::Diffuse { .. } => r(1.0),
FilterParams::Extrude { size, depth, .. } => r(stylize2::extrude_reach(*depth) + size.clamp(2.0, 255.0) * 3.0 + 1.0),
FilterParams::OilPaint { stylization, scale, .. } => r(oil::reach(*stylization, *scale)),
@ -869,6 +886,9 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::PathBlur { paths } => gallery::path(src, out, ctx, paths),
FilterParams::Custom { kernel, scale, offset } => other2::custom(src, out, ctx, kernel, *scale, *offset),
FilterParams::HsbHsl { input, output } => other2::hsb_hsl(src, out, ctx, *input, *output),
FilterParams::ColorToAlpha { color, transparency_threshold, opacity_threshold } => {
color_to_alpha::color_to_alpha(src, out, ctx, *color, *transparency_threshold, *opacity_threshold)
}
FilterParams::DeInterlace { eliminate_even, interpolate } => video::deinterlace(src, out, ctx, *eliminate_even, *interpolate),
FilterParams::NtscColors => video::ntsc(src, out, ctx),
FilterParams::FilterGallery { effects } => artistic_fx::run(effects, src, out, ctx),

View File

@ -106,6 +106,8 @@ fn new_filters() -> Vec<FilterParams> {
offset: 0.0,
},
FilterParams::HsbHsl { input: HsbModel::Rgb, output: HsbModel::Hsl },
FilterParams::ColorToAlpha { color: WHITE, transparency_threshold: 0.0, opacity_threshold: 1.0 },
FilterParams::ColorToAlpha { color: [0.5, 0.25, 0.75, 1.0], transparency_threshold: 0.1, opacity_threshold: 0.9 },
FilterParams::DeInterlace { eliminate_even: false, interpolate: true },
FilterParams::NtscColors,
]

View File

@ -0,0 +1,303 @@
//! Filter › Other › Color to Alpha (`filter.other.colorToAlpha`, #1576): turns a chosen colour
//! into transparency, unmixing it from partly covered pixels so antialiased edges keep their
//! shape over any new background (the algorithm is `photocraft_algo::color_to_alpha`).
//!
//! Photoshop has no such filter (GIMP and Krita do), so the id is our own; the menu item sits in
//! Filter › Other after Offset. It runs through [`crate::filters::run_filter`] like every other
//! filter (selection, smart filters, Last Filter, one history step), which turns a Background
//! layer into a normal layer ("Layer 0") first, as the erasers do, since the Background can't
//! hold transparency. A layer whose transparency is locked is refused rather than silently
//! getting its old alpha back.
use photocraft_algo::FilterParams;
use photocraft_color::ColorMode;
use serde_json::Value;
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// The command id.
pub const ID: &str = "filter.other.colorToAlpha";
const WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
}
/// A colour channel value: finite and clamped to 0–1 (a fallback for already validated or
/// recorded params, e.g. a smart filter read back from a file).
fn unit(v: f32, default: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { default }
}
/// Algorithm parameters (lenient: out-of-range values are clamped, unreadable ones fall back to
/// the defaults). [`run`] validates strictly before it gets here.
pub(crate) fn params(p: &Value) -> FilterParams {
let c = crate::commands::color_param(p, "color", WHITE);
let num = |k: &str, d: f32| p.get(k).and_then(Value::as_f64).map_or(d, |v| unit(v as f32, d));
FilterParams::ColorToAlpha {
color: [unit(c[0], 1.0), unit(c[1], 1.0), unit(c[2], 1.0), 1.0],
transparency_threshold: num("transparencyThreshold", 0.0),
opacity_threshold: num("opacityThreshold", 1.0),
}
}
fn threshold(p: &Value, key: &str) -> Result<()> {
match p.get(key) {
None => Ok(()),
Some(v) => match v.as_f64() {
Some(x) if x.is_finite() && (0.0..=1.0).contains(&x) => Ok(()),
Some(_) => Err(bad(format!("`{key}` must be in 0..1"))),
None => Err(bad(format!("`{key}` must be a number in 0..1"))),
},
}
}
/// Rejects params the lenient [`params`] would otherwise quietly replace.
fn validate(p: &Value) -> Result<()> {
if !p.is_object() {
return Err(bad("params must be a JSON object"));
}
match p.get("color") {
None => {}
Some(Value::String(s)) => {
crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?;
}
Some(Value::Array(a)) if (3..=4).contains(&a.len()) => {
if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) {
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
}
}
Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")),
}
threshold(p, "transparencyThreshold")?;
threshold(p, "opacityThreshold")
}
/// Menu state: a pixel layer (or smart object) in an RGB or Grayscale document whose pixels and
/// transparency may change. The Background counts: it becomes a normal layer when run.
fn enabled(s: &Session) -> std::result::Result<(), String> {
if crate::channel_cmds::edits_channel(s) {
return Err("Color to Alpha works on layer pixels, not on a channel or Quick Mask".into());
}
crate::filters::has_filterable_layer(s)?;
let d = s.active().ok_or("no document open")?;
match d.doc.mode {
ColorMode::Rgb | ColorMode::Grayscale => {}
m => return Err(format!("Color to Alpha needs an RGB or Grayscale document (this one is {m:?})")),
}
let l = crate::active_layer_of(s)?;
let locks = d.doc.effective_locks(l.id);
if locks.pixels || locks.all {
return Err(format!("the layer \"{}\" is locked", l.name));
}
if locks.transparency && !crate::extra_cmds::is_background(l) {
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
}
Ok(())
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
validate(p)?;
crate::filters::run_filter(s, ID, p)
}
/// The command spec.
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: ID,
label: "Color to Alpha…",
menu: &["Filter", "Other"],
shortcut: None,
params: r##"{"color":color=#ffffff,"transparencyThreshold":0..1=0,"opacityThreshold":0..1=1}"##,
enabled,
run,
journal: true,
}]
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_geom::Rect;
use serde_json::json;
const W: i32 = 24;
const H: i32 = 16;
/// A document with a white Background and, when `layer`, a pixel layer on top; both filled
/// with a black-to-white ramp (column 0 black, last column white).
fn session(depth: u32, mode: &str, layer: bool) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "mode": mode})).unwrap();
if layer {
s.execute("layer.new.layer", json!({})).unwrap();
}
s.edit("ramp", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for x in 0..W {
let v = x as f32 / (W - 1) as f32;
surf.fill_rect(Rect::new(x, 0, x + 1, H), &photocraft_raster::from_rgba(&surf.format(), [v, v, v, 1.0]));
}
Ok(())
})
.unwrap();
s
}
fn rgba(s: &Session, x: i32, y: i32) -> [f32; 4] {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)
}
fn pixels(s: &Session) -> Vec<f32> {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(Rect::new(0, 0, W, H))
}
#[test]
fn removes_white_at_every_depth_and_undoes_in_one_step() {
for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "gray"), (16, "gray"), (32, "gray")] {
let mut s = session(depth, mode, true);
let before = pixels(&s);
let steps = s.active().unwrap().history.past_len();
let r = s.execute(ID, json!({})).unwrap_or_else(|e| panic!("{depth} {mode}: {e}"));
assert_eq!(r["filter"]["filter"], "colorToAlpha", "{r}");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
assert_eq!(s.active().unwrap().history.undo_label(), Some("Color to Alpha"));
let tol = if depth == 8 { 3.0 / 255.0 } else { 2e-3 };
assert!(rgba(&s, W - 1, 3)[3] <= tol, "{depth} {mode}: white is transparent");
assert_eq!(rgba(&s, 0, 3), [0.0, 0.0, 0.0, 1.0], "{depth} {mode}: black is kept");
// A grey becomes black with the grey's darkness as opacity.
let x = W / 2;
let v = x as f32 / (W - 1) as f32;
let p = rgba(&s, x, 3);
assert!((p[3] - (1.0 - v)).abs() <= tol && p[0] <= tol, "{depth} {mode}: {p:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pixels(&s), before, "{depth} {mode}: undo");
}
}
#[test]
fn thresholds_and_colour_params_apply() {
let mut s = session(16, "rgb", true);
s.execute(ID, json!({"color": "#000000", "transparencyThreshold": 0.25, "opacityThreshold": 0.75})).unwrap();
// Black key: dark greys (opacity ≤ 0.25) vanish, light ones (≥ 0.75) stay as they were.
assert_eq!(rgba(&s, 2, 0)[3], 0.0);
assert_eq!(rgba(&s, W - 2, 0)[3], 1.0);
let mid = rgba(&s, W / 2, 0);
assert!(mid[3] > 0.0 && mid[3] < 1.0, "{mid:?}");
// The array form of the colour works too.
let mut t = session(8, "rgb", true);
t.execute(ID, json!({"color": [1.0, 1.0, 1.0]})).unwrap();
assert!(rgba(&t, W - 1, 0)[3] < 0.02);
}
#[test]
fn respects_the_selection() {
let mut s = session(8, "rgb", true);
s.execute("select.rect", json!({"x": W / 2, "y": 0, "width": W / 2, "height": H / 2})).unwrap();
s.execute(ID, json!({})).unwrap();
assert!(rgba(&s, W - 1, 0)[3] < 0.02, "inside the selection");
assert_eq!(rgba(&s, W - 1, H - 1)[3], 1.0, "outside the selection");
}
#[test]
fn the_background_becomes_a_normal_layer_and_undo_restores_it() {
let mut s = session(8, "rgb", false);
assert!(s.is_enabled(ID));
s.execute(ID, json!({})).unwrap();
let name = |s: &Session| {
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().name.clone()
};
assert_eq!(name(&s), "Layer 0");
assert!(rgba(&s, W - 1, 0)[3] < 0.02, "the Background really became transparent");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(name(&s), "Background");
let d = s.active().unwrap();
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
}
#[test]
fn locked_layers_and_unsupported_documents_are_refused() {
let mut s = session(8, "rgb", true);
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
assert!(!s.is_enabled(ID));
let before = pixels(&s);
assert!(s.execute(ID, json!({})).is_err());
assert_eq!(pixels(&s), before);
// Even when the menu check is bypassed (Last Filter), the filter refuses the lock.
assert!(crate::filters::run_filter(&mut s, ID, &json!({})).is_err());
assert_eq!(pixels(&s), before);
let mut s = session(8, "rgb", true);
s.execute("layer.lockLayers", json!({"pixels": true})).unwrap();
assert!(!s.is_enabled(ID));
for mode in ["cmyk", "lab"] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "mode": mode})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
assert!(!s.is_enabled(ID), "{mode}");
}
let mut s = Session::new();
assert!(!s.is_enabled(ID));
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
assert!(!s.is_enabled(ID));
}
#[test]
fn bad_params_are_rejected_without_changes() {
let mut s = session(8, "rgb", true);
let before = pixels(&s);
for p in [
json!(null),
json!(7),
json!("white"),
json!({"color": "white"}),
json!({"color": "#12"}),
json!({"color": "#zzzzzz"}),
json!({"color": [1.0, 1.0]}),
json!({"color": [1.0, 2.0, 0.0]}),
json!({"color": [1.0, "a", 0.0]}),
json!({"color": 16777215}),
json!({"transparencyThreshold": -0.1}),
json!({"opacityThreshold": 1.5}),
json!({"opacityThreshold": "1"}),
json!({"transparencyThreshold": 1e308}),
] {
assert!(matches!(s.execute(ID, p.clone()), Err(EngineError::BadParams { .. })), "{p}");
}
assert_eq!(pixels(&s), before);
}
#[test]
fn smart_objects_get_a_smart_filter_and_last_filter_repeats() {
let mut s = session(8, "rgb", true);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute(ID, json!({"color": "#ffffff"})).unwrap();
let d = s.active().unwrap();
let photocraft_doc::LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") };
assert_eq!(sm.smart_filters.len(), 1);
assert_eq!(sm.smart_filters[0].command, ID);
assert!(sm.cache.as_ref().unwrap().rgba(W - 1, 0)[3] < 0.02, "the cache was re-rendered");
let mut t = session(8, "rgb", true);
t.execute(ID, json!({"opacityThreshold": 0.5})).unwrap();
let once = pixels(&t);
t.execute("filter.lastFilter", json!({})).unwrap();
assert_ne!(pixels(&t), once, "Last Filter ran Color to Alpha again");
}
#[test]
fn lenient_mapping_clamps_recorded_params() {
assert_eq!(
params(&json!({"color": [2.0, -1.0, 0.5], "transparencyThreshold": -3, "opacityThreshold": 9})),
FilterParams::ColorToAlpha { color: [1.0, 0.0, 0.5, 1.0], transparency_threshold: 0.0, opacity_threshold: 1.0 }
);
assert_eq!(params(&Value::Null), FilterParams::ColorToAlpha { color: WHITE, transparency_threshold: 0.0, opacity_threshold: 1.0 });
}
}

View File

@ -1089,6 +1089,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::layer_style::specs());
v.extend(crate::filters::specs());
v.extend(crate::filters_ext::specs());
v.extend(crate::color_to_alpha_cmds::specs());
v.extend(crate::gallery_cmds::specs());
v.extend(crate::gradient_fill_cmds::specs());
v.extend(crate::type_cmds::specs());

View File

@ -240,11 +240,20 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result<Value>
*surf = filter(surf, &fp, area, bounds, selection.as_ref(), doc_bounds.union(&content))?;
return Ok(fp.clone());
}
// Filters that make pixels transparent (Color to Alpha) turn the Background into a
// normal layer first, as the erasers do: it can't hold transparency.
if fp.makes_transparency() {
crate::extra_cmds::background_to_layer_for_mask(doc, layer);
}
let locks = doc.effective_locks(layer);
let l = doc.layer_mut(layer).ok_or(EngineError::NoLayer(layer))?;
if locks.pixels || locks.all {
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
}
// Putting the old alpha back (below) would leave unmixed colours fully opaque.
if locks.transparency && fp.makes_transparency() && matches!(l.content, LayerContent::Raster(_)) {
return Err(EngineError::Other(format!("Could not complete your request because the transparency of layer \"{}\" is locked", l.name)));
}
let mut fp = fp.clone();
crate::filters_ext::resolve_in_layer(&mut fp, l, bounds);
let surf = match &mut l.content {

View File

@ -386,6 +386,7 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
};
FilterParams::HsbHsl { input: m("inputMode", "rgb"), output: m("rowOrder", "hsb") }
}
crate::color_to_alpha_cmds::ID => crate::color_to_alpha_cmds::params(p),
// ---- Video ----
"filter.video.deInterlace" => FilterParams::DeInterlace {
eliminate_even: s(p, "eliminate", "oddFields") == "evenFields",

View File

@ -21,6 +21,7 @@ pub mod build_info;
mod canvas_geom;
pub mod channel_cmds;
pub mod color_cmds;
pub mod color_to_alpha_cmds;
pub mod commands;
pub mod comps_cmds;
pub mod cutout_cmds;

View File

@ -31,6 +31,9 @@ pub enum Kind {
Grid(usize),
/// Structured JSON (pins, curve points…): settable through the command, not shown in the dialog.
Json,
/// A `"#rrggbb"` colour (`color`, or `color=#rrggbb` with a default), picked with a swatch.
/// Without a default the param stays unset until a colour is picked.
Color(Option<[f32; 3]>),
}
#[derive(Clone, Debug, PartialEq)]
@ -83,6 +86,10 @@ pub fn parse_spec(spec: &str) -> Vec<Param> {
Kind::Bool(rest.trim_start_matches('=').trim() == "true")
} else if v == "text" {
Kind::Text
} else if v == "color" {
Kind::Color(None)
} else if let Some(default) = v.strip_prefix("color=") {
Kind::Color(crate::color_picker_ui::parse_hex(default))
} else if v == "doc" {
Kind::Document
} else if v == "json" || v.starts_with("layer id") || v.starts_with('[') || v.starts_with('{') {
@ -185,6 +192,8 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
Kind::Document => json!(-1),
// Identity kernel: 1 in the centre.
Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::<Vec<_>>()),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
Kind::Color(None) => continue,
// Colour inputs come from the current swatches, so the proxy preview matches the result.
Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground),
Kind::Json if p.key == "background" => json!(app.session.tools.background),
@ -231,6 +240,7 @@ pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec:
Kind::Choice(c) => json!(c.first().cloned().unwrap_or_default()),
Kind::Bool(default) => json!(default),
Kind::Int { default } => json!(default),
Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)),
_ => continue,
};
fields.insert(p.key, v);
@ -308,7 +318,9 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
} else {
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
}
f.insert(p.key, json!((v * 10.0).round() / 10.0));
// Small ranges (0–1 thresholds and centres) keep the two decimals their field shows.
let scale = if max - min <= 10.0 { 100.0 } else { 10.0 };
f.insert(p.key, json!((v * scale).round() / scale));
}
Kind::Choice(options) => {
ui.horizontal(|ui| {
@ -370,6 +382,25 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
f.insert(p.key, json!(vals.iter().map(|v| v.round() as i64).collect::<Vec<_>>()));
}
Kind::Json => {}
Kind::Color(default) => {
let set = f.get(&p.key).and_then(Value::as_str).and_then(crate::color_picker_ui::parse_hex);
let rgb = set.or(default).unwrap_or([0.0; 3]);
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let mut bytes = rgb.map(q);
let mut changed = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
changed = crate::widgets::color_edit_button_srgb(ui, &mut bytes).changed();
ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace());
});
});
if changed {
f.insert(p.key, json!(crate::color_picker_ui::hex(bytes.map(|b| f32::from(b) / 255.0))));
} else if set.is_none() && default.is_some() {
f.insert(p.key, json!(crate::color_picker_ui::hex(rgb)));
}
}
Kind::Int { default } => {
let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32;
ui.horizontal(|ui| {
@ -482,6 +513,7 @@ mod tests {
"filter.pixelate.mezzotint",
"filter.render.lightingEffects",
"filter.render.relight",
"filter.other.colorToAlpha",
] {
assert!(has_dialog(id), "{id}");
}
@ -551,6 +583,47 @@ mod tests {
assert_eq!(label("wavelengthMin"), "Wavelength Min");
}
#[test]
fn colour_params_parse_with_and_without_a_default() {
let p = parse_spec(r##"{"color":color=#ffffff,"vineColor":color,"bad":color=#zz}"##);
assert_eq!(p[0].kind, Kind::Color(Some([1.0, 1.0, 1.0])));
assert_eq!(p[1].kind, Kind::Color(None));
assert_eq!(p[2].kind, Kind::Color(None));
}
/// Color to Alpha (#1576): the dialog starts at white and the GIMP-style thresholds, drawing it
/// keeps those params, and its live preview makes the white of a Background transparent.
#[test]
fn color_to_alpha_dialog_defaults_draw_and_preview() {
const ID: &str = "filter.other.colorToAlpha";
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
let id = open(&mut app, ID).unwrap();
let mut f = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone();
assert_eq!(f.get("__preview"), Some(&json!(true)));
let want = json!({"color": "#ffffff", "transparencyThreshold": 0.0, "opacityThreshold": 1.0});
assert_eq!(params_of(&f), want);
egui::Context::default().run_ui(Default::default(), |ui| body(ui, &mut f)).textures_delta.clear();
assert_eq!(params_of(&f), want, "drawing the dialog keeps its params");
let mut doc = Document::with_background(
"p",
photocraft_doc::Size::new(32, 32),
photocraft_doc::ColorMode::Rgb,
photocraft_doc::SampleType::U16,
photocraft_doc::Color::WHITE,
);
let bg = doc.layers[0].id;
doc.layers[0].surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 0, 8, 32), &[0.0, 0.0, 0.0, 1.0]);
for k in [1, 2] {
let out = preview_document(&doc, Some(bg), ID, &want, k).unwrap();
let s = out.layers[0].surface().unwrap();
assert_eq!(out.layers[0].name, "Layer 0", "k={k}");
assert!(s.pixel(24 / k as i32, 4)[3] < 1e-3, "k={k}: white removed");
assert_eq!(s.pixel(1, 4)[3], 1.0, "k={k}: black kept");
}
}
#[test]
fn preview_stays_inside_the_selection_at_every_proxy_factor() {
use photocraft_doc::SampleType;

View File

@ -2266,3 +2266,6 @@ fontWeight Italic Kurzíva
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu
Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu
Color to Alpha… Barva na alfu…
Transparency Threshold Práh průhlednosti
Opacity Threshold Práh krytí

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2305,3 +2305,6 @@ fontWeight Italic Kursiv
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe
Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe
Color to Alpha… Farbe zu Alpha…
Transparency Threshold Transparenz-Schwellenwert
Opacity Threshold Deckkraft-Schwellenwert

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3379,3 +3379,6 @@ fontWeight Thin Πολύ λεπτά
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα
Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web
Color to Alpha… Χρώμα σε άλφα…
Transparency Threshold Κατώφλι διαφάνειας
Opacity Threshold Κατώφλι αδιαφάνειας

Can't render this file because it has a wrong number of fields in line 5.

View File

@ -2335,3 +2335,6 @@ fontWeight Italic Cursiva
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano
Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano
Color to Alpha… Color a alfa…
Transparency Threshold Umbral de transparencia
Opacity Threshold Umbral de opacidad

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2689,3 +2689,6 @@ fontWeight Italic Italique
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche
Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche
Color to Alpha… Couleur vers alpha…
Transparency Threshold Seuil de transparence
Opacity Threshold Seuil d'opacité

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2676,3 +2676,6 @@ fontWeight Italic Miring
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat
Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat
Color to Alpha… Warna ke Alpha…
Transparency Threshold Ambang transparansi
Opacity Threshold Ambang opasitas

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2336,3 +2336,6 @@ fontWeight Italic Corsivo
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino
Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino
Color to Alpha… Colore ad alfa…
Transparency Threshold Soglia di trasparenza
Opacity Threshold Soglia di opacità

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -3293,3 +3293,6 @@ fontWeight Italic 斜体
# Command id overrides
@id select.all すべてを選択
Color to Alpha… カラーを透明に…
Transparency Threshold 透明のしきい値
Opacity Threshold 不透明度のしきい値

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2845,3 +2845,6 @@ fontWeight Italic 기울임
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다
Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
Color to Alpha… 색상을 알파로…
Transparency Threshold 투명도 한계값
Opacity Threshold 불투명도 한계값

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -3290,3 +3290,6 @@ cameraRaw Roughness Chropowatość
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku
Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy
Color to Alpha… Kolor na alfę…
Transparency Threshold Próg przezroczystości
Opacity Threshold Próg krycia

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2696,3 +2696,6 @@ fontWeight Italic Itálico
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima
Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima
Color to Alpha… Cor para alfa…
Transparency Threshold Limiar de transparência
Opacity Threshold Limiar de opacidade

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2538,3 +2538,6 @@ fontWeight Italic Курсив
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет
Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет
Color to Alpha… Цвет в альфа-канал…
Transparency Threshold Порог прозрачности
Opacity Threshold Порог непрозрачности

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2274,3 +2274,6 @@ fontWeight Italic 斜体
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色
Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色
Color to Alpha… 颜色转为 Alpha…
Transparency Threshold 透明度阈值
Opacity Threshold 不透明度阈值

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2243,3 +2243,6 @@ fontWeight Italic 斜體
# Color Picker warnings
Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色
Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色
Color to Alpha… 顏色轉為 Alpha…
Transparency Threshold 透明度臨界值
Opacity Threshold 不透明度臨界值

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -682,8 +682,14 @@ pub fn is_live(id: &str) -> bool {
}
/// Commands outside the catalogue that belong right after a catalogue item: `(id, after)`.
const PLACE_AFTER: &[(&str, &str)] =
&[("file.newFromClipboard", "file.new"), ("filter.render.relight", "filter.render.lightingEffects"), ("view.resetView", "view.flipHorizontal")];
/// The command takes that item's submenu path, so it joins Photoshop's submenu even where the
/// catalogue spells it differently (Filter › "Other…").
const PLACE_AFTER: &[(&str, &str)] = &[
("file.newFromClipboard", "file.new"),
("filter.render.relight", "filter.render.lightingEffects"),
("view.resetView", "view.flipHorizontal"),
("filter.other.colorToAlpha", "filter.other.offset"),
];
pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
// 1) Photoshop's full menu tree, in Photoshop order; live where we implement the command.
@ -726,13 +732,16 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
color: None,
});
}
for e in extra {
for mut e in extra {
let dup = items.iter().any(|i| i.id == e.id || (i.path == e.path && i.label.trim_end_matches('…') == e.label.trim_end_matches('…')));
if !dup {
// Insert after the item it belongs next to, else after the last item of the same
// top-level menu, keeping menus contiguous.
let top = e.path.first().cloned();
let after = PLACE_AFTER.iter().find(|(id, _)| *id == e.id).and_then(|(_, a)| items.iter().position(|i| i.id == *a));
if let Some(path) = after.and_then(|a| items.get(a)).map(|i| i.path.clone()) {
e.path = path;
}
let at = after.or_else(|| items.iter().rposition(|i| i.path.first() == top.as_ref())).map_or(items.len(), |p| p + 1);
items.insert(at, e);
}
@ -1227,6 +1236,23 @@ mod tests {
assert!(!top.iter().any(|i| i.id == "select.modify.feather"), "no extra top-level Feather");
}
/// Color to Alpha (#1576) has no Photoshop counterpart: it joins Photoshop's own Filter ›
/// Other submenu (not a second "Other") right after Offset, enabled on a pixel layer.
#[test]
fn color_to_alpha_sits_in_filter_other_after_offset() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
let items = menu_items(&app);
let at = |id: &str| items.iter().position(|i| i.id == id).unwrap_or_else(|| panic!("{id} missing"));
let (offset, c2a) = (at("filter.other.offset"), at("filter.other.colorToAlpha"));
assert_eq!(c2a, offset + 1);
assert_eq!(items[c2a].path, items[offset].path);
assert_eq!(items[c2a].label, "Color to Alpha…");
assert!(items[c2a].enabled, "the Background becomes a layer when the filter runs");
let others: Vec<_> = items.iter().filter(|i| i.path.first().is_some_and(|p| p == "Filter") && i.path.len() == 2).map(|i| &i.path[1]).collect();
assert!(!others.iter().any(|p| p.as_str() == "Other"), "no duplicate Other submenu: {others:?}");
}
#[test]
fn menu_bar_labels_have_horizontal_padding_and_open_menus() {
use egui_kittest::{Harness, kittest::Queryable};

View File

@ -394,7 +394,7 @@ psd/src/
| Area | Contents (first pass) | GPU kernel? |
|---|---|---|
| Adjustments | levels, curves, brightness/contrast, exposure, vibrance, hue/sat, color balance, B&W, photo filter, channel mixer, gradient map, selective color, invert/posterize/threshold, 3D LUT | yes (LUT + small kernels) |
| Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate | many |
| Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate, color to alpha | many |
| Selection | marquee/lasso geometry → mask, magic wand (flood fill tolerance), color range, quick select (graph cut / superpixels), feather, grow/shrink, refine edge | some |
| Inpaint/heal | spot heal, healing brush (Poisson), content-aware fill (PatchMatch + multiscale EM) | later |
| Warp | free transform (affine/perspective/warp mesh), liquify, puppet (ARAP), lens correction, resampling (Lanczos/bicubic) | yes |