diff --git a/crates/algo/src/color_to_alpha.rs b/crates/algo/src/color_to_alpha.rs new file mode 100644 index 00000000..53e86f04 --- /dev/null +++ b/crates/algo/src/color_to_alpha.rs @@ -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 { + 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)); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index f82d6073..5a487480 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -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 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), diff --git a/crates/algo/src/tests_ext.rs b/crates/algo/src/tests_ext.rs index 864c5beb..fd35f274 100644 --- a/crates/algo/src/tests_ext.rs +++ b/crates/algo/src/tests_ext.rs @@ -106,6 +106,8 @@ fn new_filters() -> Vec { 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, ] diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs new file mode 100644 index 00000000..c3120f4a --- /dev/null +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -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) -> 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 { + validate(p)?; + crate::filters::run_filter(s, ID, p) +} + +/// The command spec. +pub fn specs() -> Vec { + 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 { + 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 }); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a4feea30..aa31010b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1089,6 +1089,7 @@ fn build() -> Vec { 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()); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index af99de42..379a6b7a 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -240,11 +240,20 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result *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 { diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index 9d3730fc..bf1656ca 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -386,6 +386,7 @@ pub fn params_for(id: &str, p: &Value) -> Option { }; 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01097aeb..9f62f212 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index e7e43dc7..3e502fc7 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -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 { 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 { Kind::Document => json!(-1), // Identity kernel: 1 in the centre. Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), + 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) { } 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) { f.insert(p.key, json!(vals.iter().map(|v| v.round() as i64).collect::>())); } 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; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1eee87fe..457a4537 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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í diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a531d8df..27a59392 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e4acb0ce..1bbd00a6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Κατώφλι αδιαφάνειας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 38dcaaed..fed59e6e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 92fdbc9f..c91df218 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f36069c9..997674e3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e121fbd7..b1b7a271 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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à diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b7295421..f1d73e36 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3293,3 +3293,6 @@ fontWeight Italic 斜体 # Command id overrides @id select.all すべてを選択 + Color to Alpha… カラーを透明に… + Transparency Threshold 透明のしきい値 + Opacity Threshold 不透明度のしきい値 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 00319021..faab7d23 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 불투명도 한계값 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 21d97b5a..b13c74de 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ec4bc9a3..42611857 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 049a16b7..e567e423 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Порог непрозрачности diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index da674509..7ded4bfc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 不透明度阈值 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 785b419f..2aaf215e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 不透明度臨界值 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a45aa7a7..98418b6f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { // 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 { 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}; diff --git a/docs/architecture.md b/docs/architecture.md index b045058f..83c53a04 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 |