Merge main
This commit is contained in:
commit
df0e858198
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue