diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index c422a00b..5de007fc 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -13,10 +13,10 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; /// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop -/// brushes (.abr) and gradients (.grd), which go to the preset libraries. +/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", - "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", + "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", ]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9b05eefe..eaea4e4e 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -12,10 +12,10 @@ use std::rc::Rc; use std::sync::Arc; /// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop -/// brushes (.abr) and gradients (.grd), which go to the preset libraries. +/// brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", - "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", + "pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", ]; /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's @@ -32,9 +32,16 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("OpenEXR", &["exr"]), ]; +/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog +/// never swaps their extension for a document format's. +const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Swatch Exchange", &["ase"])]; + /// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd. fn save_filters(suggested: &str) -> Vec<(String, Vec)> { let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + if let Some((name, exts)) = OTHER_SAVE_FILTERS.iter().find(|(_, exts)| exts.contains(&ext.as_str())) { + return vec![(name.to_string(), exts.iter().map(|e| e.to_string()).collect())]; + } let mut v: Vec<(String, Vec)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect(); match v.iter().position(|(_, exts)| exts.contains(&ext)) { Some(i) => { diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 2244a2b8..113f2998 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -258,10 +258,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" => { + "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } + // With a `path` the file is written there; without one the bytes come back as `data`. + "swatches.export" => params.get("path").is_some(), "plugin.reload" => true, _ => false, } @@ -506,6 +508,8 @@ mod tests { ("plugin.reload", serde_json::json!({"path": "/outside/plugins"})), ("plugin.reload", serde_json::json!({})), ("plugin.install", serde_json::json!({"path": " "})), + ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), + ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -513,6 +517,8 @@ mod tests { ("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})), ("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})), ("plugin.install", serde_json::json!({"data": "QUJD"})), + ("swatches.import", serde_json::json!({"data": "QUJD"})), + ("swatches.export", serde_json::json!({"format": "ase"})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 8191c673..202d62a5 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1036,6 +1036,7 @@ fn build() -> Vec { v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); + v.extend(crate::swatch_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::redeye_cmds::specs()); v.extend(crate::image_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df302590..9f6184a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,6 +81,7 @@ pub mod smart_cmds; pub mod smartselect_cmds; pub mod snap; pub mod stamp_cmds; +pub mod swatch_cmds; pub mod symmetry_cmds; mod timeline_cmds; pub mod transform_cmds; diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index e2a22589..c78646e8 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -11,7 +11,9 @@ //! - shapes ([`shapes`]): vector shapes for the Custom Shape tool, placed as shape layers; //! - tool presets ([`tools`]): a tool plus its options (opaque JSON the shell fills in); //! - clone sources ([`clone_source`]): five source slots with offset, scale, rotation and flip -//! that `paint.cloneStamp` / `paint.healingBrush` follow. +//! that `paint.cloneStamp` / `paint.healingBrush` follow; +//! - swatches ([`swatches`]): named colours in their own colour model (commands in +//! `crate::swatch_cmds`, `.aco`/`.ase` import and export). //! //! The built-in groups are our own (clean-room): colours, styles and shapes were designed here, //! not copied from Photoshop's presets. User edits persist with the preferences document: see @@ -22,6 +24,7 @@ pub mod gradients; pub mod patterns; pub mod shapes; pub mod styles; +pub mod swatches; pub mod tools; #[cfg(test)] @@ -80,6 +83,8 @@ pub struct PresetState { /// The user defaults "Make Default" in the Layer Style dialog saves /// (effect kind → param set); "Reset to Default" restores them. pub layer_defaults: BTreeMap, + /// Swatches panel library. + pub swatches: Vec>, /// Bumped on every preset change (UIs key thumbnail caches on it). pub rev: u64, } @@ -96,6 +101,7 @@ impl Default for PresetState { tool_presets: tools::builtin(), clone: Default::default(), layer_defaults: BTreeMap::new(), + swatches: swatches::builtin(), rev: 0, } } @@ -120,6 +126,7 @@ struct Persisted { custom_shapes: Option>, #[serde(default)] layer_defaults: Option>, + swatches: Option>>, } impl PresetState { @@ -134,6 +141,7 @@ impl PresetState { tool_presets: Some(self.tool_presets.clone()), custom_shapes: Some(s.edit_state.custom_shapes.clone()), layer_defaults: Some(self.layer_defaults.clone()), + swatches: Some(self.swatches.clone()), }; serde_json::to_value(p).unwrap_or(Value::Null) } @@ -169,6 +177,11 @@ impl Session { if let Some(d) = p.layer_defaults { st.layer_defaults = d; } + if let Some(mut g) = p.swatches { + // A hand-edited preferences file can't make the library unbounded. + crate::swatch_cmds::cap_library(&mut g); + self.presets.swatches = g; + } self.presets.rev += 1; } diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs new file mode 100644 index 00000000..f46aad32 --- /dev/null +++ b/crates/engine/src/presets/swatches.rs @@ -0,0 +1,423 @@ +//! Window › Swatches: named colours in groups (folders), as in Photoshop's Swatches panel. +//! +//! A swatch keeps its colour in the model it was made in: RGB, HSB, CMYK, Lab or grayscale, +//! as `f32` components that hold 16-bit file values exactly (an `.aco` → `.aco` round trip is +//! lossless), plus colour-book entries we cannot evaluate kept verbatim. Nothing assumes sRGB: +//! RGB and HSB components are in the RGB working space, CMYK and gray in the CMYK and gray +//! working spaces (Edit › Color Settings), Lab is D50. Turning a swatch into the RGB the +//! foreground colour holds (and the panel shows) goes through `photocraft-cms` +//! ([`SwatchConv`]). +//! +//! The library persists with the preferences document (`presets.swatches`, see +//! [`super::PresetState::to_json`]). The default set is our own (clean-room): grays, pastels, +//! pure and dark hues. The commands live in `crate::swatch_cmds`; file formats in +//! `photocraft_psd::{aco, ase}`. + +use std::sync::Arc; + +use photocraft_cms::{Builtin, Profile, Transform, TransformOptions}; +use photocraft_color::ColorMode; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{Group, Named}; +use crate::Session; + +/// A swatch colour in its own model. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "model", content = "values", rename_all = "camelCase")] +pub enum SwatchColor { + /// Red, green, blue 0..1 in the RGB working space. + Rgb([f32; 3]), + /// Hue 0..360°, saturation and brightness 0..1 (an RGB working-space colour). + Hsb([f32; 3]), + /// Cyan, magenta, yellow, black ink 0..1 in the CMYK working space. + Cmyk([f32; 4]), + /// L* 0..100, a*, b* (D50). + Lab([f32; 3]), + /// Black ink 0..1 (0 = white) in the gray working space, as Photoshop's K slider. + Gray(f32), + /// A colour space PhotoCraft does not model (colour books and the like), kept verbatim from + /// an `.aco` file so it survives a round trip. + Book { space: u16, values: [u16; 4] }, +} + +/// The colour type Adobe Swatch Exchange files carry (kept for round trips). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SwatchKind { + #[default] + Process, + Global, + Spot, +} + +impl SwatchKind { + pub fn id(self) -> &'static str { + match self { + SwatchKind::Process => "process", + SwatchKind::Global => "global", + SwatchKind::Spot => "spot", + } + } + pub fn parse(s: &str) -> Option { + Some(match s { + "process" | "normal" => SwatchKind::Process, + "global" => SwatchKind::Global, + "spot" => SwatchKind::Spot, + _ => return None, + }) + } + fn is_process(&self) -> bool { + *self == SwatchKind::Process + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Swatch { + pub name: String, + pub color: SwatchColor, + #[serde(default, skip_serializing_if = "SwatchKind::is_process")] + pub kind: SwatchKind, +} + +impl Swatch { + pub fn new(name: &str, color: SwatchColor) -> Self { + Swatch { name: name.into(), color, kind: SwatchKind::Process } + } +} + +impl Named for Swatch { + fn name(&self) -> &str { + &self.name + } + fn set_name(&mut self, n: String) { + self.name = n; + } +} + +fn finite(v: f32) -> f32 { + if v.is_finite() { v } else { 0.0 } +} + +fn unit(v: f32) -> f32 { + finite(v).clamp(0.0, 1.0) +} + +/// HSB (hue in degrees) to RGB, both in the same RGB space. +pub fn hsb_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let (s, v) = (unit(s), unit(v)); + let h = finite(h).rem_euclid(360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - (h % 2.0 - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = v - c; + [r + m, g + m, b + m].map(unit) +} + +impl SwatchColor { + /// Model id (`rgb`, `hsb`, `cmyk`, `lab`, `gray`, `book`). + pub fn model(&self) -> &'static str { + match self { + SwatchColor::Rgb(_) => "rgb", + SwatchColor::Hsb(_) => "hsb", + SwatchColor::Cmyk(_) => "cmyk", + SwatchColor::Lab(_) => "lab", + SwatchColor::Gray(_) => "gray", + SwatchColor::Book { .. } => "book", + } + } + + /// Components in the units the Color Picker shows: RGB 0..255, HSB degrees and percent, + /// CMYK and gray ink percent, Lab as is. + pub fn display_values(&self) -> Vec { + let r = |v: f32, k: f64| (f64::from(finite(v)) * k * 1000.0).round() / 1000.0; + match *self { + SwatchColor::Rgb(c) => c.iter().map(|v| r(*v, 255.0)).collect(), + SwatchColor::Hsb([h, s, b]) => vec![r(h, 1.0), r(s, 100.0), r(b, 100.0)], + SwatchColor::Cmyk(c) => c.iter().map(|v| r(*v, 100.0)).collect(), + SwatchColor::Lab(c) => c.iter().map(|v| r(*v, 1.0)).collect(), + SwatchColor::Gray(k) => vec![r(k, 100.0)], + SwatchColor::Book { space, values } => std::iter::once(f64::from(space)).chain(values.iter().map(|v| f64::from(*v))).collect(), + } + } + + /// Parse a command's colour: `"#rrggbb"` (RGB working space), or + /// `{"model":"rgb|hsb|cmyk|lab|gray","values":[…]}` in [`display_values`](Self::display_values) units. + pub fn from_param(v: &Value) -> Result { + if let Some(s) = v.as_str() { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("bad colour `{s}` (\"#rrggbb\" or {{\"model\",\"values\"}})")); + } + let b = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |x| f32::from(x) / 255.0); + return Ok(SwatchColor::Rgb([b(0), b(2), b(4)])); + } + let model = v.get("model").and_then(Value::as_str).ok_or("a colour needs `model` (rgb|hsb|cmyk|lab|gray) and `values`")?; + let vals: Vec = v + .get("values") + .and_then(Value::as_array) + .ok_or("a colour needs `values`")? + .iter() + .map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f as f32).ok_or("colour values must be finite numbers")) + .collect::>()?; + let need = |n: usize| if vals.len() == n { Ok(()) } else { Err(format!("a {model} colour has {n} values, got {}", vals.len())) }; + let pct = |v: f32| (v / 100.0).clamp(0.0, 1.0); + Ok(match model { + "rgb" => { + need(3)?; + SwatchColor::Rgb([0, 1, 2].map(|i| (vals[i] / 255.0).clamp(0.0, 1.0))) + } + "hsb" => { + need(3)?; + SwatchColor::Hsb([vals[0].rem_euclid(360.0), pct(vals[1]), pct(vals[2])]) + } + "cmyk" => { + need(4)?; + SwatchColor::Cmyk([0, 1, 2, 3].map(|i| pct(vals[i]))) + } + "lab" => { + need(3)?; + SwatchColor::Lab([vals[0].clamp(0.0, 100.0), vals[1].clamp(-128.0, 127.0), vals[2].clamp(-128.0, 127.0)]) + } + "gray" => { + need(1)?; + SwatchColor::Gray(pct(vals[0])) + } + m => return Err(format!("unknown colour model `{m}` (rgb|hsb|cmyk|lab|gray)")), + }) + } + + pub fn to_json(&self) -> Value { + json!({"model": self.model(), "values": self.display_values()}) + } +} + +// ------------------------------------------------------------------ colour management + +/// Converts swatch colours to the RGB working space through `photocraft-cms` (relative +/// colorimetric + black point compensation, Photoshop's default). Build one per library or +/// colour-settings change; evaluating is cheap. +pub struct SwatchConv { + cmyk: Option>, + lab: Option>, + gray: Option>, +} + +fn working(s: Option<&Session>, mode: ColorMode) -> Arc { + let spec = s.map(|s| { + let c = &s.color.settings; + match mode { + ColorMode::Cmyk => c.working_cmyk.clone(), + ColorMode::Grayscale => c.working_gray.clone(), + _ => c.working_rgb.clone(), + } + }); + spec.and_then(|sp| crate::color_cmds::resolve_profile(&sp, None, Some(mode)).ok()) + .filter(|p| p.color_space == crate::color_cmds::mode_space(mode)) + .unwrap_or_else(|| crate::color_cmds::working_profile(mode)) +} + +impl SwatchConv { + /// Conversions into the session's RGB working space from its CMYK and gray working spaces. + pub fn new(s: &Session) -> Self { + Self::with(Some(s)) + } + + /// The built-in working spaces (sRGB, coated CMYK, sGray). + pub fn builtin() -> Self { + Self::with(None) + } + + fn with(s: Option<&Session>) -> Self { + let rgb = working(s, ColorMode::Rgb); + let opts = TransformOptions { intent: photocraft_cms::Intent::RelativeColorimetric, bpc: true, ..Default::default() }; + let link = |src: &Profile| photocraft_cms::cached(src, &rgb, opts).ok(); + SwatchConv { cmyk: link(&working(s, ColorMode::Cmyk)), lab: link(Builtin::LabD50.profile()), gray: link(&working(s, ColorMode::Grayscale)) } + } + + /// The colour in the RGB working space (what the foreground colour holds), 0..1. + /// Colour-book entries, which have no colour of their own here, are mid gray. + pub fn rgb(&self, c: &SwatchColor) -> [f32; 3] { + let eval = |t: &Option>, input: &[f32]| -> Option<[f32; 3]> { + let t = t.as_ref()?; + let mut o = [0.0f32; 16]; + t.eval(input, &mut o); + Some([unit(o[0]), unit(o[1]), unit(o[2])]) + }; + match *c { + SwatchColor::Rgb(v) => v.map(unit), + SwatchColor::Hsb(v) => hsb_to_rgb(v), + SwatchColor::Cmyk(v) => { + let v = v.map(unit); + eval(&self.cmyk, &v).unwrap_or_else(|| photocraft_color::convert::cmyk_to_rgb(v)) + } + SwatchColor::Lab([l, a, b]) => { + // ICC v4 Lab encoding (the Lab D50 profile's device values). + let enc = [unit(finite(l) / 100.0), unit((finite(a) + 128.0) / 255.0), unit((finite(b) + 128.0) / 255.0)]; + eval(&self.lab, &enc).unwrap_or_else(|| photocraft_color::convert::lab_to_srgb([finite(l), finite(a), finite(b)])) + } + SwatchColor::Gray(k) => { + let level = 1.0 - unit(k); + eval(&self.gray, &[level]).unwrap_or([level; 3]) + } + SwatchColor::Book { .. } => [0.5; 3], + } + } +} + +/// `#rrggbb` of RGB components. +pub fn hex(c: [f32; 3]) -> String { + let q = |v: f32| (unit(v) * 255.0).round() as u8; + format!("#{:02x}{:02x}{:02x}", q(c[0]), q(c[1]), q(c[2])) +} + +// ------------------------------------------------------------------ defaults + +/// The default library: 40 colours in four groups (our own palette). +pub fn builtin() -> Vec> { + let rgb = |n: &str, c: [u8; 3]| Swatch::new(n, SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0))); + let grays = [ + ("Black", 0), + ("90% Gray", 26), + ("80% Gray", 51), + ("70% Gray", 77), + ("60% Gray", 102), + ("50% Gray", 128), + ("40% Gray", 153), + ("30% Gray", 179), + ("20% Gray", 204), + ("White", 255), + ]; + let hues = ["Red", "Orange", "Yellow", "Yellow Green", "Green", "Cyan Green", "Cyan", "Blue", "Violet", "Magenta"]; + let pastel: [[u8; 3]; 10] = [ + [236, 128, 128], + [244, 176, 132], + [250, 224, 128], + [214, 240, 128], + [150, 232, 150], + [128, 232, 200], + [128, 220, 240], + [128, 176, 244], + [168, 144, 244], + [232, 144, 232], + ]; + let pure: [[u8; 3]; 10] = [ + [230, 40, 40], + [245, 120, 30], + [250, 210, 30], + [160, 220, 40], + [40, 200, 80], + [30, 200, 170], + [30, 170, 230], + [40, 100, 230], + [120, 70, 220], + [210, 50, 180], + ]; + let dark: [[u8; 3]; 10] = + [[120, 20, 20], [130, 60, 10], [130, 110, 10], [80, 120, 20], [20, 100, 40], [10, 100, 90], [10, 80, 120], [20, 50, 120], [60, 30, 110], [110, 20, 90]]; + let row = |prefix: &str, cs: [[u8; 3]; 10]| -> Vec { + hues.iter().zip(cs).map(|(h, c)| rgb(&if prefix.is_empty() { (*h).to_string() } else { format!("{prefix} {h}") }, c)).collect() + }; + vec![ + Group::new("Grays", grays.iter().map(|(n, v)| rgb(n, [*v; 3])).collect()), + Group::new("Pastel", row("Pastel", pastel)), + Group::new("Pure", row("", pure)), + Group::new("Dark", row("Dark", dark)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_library_has_the_forty_colours() { + let b = builtin(); + assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 40); + let names: Vec<&str> = b.iter().flat_map(|g| g.items.iter().map(|s| s.name.as_str())).collect(); + let mut uniq = names.clone(); + uniq.sort_unstable(); + uniq.dedup(); + assert_eq!(uniq.len(), 40, "names are unique: {names:?}"); + assert_eq!(b[2].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + } + + #[test] + fn conversions_go_through_the_cms() { + let c = SwatchConv::builtin(); + let near = |a: [f32; 3], b: [f32; 3], tol: f32| a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol); + assert_eq!(c.rgb(&SwatchColor::Rgb([0.1, 0.2, 0.3])), [0.1, 0.2, 0.3]); + assert!(near(c.rgb(&SwatchColor::Hsb([120.0, 1.0, 1.0])), [0.0, 1.0, 0.0], 1e-6)); + // Lab white and black, gray (no ink = white), paper-white CMYK. + assert!(near(c.rgb(&SwatchColor::Lab([100.0, 0.0, 0.0])), [1.0; 3], 0.01)); + assert!(near(c.rgb(&SwatchColor::Lab([0.0, 0.0, 0.0])), [0.0; 3], 0.01)); + assert!(near(c.rgb(&SwatchColor::Gray(0.0)), [1.0; 3], 0.01)); + assert!(near(c.rgb(&SwatchColor::Gray(1.0)), [0.0; 3], 0.01)); + assert!(near(c.rgb(&SwatchColor::Cmyk([0.0; 4])), [1.0; 3], 0.02)); + // Full cyan reads as a blue-green, not as naive (0, 1, 1). + let cy = c.rgb(&SwatchColor::Cmyk([1.0, 0.0, 0.0, 0.0])); + assert!(cy[0] < 0.3 && cy[2] > cy[0] && cy[1] > cy[0], "{cy:?}"); + // Lab red-ish. + let red = c.rgb(&SwatchColor::Lab([54.0, 80.0, 67.0])); + assert!(red[0] > 0.9 && red[1] < 0.2, "{red:?}"); + // Out-of-range and non-finite stored values never escape 0..1. + for col in [SwatchColor::Rgb([f32::NAN, 9.0, -3.0]), SwatchColor::Hsb([f32::INFINITY, 2.0, -1.0]), SwatchColor::Lab([500.0, f32::NAN, -999.0])] { + assert!(c.rgb(&col).iter().all(|v| (0.0..=1.0).contains(v)), "{col:?}"); + } + assert_eq!(c.rgb(&SwatchColor::Book { space: 3, values: [0; 4] }), [0.5; 3]); + } + + #[test] + fn a_wide_gamut_working_space_changes_cmyk_but_not_rgb() { + let mut s = Session::new(); + s.color.settings.working_rgb = "display-p3".into(); + let p3 = SwatchConv::new(&s); + let srgb = SwatchConv::builtin(); + let cmyk = SwatchColor::Cmyk([1.0, 0.0, 0.0, 0.0]); + assert_ne!(p3.rgb(&cmyk), srgb.rgb(&cmyk)); + assert_eq!(p3.rgb(&SwatchColor::Rgb([0.2, 0.4, 0.6])), [0.2, 0.4, 0.6]); + } + + #[test] + fn colour_params() { + assert_eq!(SwatchColor::from_param(&json!("#ff0000")).unwrap(), SwatchColor::Rgb([1.0, 0.0, 0.0])); + assert_eq!(SwatchColor::from_param(&json!({"model": "cmyk", "values": [100, 0, 50, 0]})).unwrap(), SwatchColor::Cmyk([1.0, 0.0, 0.5, 0.0])); + assert_eq!(SwatchColor::from_param(&json!({"model": "gray", "values": [25]})).unwrap(), SwatchColor::Gray(0.25)); + let lab = SwatchColor::from_param(&json!({"model": "lab", "values": [50, -20, 30]})).unwrap(); + assert_eq!(lab.display_values(), vec![50.0, -20.0, 30.0]); + for bad in [ + json!("#ff00"), + json!("red"), + json!(5), + json!({}), + json!({"model": "rgb"}), + json!({"model": "rgb", "values": [1, 2]}), + json!({"model": "rgb", "values": ["a", 2, 3]}), + json!({"model": "xyz", "values": [1, 2, 3]}), + json!({"model": "gray", "values": []}), + ] { + assert!(SwatchColor::from_param(&bad).is_err(), "{bad}"); + } + } + + #[test] + fn serde_round_trip() { + let sw = vec![ + Swatch { name: "a".into(), color: SwatchColor::Lab([50.0, -1.5, 2.25]), kind: SwatchKind::Spot }, + Swatch::new("b", SwatchColor::Book { space: 3, values: [1, 2, 3, 4] }), + Swatch::new("c", SwatchColor::Gray(0.5)), + ]; + let v = serde_json::to_value(&sw).unwrap(); + assert_eq!(v[2], json!({"name": "c", "color": {"model": "gray", "values": 0.5}})); + let back: Vec = serde_json::from_value(v).unwrap(); + assert_eq!(back, sw); + } +} diff --git a/crates/engine/src/swatch_cmds.rs b/crates/engine/src/swatch_cmds.rs new file mode 100644 index 00000000..72b0c783 --- /dev/null +++ b/crates/engine/src/swatch_cmds.rs @@ -0,0 +1,802 @@ +//! Swatches panel commands (`swatches.*`): add, delete, rename, reorder and group swatches, set +//! the foreground or background colour from one, restore the defaults, and import or export +//! Photoshop `.aco` and Adobe Swatch Exchange `.ase` files. +//! +//! The library is [`PresetState::swatches`](crate::presets::PresetState) (model and colour +//! conversions in [`crate::presets::swatches`]); file parsing is `photocraft_psd::{aco, ase}`. +//! Swatches are addressed by name (names are kept unique across the library, as for the other +//! presets), optionally narrowed by `group`, or by `index` (position across all groups, in +//! panel order). Like the other preset commands they change the preferences document, not a +//! document's history. + +use photocraft_psd::aco::{AcoColor, AcoSwatch}; +use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::presets::swatches::{Swatch, SwatchColor, SwatchConv, SwatchKind, builtin, hex, hsb_to_rgb}; +use crate::presets::{Group, bad, find, str_param, unique_name}; +use crate::{Result, Session}; + +/// Most swatches the library holds (imports beyond it are refused). +pub const MAX_SWATCHES: usize = 100_000; +/// Most groups the library holds. +pub const MAX_GROUPS: usize = 10_000; +/// Longest swatch or group name kept (characters). +pub const MAX_NAME: usize = 255; + +/// Trim a hand-edited or imported library to the limits (never refuse to start over it). +pub fn cap_library(groups: &mut Vec>) { + groups.truncate(MAX_GROUPS); + let mut left = MAX_SWATCHES; + for g in groups.iter_mut() { + g.items.truncate(left); + left -= g.items.len(); + g.name = clip(&g.name); + for s in &mut g.items { + s.name = clip(&s.name); + } + } +} + +fn clip(s: &str) -> String { + s.chars().take(MAX_NAME).collect() +} + +fn total(groups: &[Group]) -> usize { + groups.iter().map(|g| g.items.len()).sum() +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +fn has_swatches(s: &Session) -> std::result::Result<(), String> { + if total(&s.presets.swatches) > 0 { Ok(()) } else { Err("the Swatches panel is empty".into()) } +} + +fn has_groups(s: &Session) -> std::result::Result<(), String> { + if s.presets.swatches.is_empty() { Err("there are no swatch groups".into()) } else { Ok(()) } +} + +fn name_param(p: &Value, k: &str, cmd: &str) -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(clip(s.trim()))), + Some(_) => Err(bad(cmd, format!("`{k}` must be a non-empty string"))), + } +} + +/// The swatch a command names: `"swatch"` (name, narrowed by `"group"`) or `"index"`. +fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let groups = &s.presets.swatches; + if let Some(name) = str_param(p, "swatch") { + return find(groups, name, str_param(p, "group")).ok_or_else(|| bad(cmd, format!("no swatch \"{name}\" (see swatches.list)"))); + } + if let Some(i) = p.get("index") { + let i = i.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?; + let mut left = usize::try_from(i).unwrap_or(usize::MAX); + for (gi, g) in groups.iter().enumerate() { + if left < g.items.len() { + return Ok((gi, left)); + } + left -= g.items.len(); + } + return Err(bad(cmd, format!("no swatch at index {i} (the panel has {})", total(groups)))); + } + Err(bad(cmd, "give `swatch` (a name) or `index`")) +} + +/// Name → position of the first swatch with that name (optionally only in `group`), so commands +/// taking lists of names stay linear on big libraries. +fn index_by_name<'a>(groups: &'a [Group], group: Option<&str>) -> std::collections::HashMap<&'a str, (usize, usize)> { + let mut m = std::collections::HashMap::new(); + for (gi, g) in groups.iter().enumerate().filter(|(_, g)| group.is_none_or(|n| g.name == n)) { + for (ii, sw) in g.items.iter().enumerate() { + m.entry(sw.name.as_str()).or_insert((gi, ii)); + } + } + m +} + +fn group_of(s: &Session, name: &str, cmd: &str) -> Result { + s.presets.swatches.iter().position(|g| g.name == name).ok_or_else(|| bad(cmd, format!("no swatch group \"{name}\""))) +} + +fn unique_group(groups: &[Group], base: &str) -> String { + crate::presets::gradients::unique_group(groups, base) +} + +fn swatch_json(conv: &SwatchConv, sw: &Swatch) -> Value { + let mut v = sw.color.to_json(); + v["name"] = json!(sw.name); + v["hex"] = json!(hex(conv.rgb(&sw.color))); + if sw.kind != SwatchKind::Process { + v["kind"] = json!(sw.kind.id()); + } + v +} + +// ------------------------------------------------------------------ commands + +fn list(s: &mut Session, _: &Value) -> Result { + let conv = SwatchConv::new(s); + let groups: Vec = + s.presets.swatches.iter().map(|g| json!({"name": g.name, "swatches": g.items.iter().map(|sw| swatch_json(&conv, sw)).collect::>()})).collect(); + Ok(json!({"groups": groups, "count": total(&s.presets.swatches)})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.add"; + let color = match p.get("color") { + None | Some(Value::Null) => { + let f = s.tools.foreground; + SwatchColor::Rgb([f[0], f[1], f[2]].map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) + } + Some(v) => SwatchColor::from_param(v).map_err(|e| bad(CMD, e))?, + }; + let kind = match str_param(p, "kind") { + None => SwatchKind::Process, + Some(k) => SwatchKind::parse(k).ok_or_else(|| bad(CMD, format!("unknown kind `{k}` (process|global|spot)")))?, + }; + if total(&s.presets.swatches) >= MAX_SWATCHES { + return Err(bad(CMD, format!("the Swatches panel is full ({MAX_SWATCHES} swatches)"))); + } + let group = name_param(p, "group", CMD)?; + let explicit = name_param(p, "name", CMD)?; + let groups = &mut s.presets.swatches; + let name = match explicit { + Some(n) => unique_name(groups, &n), + // Photoshop's default: "Color Swatch 1", "Color Swatch 2"… + None => { + let taken = |n: &str| groups.iter().any(|g| g.items.iter().any(|i| i.name == n)); + (1..).map(|i| format!("Color Swatch {i}")).find(|n| !taken(n)).unwrap_or_default() + } + }; + let gi = match group { + Some(g) => match groups.iter().position(|x| x.name == g) { + Some(i) => i, + None => { + if groups.len() >= MAX_GROUPS { + return Err(bad(CMD, "too many swatch groups")); + } + groups.push(Group::new(&g, Vec::new())); + groups.len() - 1 + } + }, + None => { + if groups.is_empty() { + groups.push(Group::new("Swatches", Vec::new())); + } + 0 + } + }; + let items = &mut groups[gi].items; + let at = match p.get("index") { + None | Some(Value::Null) => items.len(), + Some(v) => usize::try_from(v.as_u64().ok_or_else(|| bad(CMD, "`index` must be a non-negative integer"))?).unwrap_or(usize::MAX).min(items.len()), + }; + items.insert(at, Swatch { name: name.clone(), color, kind }); + let gname = groups[gi].name.clone(); + s.presets_changed(); + Ok(json!({"name": name, "group": gname, "index": at})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.delete"; + let mut targets: Vec<(usize, usize)> = match p.get("swatch") { + Some(Value::Array(a)) => { + let ix = index_by_name(&s.presets.swatches, str_param(p, "group")); + let mut v = Vec::with_capacity(a.len().min(MAX_SWATCHES)); + for n in a { + let name = n.as_str().ok_or_else(|| bad(CMD, "`swatch` names must be strings"))?; + v.push(*ix.get(name).ok_or_else(|| bad(CMD, format!("no swatch \"{name}\" (see swatches.list)")))?); + } + v + } + _ => vec![locate(s, p, CMD)?], + }; + // Remove from the back so earlier positions stay valid; duplicates count once. + targets.sort_unstable(); + targets.dedup(); + let groups = &mut s.presets.swatches; + let mut names = Vec::with_capacity(targets.len()); + for (gi, ii) in targets.into_iter().rev() { + if let Some(g) = groups.get_mut(gi) + && ii < g.items.len() + { + names.push(g.items.remove(ii).name); + } + } + names.reverse(); + s.presets_changed(); + Ok(json!({"deleted": names})) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.rename"; + let (gi, ii) = locate(s, p, CMD)?; + let to = name_param(p, "name", CMD)?.ok_or_else(|| bad(CMD, "missing `name`"))?; + let groups = &mut s.presets.swatches; + if groups[gi].items[ii].name != to && find(groups, &to, None).is_some() { + return Err(bad(CMD, format!("a swatch named \"{to}\" already exists"))); + } + groups[gi].items[ii].name = to.clone(); + s.presets_changed(); + Ok(json!({"name": to})) +} + +fn move_swatch(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.move"; + let (gi, ii) = locate(s, p, CMD)?; + let ti = match name_param(p, "to", CMD)? { + Some(t) => group_of(s, &t, CMD)?, + None => gi, + }; + let at = match p.get("position") { + None | Some(Value::Null) => None, + Some(v) => Some(usize::try_from(v.as_u64().ok_or_else(|| bad(CMD, "`position` must be a non-negative integer"))?).unwrap_or(usize::MAX)), + }; + let groups = &mut s.presets.swatches; + let item = groups[gi].items.remove(ii); + let items = &mut groups[ti].items; + let at = at.unwrap_or(items.len()).min(items.len()); + items.insert(at, item); + let gname = groups[ti].name.clone(); + s.presets_changed(); + Ok(json!({"group": gname, "position": at})) +} + +fn new_group(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.newGroup"; + if s.presets.swatches.len() >= MAX_GROUPS { + return Err(bad(CMD, "too many swatch groups")); + } + let base = name_param(p, "name", CMD)?.unwrap_or_else(|| "Group".into()); + // Swatches to move into the new group (Photoshop: New Group from the selected swatches). + let mut moving: Vec<(usize, usize)> = Vec::new(); + if let Some(a) = p.get("swatches") { + let a = a.as_array().ok_or_else(|| bad(CMD, "`swatches` must be a list of names"))?; + let ix = index_by_name(&s.presets.swatches, None); + for n in a { + let name = n.as_str().ok_or_else(|| bad(CMD, "`swatches` names must be strings"))?; + moving.push(*ix.get(name).ok_or_else(|| bad(CMD, format!("no swatch \"{name}\"")))?); + } + } + moving.sort_unstable(); + moving.dedup(); + let groups = &mut s.presets.swatches; + let name = unique_group(groups, &base); + let mut items: Vec = Vec::with_capacity(moving.len()); + for (gi, ii) in moving.into_iter().rev() { + items.push(groups[gi].items.remove(ii)); + } + items.reverse(); + let n = items.len(); + groups.push(Group::new(&name, items)); + s.presets_changed(); + Ok(json!({"group": name, "moved": n})) +} + +fn rename_group(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.renameGroup"; + let g = name_param(p, "group", CMD)?.ok_or_else(|| bad(CMD, "missing `group`"))?; + let to = name_param(p, "name", CMD)?.ok_or_else(|| bad(CMD, "missing `name`"))?; + let i = group_of(s, &g, CMD)?; + if g != to && s.presets.swatches.iter().any(|x| x.name == to) { + return Err(bad(CMD, format!("a group named \"{to}\" already exists"))); + } + s.presets.swatches[i].name = to.clone(); + s.presets_changed(); + Ok(json!({"group": to})) +} + +fn delete_group(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.deleteGroup"; + let g = name_param(p, "group", CMD)?.ok_or_else(|| bad(CMD, "missing `group`"))?; + let keep = match p.get("keepSwatches") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(CMD, "`keepSwatches` must be true or false"))?, + }; + let i = group_of(s, &g, CMD)?; + let groups = &mut s.presets.swatches; + if keep && groups.len() < 2 { + return Err(bad(CMD, "there is no other group to keep the swatches in")); + } + let removed = groups.remove(i); + let n = removed.items.len(); + if keep { + // Into the group above (or the new first group), like Photoshop's "Delete Group Only". + let into = i.saturating_sub(1).min(groups.len().saturating_sub(1)); + if let Some(t) = groups.get_mut(into) { + t.items.extend(removed.items); + } + } + s.presets_changed(); + Ok(json!({"group": g, if keep { "kept" } else { "deleted" }: n})) +} + +fn use_swatch(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.use"; + let (gi, ii) = locate(s, p, CMD)?; + let target = str_param(p, "target").unwrap_or("foreground"); + let sw = s.presets.swatches[gi].items[ii].clone(); + let rgb = SwatchConv::new(s).rgb(&sw.color); + let c = [rgb[0], rgb[1], rgb[2], 1.0]; + match target { + "foreground" => s.tools.foreground = c, + "background" => s.tools.background = c, + t => return Err(bad(CMD, format!("unknown target `{t}` (foreground|background)"))), + } + Ok(json!({"swatch": sw.name, "target": target, "hex": hex(rgb)})) +} + +fn reset(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.reset"; + let append = match p.get("append") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(CMD, "`append` must be true or false"))?, + }; + let mut b = builtin(); + if append { + if total(&s.presets.swatches) + total(&b) > MAX_SWATCHES || s.presets.swatches.len() + b.len() > MAX_GROUPS { + return Err(bad(CMD, "the Swatches panel is full")); + } + for g in &mut b { + g.name = unique_group(&s.presets.swatches, &g.name); + for sw in &mut g.items { + sw.name = unique_name(&s.presets.swatches, &sw.name); + } + s.presets.swatches.push(g.clone()); + } + } else { + s.presets.swatches = b; + } + s.presets_changed(); + Ok(json!({"groups": s.presets.swatches.len(), "count": total(&s.presets.swatches)})) +} + +// ------------------------------------------------------------------ files + +/// File format of a swatch file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Aco, + Ase, +} + +impl Format { + fn parse(s: &str) -> Option { + match s.trim_start_matches('.').to_ascii_lowercase().as_str() { + "aco" => Some(Format::Aco), + "ase" => Some(Format::Ase), + _ => None, + } + } + fn id(self) -> &'static str { + match self { + Format::Aco => "aco", + Format::Ase => "ase", + } + } +} + +fn ext_format(path: Option<&str>) -> Option { + Format::parse(std::path::Path::new(path?).extension()?.to_str()?) +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +fn q16(v: f32) -> u16 { + (unit(v) * 65535.0).round() as u16 +} + +fn from_aco(c: AcoColor) -> SwatchColor { + let f = |v: u16| f32::from(v) / 65535.0; + match c { + AcoColor::Rgb(v) => SwatchColor::Rgb(v.map(f)), + AcoColor::Hsb([h, sa, b]) => SwatchColor::Hsb([f(h) * 360.0, f(sa), f(b)]), + AcoColor::Cmyk(v) => SwatchColor::Cmyk(v.map(f)), + AcoColor::Lab { l, a, b } => SwatchColor::Lab([f32::from(l.min(10000)) / 100.0, f32::from(a) / 100.0, f32::from(b) / 100.0]), + AcoColor::Gray(g) => SwatchColor::Gray(1.0 - f32::from(g.min(10000)) / 10000.0), + AcoColor::Other { space, values } => SwatchColor::Book { space, values }, + } +} + +fn to_aco(c: &SwatchColor) -> AcoColor { + match *c { + SwatchColor::Rgb(v) => AcoColor::Rgb(v.map(q16)), + SwatchColor::Hsb([h, sa, b]) => { + let h = if h.is_finite() { h.rem_euclid(360.0) } else { 0.0 }; + AcoColor::Hsb([((h / 360.0) * 65535.0).round().min(65535.0) as u16, q16(sa), q16(b)]) + } + SwatchColor::Cmyk(v) => AcoColor::Cmyk(v.map(q16)), + SwatchColor::Lab([l, a, b]) => { + let ab = |v: f32| if v.is_finite() { (v * 100.0).round().clamp(-12800.0, 12700.0) as i16 } else { 0 }; + AcoColor::Lab { l: (if l.is_finite() { l } else { 0.0 } * 100.0).round().clamp(0.0, 10000.0) as u16, a: ab(a), b: ab(b) } + } + SwatchColor::Gray(k) => AcoColor::Gray(((1.0 - unit(k)) * 10000.0).round() as u16), + SwatchColor::Book { space, values } => AcoColor::Other { space, values }, + } +} + +fn from_ase(c: &AseColor) -> Option { + Some(match *c { + AseColor::Rgb(v) => SwatchColor::Rgb(v), + AseColor::Cmyk(v) => SwatchColor::Cmyk(v), + // L is stored as a fraction (L*/100); a few writers store L* itself, which can only be + // told apart above 1. + AseColor::Lab([l, a, b]) => SwatchColor::Lab([if l > 1.0 { l } else { l * 100.0 }, a, b]), + AseColor::Gray(g) => SwatchColor::Gray(1.0 - g), + AseColor::Other { .. } => return None, + }) +} + +fn to_ase(c: &SwatchColor) -> Option { + Some(match *c { + SwatchColor::Rgb(v) => AseColor::Rgb(v), + // ASE has no HSB; HSB is a view of RGB, so this is exact up to float rounding. + SwatchColor::Hsb(v) => AseColor::Rgb(hsb_to_rgb(v)), + SwatchColor::Cmyk(v) => AseColor::Cmyk(v), + SwatchColor::Lab([l, a, b]) => AseColor::Lab([l / 100.0, a, b]), + SwatchColor::Gray(k) => AseColor::Gray(1.0 - k), + SwatchColor::Book { .. } => return None, + }) +} + +fn kind_from_ase(k: AseKind) -> SwatchKind { + match k { + AseKind::Global => SwatchKind::Global, + AseKind::Spot => SwatchKind::Spot, + AseKind::Process => SwatchKind::Process, + } +} + +fn kind_to_ase(k: SwatchKind) -> AseKind { + match k { + SwatchKind::Global => AseKind::Global, + SwatchKind::Spot => AseKind::Spot, + SwatchKind::Process => AseKind::Process, + } +} + +/// Parse a swatch file into groups (`group` names the group for ungrouped colours). +pub fn read_file(bytes: &[u8], format: Format, group: &str) -> std::result::Result<(Vec>, Vec), String> { + let mut warnings = Vec::new(); + let mut out: Vec> = Vec::new(); + let fallback = |i: usize, name: &str| if name.trim().is_empty() { format!("Swatch {}", i + 1) } else { clip(name.trim()) }; + match format { + Format::Aco => { + let f = photocraft_psd::aco::parse(bytes).map_err(|e| format!("not a readable .aco swatch file: {e}"))?; + let books = f.swatches.iter().filter(|s| matches!(s.color, AcoColor::Other { .. })).count(); + if books > 0 { + warnings.push(format!( + "{books} swatches use a colour book or colour space PhotoCraft can't show; they are kept (shown gray) and saved back unchanged" + )); + } + let items = f + .swatches + .iter() + .enumerate() + .map(|(i, s)| Swatch { name: fallback(i, &s.name), color: from_aco(s.color), kind: SwatchKind::Process }) + .collect(); + out.push(Group::new(group, items)); + } + Format::Ase => { + let f = photocraft_psd::ase::parse(bytes).map_err(|e| format!("not a readable .ase swatch file: {e}"))?; + let mut skipped = 0; + let mut n = 0usize; + let mut conv = |s: &AseSwatch| -> Option { + n += 1; + match from_ase(&s.color) { + Some(color) => Some(Swatch { name: fallback(n - 1, &s.name), color, kind: kind_from_ase(s.kind) }), + None => { + skipped += 1; + None + } + } + }; + let mut loose = Vec::new(); + for e in &f.entries { + match e { + AseEntry::Swatch(s) => loose.extend(conv(s)), + AseEntry::Group { name, swatches } => { + let items: Vec = swatches.iter().filter_map(&mut conv).collect(); + let name = if name.trim().is_empty() { group.to_string() } else { clip(name.trim()) }; + out.push(Group::new(&name, items)); + } + } + } + if !loose.is_empty() || out.is_empty() { + out.insert(0, Group::new(group, loose)); + } + if skipped > 0 { + warnings.push(format!("{skipped} swatches in an unknown colour model were skipped")); + } + } + } + if total(&out) == 0 { + return Err("the file holds no swatches".into()); + } + Ok((out, warnings)) +} + +/// Encode groups as a swatch file; returns the bytes, the number of swatches written and +/// warnings about what the format cannot hold. +pub fn write_file(groups: &[Group], format: Format) -> std::result::Result<(Vec, usize, Vec), String> { + let mut warnings = Vec::new(); + match format { + Format::Aco => { + let swatches: Vec = + groups.iter().flat_map(|g| g.items.iter()).map(|s| AcoSwatch { name: s.name.clone(), color: to_aco(&s.color) }).collect(); + if groups.len() > 1 { + warnings.push(".aco files have no groups; the swatches were saved as one list".into()); + } + if groups.iter().flat_map(|g| g.items.iter()).any(|s| s.kind != SwatchKind::Process) { + warnings.push(".aco files don't mark spot or global colours; save as .ase to keep them".into()); + } + let n = swatches.len(); + let bytes = photocraft_psd::aco::write(&swatches).map_err(|e| e.to_string())?; + Ok((bytes, n, warnings)) + } + Format::Ase => { + let mut books = 0; + let mut entries = Vec::with_capacity(groups.len()); + let mut n = 0; + for g in groups { + let swatches: Vec = g + .items + .iter() + .filter_map(|s| match to_ase(&s.color) { + Some(color) => Some(AseSwatch { name: s.name.clone(), color, kind: kind_to_ase(s.kind) }), + None => { + books += 1; + None + } + }) + .collect(); + n += swatches.len(); + entries.push(AseEntry::Group { name: g.name.clone(), swatches }); + } + if books > 0 { + warnings.push(format!("{books} colour-book swatches can't be stored in an .ase file and were left out (save as .aco to keep them)")); + } + let bytes = photocraft_psd::ase::write(&AseFile { entries }).map_err(|e| e.to_string())?; + Ok((bytes, n, warnings)) + } + } +} + +fn import(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.import"; + let (bytes, stem) = crate::preset_import_cmds::file_bytes(p, CMD, ".aco or .ase file")?; + let format = match str_param(p, "format") { + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + None if bytes.starts_with(photocraft_psd::ase::SIGNATURE) => Format::Ase, + None if matches!(bytes.get(..2), Some([0, 1] | [0, 2])) => Format::Aco, + None => ext_format(str_param(p, "path")).ok_or_else(|| bad(CMD, "not an .aco or .ase swatch file"))?, + }; + let mode = str_param(p, "mode").unwrap_or("append"); + let replace = match mode { + "append" => false, + "replace" => true, + m => return Err(bad(CMD, format!("unknown mode `{m}` (append|replace)"))), + }; + let group = match name_param(p, "group", CMD)? { + Some(g) => g, + None if !stem.trim().is_empty() => clip(stem.trim()), + None => "Imported Swatches".into(), + }; + let (groups, warnings) = read_file(&bytes, format, &group).map_err(|e| bad(CMD, e))?; + let base: Vec> = if replace { Vec::new() } else { s.presets.swatches.clone() }; + if total(&base) + total(&groups) > MAX_SWATCHES || base.len() + groups.len() > MAX_GROUPS { + return Err(bad(CMD, format!("too many swatches: the panel holds at most {MAX_SWATCHES} in {MAX_GROUPS} groups"))); + } + let mut lib = base; + let count = total(&groups); + let mut names = Vec::with_capacity(groups.len()); + // Names stay unique across the library (agents address swatches by name). + let mut next: std::collections::HashMap = std::collections::HashMap::new(); + let mut taken: std::collections::HashSet = lib.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect(); + for mut g in groups { + g.name = unique_group(&lib, &g.name); + for sw in &mut g.items { + if taken.contains(&sw.name) { + // Resume numbering per base name, so a file of identical names stays linear. + let from = next.get(&sw.name).copied().unwrap_or(2); + let (i, n) = (from..).map(|i| (i, format!("{} {i}", sw.name))).find(|(_, n)| !taken.contains(n)).unwrap_or((from, sw.name.clone())); + next.insert(sw.name.clone(), i + 1); + sw.name = n; + } + taken.insert(sw.name.clone()); + } + names.push(g.name.clone()); + lib.push(g); + } + s.presets.swatches = lib; + s.presets_changed(); + Ok(json!({"format": format.id(), "mode": mode, "groups": names, "count": count, "warnings": warnings})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "swatches.export"; + let path = str_param(p, "path"); + let format = match str_param(p, "format") { + Some(f) => Format::parse(f).ok_or_else(|| bad(CMD, format!("unknown format `{f}` (aco|ase)")))?, + None => ext_format(path).unwrap_or(Format::Aco), + }; + let mut groups: Vec> = match name_param(p, "group", CMD)? { + Some(g) => vec![s.presets.swatches[group_of(s, &g, CMD)?].clone()], + None => s.presets.swatches.clone(), + }; + // Only some swatches (Photoshop: Export Selected Swatches). + if let Some(a) = p.get("swatches") { + let a = a.as_array().ok_or_else(|| bad(CMD, "`swatches` must be a list of names"))?; + let mut want = std::collections::HashSet::with_capacity(a.len().min(MAX_SWATCHES)); + { + let ix = index_by_name(&groups, None); + for n in a { + let name = n.as_str().ok_or_else(|| bad(CMD, "`swatches` names must be strings"))?; + if !ix.contains_key(name) { + return Err(bad(CMD, format!("no swatch \"{name}\""))); + } + want.insert(name.to_string()); + } + } + for g in &mut groups { + g.items.retain(|sw| want.contains(&sw.name)); + } + groups.retain(|g| !g.items.is_empty()); + } + if total(&groups) == 0 { + return Err(bad(CMD, "no swatches to export")); + } + let (bytes, count, warnings) = write_file(&groups, format).map_err(|e| bad(CMD, e))?; + let mut out = json!({"format": format.id(), "count": count, "bytes": bytes.len(), "warnings": warnings}); + match path { + Some(path) => { + crate::file_cmds::write_file(path, &bytes)?; + out["path"] = json!(path); + } + None => out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)), + } + Ok(out) +} + +/// Swatches panel command specs. +pub fn specs() -> Vec { + const COLOR: &str = r##""#rrggbb"|{"model":"rgb|hsb|cmyk|lab|gray","values":[rgb 0..255 | hue°,s%,b% | c%,m%,y%,k% | L,a,b | k%]}"##; + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + let target = r##""swatch":name (or "index":n across all groups),"group":name? (narrows the name lookup)"##; + vec![ + CommandSpec { + id: "swatches.list", + label: "Swatches", + menu: &[], + shortcut: None, + params: r##"{} → {groups:[{name,swatches:[{name,model,values,hex,kind?}]}],count}. values are in Color Picker units; hex is the colour in the RGB working space."##, + enabled: always, + run: list, + journal: false, + }, + CommandSpec { + id: "swatches.add", + label: "New Swatch…", + menu: &[], + shortcut: None, + params: leak(format!( + r##"{{"color":{COLOR}?=foreground,"name":str?="Color Swatch N","group":name?=first (created when missing),"index":n?=end,"kind":"process|global|spot"="process"}} → {{name,group,index}}"## + )), + enabled: always, + run: add, + journal: true, + }, + CommandSpec { + id: "swatches.delete", + label: "Delete Swatch", + menu: &[], + shortcut: None, + params: leak(format!(r##"{{{target}; "swatch" may be a list of names}} → {{deleted:[names]}}"##)), + enabled: has_swatches, + run: delete, + journal: true, + }, + CommandSpec { + id: "swatches.rename", + label: "Rename Swatch…", + menu: &[], + shortcut: None, + params: leak(format!(r##"{{{target},"name":str}} → {{name}}"##)), + enabled: has_swatches, + run: rename, + journal: true, + }, + CommandSpec { + id: "swatches.move", + label: "Move Swatch", + menu: &[], + shortcut: None, + params: leak(format!(r##"{{{target},"to":group?=its own group,"position":n?=end}} → {{group,position}}"##)), + enabled: has_swatches, + run: move_swatch, + journal: true, + }, + CommandSpec { + id: "swatches.newGroup", + label: "New Swatch Group…", + menu: &[], + shortcut: None, + params: r##"{"name":str="Group" (made unique),"swatches":[names]? (moved into the new group)} → {group,moved}"##, + enabled: always, + run: new_group, + journal: true, + }, + CommandSpec { + id: "swatches.renameGroup", + label: "Rename Swatch Group…", + menu: &[], + shortcut: None, + params: r##"{"group":name,"name":str} → {group}"##, + enabled: has_groups, + run: rename_group, + journal: true, + }, + CommandSpec { + id: "swatches.deleteGroup", + label: "Delete Swatch Group", + menu: &[], + shortcut: None, + params: r##"{"group":name,"keepSwatches":bool=false (move its swatches into the group above)} → {group,deleted|kept}"##, + enabled: has_groups, + run: delete_group, + journal: true, + }, + CommandSpec { + id: "swatches.use", + label: "Use Swatch", + menu: &[], + shortcut: None, + params: leak(format!( + r##"{{{target},"target":"foreground|background"="foreground"}} → {{swatch,target,hex}}. CMYK, Lab and gray swatches are converted to the RGB working space with colour management."## + )), + enabled: has_swatches, + run: use_swatch, + journal: true, + }, + CommandSpec { + id: "swatches.reset", + label: "Reset Swatches", + menu: &[], + shortcut: None, + params: r##"{"append":bool=false (add the defaults instead of replacing the library)} → {groups,count}"##, + enabled: always, + run: reset, + journal: true, + }, + CommandSpec { + id: "swatches.import", + label: "Import Swatches…", + menu: &[], + shortcut: None, + params: r##"{"path":".aco or .ase file"?,"data":base64 bytes?,"format":"aco|ase"? (detected),"mode":"append|replace"="append","group":str?=file name (the group for ungrouped colours)} → {format,mode,groups,count,warnings}"##, + enabled: always, + run: import, + journal: true, + }, + CommandSpec { + id: "swatches.export", + label: "Export Swatches…", + menu: &[], + shortcut: None, + params: r##"{"format":"aco|ase"?=from path, else aco,"path":file? (else the bytes come back as base64 `data`),"group":name? (only that group),"swatches":[names]? (only these)} → {format,count,bytes,warnings,path|data}"##, + enabled: has_swatches, + run: export, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs new file mode 100644 index 00000000..4a92441b --- /dev/null +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -0,0 +1,291 @@ +use super::*; +use photocraft_paint::tile::{b64_decode, b64_encode}; + +fn names(s: &Session) -> Vec { + s.presets.swatches.iter().flat_map(|g| g.items.iter().map(|i| i.name.clone())).collect() +} + +fn export_bytes(s: &mut Session, p: Value) -> Vec { + let r = s.execute("swatches.export", p).unwrap(); + b64_decode(r["data"].as_str().unwrap()).unwrap() +} + +#[test] +fn defaults_and_listing() { + let mut s = Session::new(); + let r = s.execute("swatches.list", json!({})).unwrap(); + assert_eq!(r["count"], 40); + assert_eq!(r["groups"][0]["name"], "Grays"); + assert_eq!(r["groups"][0]["swatches"][0], json!({"name": "Black", "model": "rgb", "values": [0.0, 0.0, 0.0], "hex": "#000000"})); + assert_eq!(r["groups"][2]["swatches"][0]["hex"], "#e62828"); +} + +#[test] +fn add_from_foreground_and_explicit_models() { + let mut s = Session::new(); + s.tools.foreground = [1.0, 0.5, 0.0, 1.0]; + let r = s.execute("swatches.add", json!({})).unwrap(); + assert_eq!((r["name"].as_str(), r["group"].as_str()), (Some("Color Swatch 1"), Some("Grays"))); + assert_eq!(s.presets.swatches[0].items.last().unwrap().color, SwatchColor::Rgb([1.0, 0.5, 0.0])); + let r = s.execute("swatches.add", json!({})).unwrap(); + assert_eq!(r["name"], "Color Swatch 2"); + // CMYK into a new group at the front, spot. + let r = s + .execute("swatches.add", json!({"name": "Ink", "group": "Print", "index": 0, "kind": "spot", "color": {"model": "cmyk", "values": [100, 0, 0, 0]}})) + .unwrap(); + assert_eq!(r["group"], "Print"); + let g = s.presets.swatches.last().unwrap(); + assert_eq!(g.items[0], Swatch { name: "Ink".into(), color: SwatchColor::Cmyk([1.0, 0.0, 0.0, 0.0]), kind: SwatchKind::Spot }); + // Names stay unique. + let r = s.execute("swatches.add", json!({"name": "Ink"})).unwrap(); + assert_eq!(r["name"], "Ink 2"); +} + +#[test] +fn use_sets_foreground_and_background_through_colour_management() { + let mut s = Session::new(); + s.execute("swatches.use", json!({"swatch": "Red"})).unwrap(); + assert_eq!(s.tools.foreground, [230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0, 1.0]); + s.execute("swatches.use", json!({"index": 9, "target": "background"})).unwrap(); + assert_eq!(s.tools.background, [1.0, 1.0, 1.0, 1.0], "index 9 is White"); + s.execute("swatches.add", json!({"name": "Lab white", "color": {"model": "lab", "values": [100, 0, 0]}})).unwrap(); + let r = s.execute("swatches.use", json!({"swatch": "Lab white"})).unwrap(); + assert_eq!(r["hex"], "#ffffff"); + s.execute("swatches.add", json!({"name": "Paper", "color": {"model": "gray", "values": [0]}})).unwrap(); + s.execute("swatches.use", json!({"swatch": "Paper", "target": "background"})).unwrap(); + assert!(s.tools.background.iter().all(|v| (v - 1.0).abs() < 0.01), "{:?}", s.tools.background); +} + +#[test] +fn rename_delete_move_and_groups() { + let mut s = Session::new(); + s.execute("swatches.rename", json!({"swatch": "Black", "name": "Ink Black"})).unwrap(); + assert!(names(&s).contains(&"Ink Black".to_string())); + assert!(s.execute("swatches.rename", json!({"swatch": "Ink Black", "name": "White"})).is_err(), "duplicate"); + // Reorder within the group, then into another group. + s.execute("swatches.move", json!({"swatch": "White", "position": 0})).unwrap(); + assert_eq!(s.presets.swatches[0].items[0].name, "White"); + let r = s.execute("swatches.move", json!({"swatch": "White", "to": "Dark", "position": 1})).unwrap(); + assert_eq!(r, json!({"group": "Dark", "position": 1})); + assert_eq!(s.presets.swatches[3].items[1].name, "White"); + // Out-of-range positions clamp. + let r = s.execute("swatches.move", json!({"swatch": "White", "position": 999_999})).unwrap(); + assert_eq!(r["position"], 10); + // New group from swatches, rename it, delete it keeping the swatches. + let r = s.execute("swatches.newGroup", json!({"name": "Mine", "swatches": ["Red", "Blue"]})).unwrap(); + assert_eq!(r, json!({"group": "Mine", "moved": 2})); + let r = s.execute("swatches.newGroup", json!({"name": "Mine"})).unwrap(); + assert_eq!(r["group"], "Mine 1"); + s.execute("swatches.renameGroup", json!({"group": "Mine", "name": "Favourites"})).unwrap(); + assert!(s.execute("swatches.renameGroup", json!({"group": "Favourites", "name": "Dark"})).is_err()); + let r = s.execute("swatches.deleteGroup", json!({"group": "Favourites", "keepSwatches": true})).unwrap(); + assert_eq!(r["kept"], 2); + assert!(s.presets.swatches[3].items.iter().any(|i| i.name == "Red"), "kept in the group above"); + s.execute("swatches.deleteGroup", json!({"group": "Mine 1"})).unwrap(); + // Delete several by name, and one by index. + let r = s.execute("swatches.delete", json!({"swatch": ["Red", "Blue", "Red"]})).unwrap(); + assert_eq!(r["deleted"].as_array().unwrap().len(), 2); + let r = s.execute("swatches.delete", json!({"index": 0})).unwrap(); + assert_eq!(r["deleted"], json!(["Ink Black"])); + assert_eq!(names(&s).len(), 37); + // Reset restores the defaults; append adds them again with unique names. + s.execute("swatches.reset", json!({})).unwrap(); + assert_eq!(names(&s).len(), 40); + s.execute("swatches.reset", json!({"append": true})).unwrap(); + assert_eq!(names(&s).len(), 80); + assert!(names(&s).contains(&"Black 2".to_string())); + assert_eq!(s.presets.swatches[4].name, "Grays 1"); +} + +#[test] +fn changes_persist_with_the_preferences() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "Keep me", "color": {"model": "lab", "values": [40, 12.5, -7.25]}})).unwrap(); + let saved = s.presets.to_json(&s); + let mut t = Session::new(); + t.load_presets_json(saved); + assert_eq!(t.presets.swatches, s.presets.swatches); + // A prefs file from before swatches existed keeps the defaults. + let mut u = Session::new(); + u.load_presets_json(json!({"gradients": null})); + assert_eq!(u.presets.swatches, builtin()); +} + +#[test] +fn aco_round_trip_keeps_16_bit_values_and_books() { + let swatches = vec![ + AcoSwatch { name: "R".into(), color: AcoColor::Rgb([65535, 12345, 1]) }, + AcoSwatch { name: "H".into(), color: AcoColor::Hsb([40000, 30000, 65535]) }, + AcoSwatch { name: "C".into(), color: AcoColor::Cmyk([65535, 1, 32768, 7]) }, + AcoSwatch { name: "L".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } }, + AcoSwatch { name: "G".into(), color: AcoColor::Gray(1234) }, + AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [9, 8, 7, 6] } }, + ]; + let file = photocraft_psd::aco::write(&swatches).unwrap(); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&file), "group": "Pro", "mode": "replace"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("aco"), Some(6))); + assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the colour book is reported"); + assert_eq!(s.presets.swatches.len(), 1); + assert_eq!(s.presets.swatches[0].items[3].color, SwatchColor::Lab([53.21, -128.0, 127.0])); + assert_eq!(s.presets.swatches[0].items[4].color.model(), "gray"); + assert_eq!(export_bytes(&mut s, json!({"format": "aco"})), file, "byte-identical .aco"); + // On disk too, with the group taken from the file name. + let dir = std::env::temp_dir().join(format!("pc-swatch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Brand Colors.aco"); + std::fs::write(&path, &file).unwrap(); + let r = s.execute("swatches.import", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["groups"], json!(["Brand Colors"])); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("R")).count(), 2, "imported names are made unique"); + let out = dir.join("out.ase"); + let r = s.execute("swatches.export", json!({"path": out.to_string_lossy(), "group": "Pro"})).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("ase"), Some(5)), "the book can't go to .ase"); + assert!(std::fs::read(&out).unwrap().starts_with(b"ASEF")); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn ase_round_trip_keeps_groups_floats_and_kinds() { + let src = AseFile { + entries: vec![ + AseEntry::Swatch(AseSwatch { name: "Loose".into(), color: AseColor::Rgb([0.1, 0.2, 0.3]), kind: AseKind::Process }), + AseEntry::Group { + name: "Brand".into(), + swatches: vec![ + AseSwatch { name: "Spot ink".into(), color: AseColor::Cmyk([0.0, 0.85, 1.0, 0.05]), kind: AseKind::Spot }, + AseSwatch { name: "Global lab".into(), color: AseColor::Lab([0.5, 20.0, -30.0]), kind: AseKind::Global }, + AseSwatch { name: "Gray".into(), color: AseColor::Gray(0.25), kind: AseKind::Process }, + AseSwatch { name: "Weird".into(), color: AseColor::Other { model: *b"XYZ ", data: vec![0; 12] }, kind: AseKind::Process }, + ], + }, + ], + }; + let bytes = photocraft_psd::ase::write(&src).unwrap(); + let mut s = Session::new(); + let r = s.execute("swatches.import", json!({"data": b64_encode(&bytes), "group": "Exchange"})).unwrap(); + assert_eq!(r["groups"], json!(["Exchange", "Brand"])); + assert_eq!(r["count"], 4); + assert_eq!(r["warnings"].as_array().unwrap().len(), 1, "the unknown model is reported"); + let brand = s.presets.swatches.iter().find(|g| g.name == "Brand").unwrap(); + assert_eq!(brand.items[0].kind, SwatchKind::Spot); + assert_eq!(brand.items[1].color, SwatchColor::Lab([50.0, 20.0, -30.0])); + assert_eq!(brand.items[2].color, SwatchColor::Gray(0.75)); + // Export both groups and read them back. + let out = export_bytes(&mut s, json!({"format": "ase", "swatches": ["Loose", "Spot ink", "Global lab", "Gray"]})); + let back = photocraft_psd::ase::parse(&out).unwrap(); + let flat: Vec<&AseSwatch> = back + .entries + .iter() + .flat_map(|e| match e { + AseEntry::Group { swatches, .. } => swatches.iter().collect::>(), + AseEntry::Swatch(s) => vec![s], + }) + .collect(); + assert_eq!(flat.len(), 4); + assert_eq!(flat[0].color, AseColor::Rgb([0.1, 0.2, 0.3])); + assert_eq!(flat[1].color, AseColor::Cmyk([0.0, 0.85, 1.0, 0.05])); + assert_eq!(flat[1].kind, AseKind::Spot); + assert_eq!(flat[2].kind, AseKind::Global); + let (AseColor::Lab(l), AseColor::Gray(g)) = (&flat[2].color, &flat[3].color) else { panic!("models") }; + assert!((l[0] - 0.5).abs() < 1e-6 && l[1] == 20.0 && l[2] == -30.0); + assert!((g - 0.25).abs() < 1e-6); + // HSB swatches become RGB in .ase. + s.execute("swatches.add", json!({"name": "Hue", "color": {"model": "hsb", "values": [120, 100, 100]}})).unwrap(); + let out = export_bytes(&mut s, json!({"format": "ase", "swatches": ["Hue"]})); + let back = photocraft_psd::ase::parse(&out).unwrap(); + assert_eq!( + back.entries, + vec![AseEntry::Group { + name: "Grays".into(), + swatches: vec![AseSwatch { name: "Hue".into(), color: AseColor::Rgb([0.0, 1.0, 0.0]), kind: AseKind::Process }] + }] + ); +} + +#[test] +fn bad_params_fail_gracefully() { + let mut s = Session::new(); + let before = s.presets.swatches.clone(); + let cases: &[(&str, Value)] = &[ + ("swatches.add", json!({"color": "#12"})), + ("swatches.add", json!({"color": {"model": "lab"}})), + ("swatches.add", json!({"color": 5})), + ("swatches.add", json!({"name": 7})), + ("swatches.add", json!({"name": " "})), + ("swatches.add", json!({"group": []})), + ("swatches.add", json!({"index": -1})), + ("swatches.add", json!({"kind": "metallic"})), + ("swatches.delete", json!({})), + ("swatches.delete", json!({"swatch": "Nope"})), + ("swatches.delete", json!({"swatch": ["Red", 5]})), + ("swatches.delete", json!({"swatch": ["Red", "Nope"]})), + ("swatches.delete", json!({"index": 40})), + ("swatches.delete", json!({"index": "0"})), + ("swatches.delete", json!({"swatch": "Red", "group": "Grays"})), + ("swatches.rename", json!({"swatch": "Red"})), + ("swatches.rename", json!({"swatch": "Red", "name": ""})), + ("swatches.rename", json!({"name": "x"})), + ("swatches.move", json!({"swatch": "Red", "to": "Nowhere"})), + ("swatches.move", json!({"swatch": "Red", "position": -3})), + ("swatches.move", json!({"index": 999_999})), + ("swatches.newGroup", json!({"swatches": "Red"})), + ("swatches.newGroup", json!({"swatches": ["Nope"]})), + ("swatches.newGroup", json!({"name": 3})), + ("swatches.renameGroup", json!({"group": "Nope", "name": "x"})), + ("swatches.renameGroup", json!({"group": "Grays"})), + ("swatches.deleteGroup", json!({})), + ("swatches.deleteGroup", json!({"group": "Grays", "keepSwatches": "yes"})), + ("swatches.use", json!({"swatch": "Red", "target": "middle"})), + ("swatches.use", json!({})), + ("swatches.reset", json!({"append": 1})), + ("swatches.import", json!({})), + ("swatches.import", json!({"path": "/nonexistent/x.aco"})), + ("swatches.import", json!({"data": "%%%"})), + ("swatches.import", json!({"data": b64_encode(b"junk junk")})), + ("swatches.import", json!({"data": b64_encode(b"ASEF\0\x01\0\0\xff\xff\xff\xff")})), + ("swatches.import", json!({"data": b64_encode(&[0, 1, 0xff, 0xff])})), + ("swatches.import", json!({"data": b64_encode(&[0, 1, 0, 0])})), + ("swatches.import", json!({"data": b64_encode(&[0, 1, 0, 0]), "format": "gpl"})), + ("swatches.import", json!({"data": b64_encode(&[0, 1, 0, 0]), "mode": "merge"})), + ("swatches.export", json!({"format": "pdf"})), + ("swatches.export", json!({"group": "Nope"})), + ("swatches.export", json!({"swatches": ["Nope"]})), + ("swatches.export", json!({"swatches": "Red"})), + ]; + for (id, p) in cases { + assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); + } + // An unwritable path (its "folder" is a file) is an error. + let file = std::env::temp_dir().join(format!("pc-swatch-file-{}", std::process::id())); + std::fs::write(&file, b"x").unwrap(); + assert!(s.execute("swatches.export", json!({"path": file.join("x.aco").to_string_lossy()})).is_err()); + std::fs::remove_file(&file).ok(); + assert_eq!(s.presets.swatches, before, "failed commands change nothing"); + // Empty library: the swatch commands are disabled, not crashing. + s.execute("swatches.delete", json!({"swatch": names(&s)})).unwrap(); + for g in ["Grays", "Pastel", "Pure", "Dark"] { + s.execute("swatches.deleteGroup", json!({"group": g})).unwrap(); + } + for id in ["swatches.delete", "swatches.rename", "swatches.move", "swatches.use", "swatches.export", "swatches.renameGroup", "swatches.deleteGroup"] { + assert!(s.execute(id, json!({"swatch": "Red", "group": "Grays", "name": "x"})).is_err(), "{id}"); + } + // Adding to an empty library creates a group. + let r = s.execute("swatches.add", json!({})).unwrap(); + assert_eq!(r["group"], "Swatches"); +} + +#[test] +fn library_limits() { + let mut s = Session::new(); + let one = vec![AcoSwatch { name: "x".into(), color: AcoColor::Gray(0) }; 60_000]; + let file = b64_encode(&photocraft_psd::aco::write(&one).unwrap()); + s.execute("swatches.import", json!({"data": file, "mode": "replace"})).unwrap(); + assert!(s.execute("swatches.import", json!({"data": file})).is_err(), "over the library limit"); + assert_eq!(names(&s).len(), 60_000); + // A hand-edited preferences file is trimmed, not trusted. + let mut g = vec![Group::new(&"n".repeat(1000), vec![Swatch::new("a", SwatchColor::Gray(0.0)); MAX_SWATCHES + 5])]; + cap_library(&mut g); + assert_eq!(total(&g), MAX_SWATCHES); + assert_eq!(g[0].name.chars().count(), MAX_NAME); +} diff --git a/crates/psd/src/aco.rs b/crates/psd/src/aco.rs new file mode 100644 index 00000000..6f32d6b0 --- /dev/null +++ b/crates/psd/src/aco.rs @@ -0,0 +1,299 @@ +//! Photoshop Color Swatches files (`.aco`), versions 1 and 2. +//! +//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Swatches): a +//! version 1 section (`u16` version = 1, `u16` count, then `count` colour records of a `u16` +//! colour space id and four `u16` values), usually followed by a version 2 section with the +//! same colours plus a name each (a `u32` UTF-16 code-unit count that includes the trailing +//! null, then the big-endian units). Files that start with the version 2 section are read too. +//! +//! Colour encodings (spec, "Color structure"): RGB and HSB are full 16-bit values; CMYK stores +//! 0 for 100 % ink; Lab stores L* × 100 (0..=10000) and signed a*, b* × 100; grayscale stores a +//! gray level 0..=10000 where 0 is black (public notes on the format convert it with +//! `level / 39.0625` to 0..255). Colours are kept at their 16-bit precision in the space they +//! were stored in; any other colour space (colour books, wide CMYK, …) is kept verbatim so a +//! read → write round trip never loses it. Converting colours is the caller's job. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// Most swatches in one file (the counts are 16-bit). +pub const MAX_SWATCHES: usize = u16::MAX as usize; +/// Longest name read or written, in UTF-16 code units (Photoshop's own limit is far lower). +pub const MAX_NAME_UNITS: usize = 1024; + +/// Colour space ids of the `.aco` colour record. +pub mod space { + /// RGB. + pub const RGB: u16 = 0; + /// HSB. + pub const HSB: u16 = 1; + /// CMYK. + pub const CMYK: u16 = 2; + /// CIE L*a*b*. + pub const LAB: u16 = 7; + /// Grayscale. + pub const GRAY: u16 = 8; +} + +/// A swatch colour as stored, at full 16-bit precision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AcoColor { + /// Red, green, blue, 0..=65535. + Rgb([u16; 3]), + /// Hue (0..=65535 for 0..360°), saturation and brightness (0..=65535). + Hsb([u16; 3]), + /// Cyan, magenta, yellow, black **ink** amounts, 0..=65535 (65535 = 100 % ink; the file + /// stores the complement). + Cmyk([u16; 4]), + /// L* × 100 (0..=10000), a* × 100 and b* × 100 (−12800..=12700). + Lab { + /// Lightness × 100. + l: u16, + /// a* × 100. + a: i16, + /// b* × 100. + b: i16, + }, + /// Gray level 0..=10000 (0 = black, 10000 = white). + Gray(u16), + /// Any other colour space, kept verbatim (the four values as stored). + Other { + /// Colour space id. + space: u16, + /// The four stored values. + values: [u16; 4], + }, +} + +impl AcoColor { + /// Decode a colour record (space id + four stored values). + pub fn from_record(space: u16, v: [u16; 4]) -> AcoColor { + match space { + space::RGB => AcoColor::Rgb([v[0], v[1], v[2]]), + space::HSB => AcoColor::Hsb([v[0], v[1], v[2]]), + space::CMYK => AcoColor::Cmyk(v.map(|x| u16::MAX - x)), + space::LAB => AcoColor::Lab { l: v[0], a: v[1] as i16, b: v[2] as i16 }, + space::GRAY => AcoColor::Gray(v[0]), + _ => AcoColor::Other { space, values: v }, + } + } + + /// The colour record (space id + four stored values) for this colour. + pub fn to_record(self) -> (u16, [u16; 4]) { + match self { + AcoColor::Rgb([r, g, b]) => (space::RGB, [r, g, b, 0]), + AcoColor::Hsb([h, s, b]) => (space::HSB, [h, s, b, 0]), + AcoColor::Cmyk(c) => (space::CMYK, c.map(|x| u16::MAX - x)), + AcoColor::Lab { l, a, b } => (space::LAB, [l, a as u16, b as u16, 0]), + AcoColor::Gray(g) => (space::GRAY, [g, 0, 0, 0]), + AcoColor::Other { space, values } => (space, values), + } + } +} + +/// One swatch: a colour and its name (empty in version 1 only files). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcoSwatch { + /// Name (from the version 2 section). + pub name: String, + /// Colour. + pub color: AcoColor, +} + +/// A parsed `.aco` file. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AcoFile { + /// Swatches in file order. + pub swatches: Vec, + /// Whether the file had a version 2 section (names). + pub has_names: bool, +} + +fn read_record(r: &mut Reader<'_>) -> Result { + let space = r.u16()?; + let v = [r.u16()?, r.u16()?, r.u16()?, r.u16()?]; + Ok(AcoColor::from_record(space, v)) +} + +fn read_name(r: &mut Reader<'_>) -> Result { + let n = u64::from(r.u32()?); + r.check_count(n, 2)?; + let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("swatch name length"))?; + let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); + for _ in 0..n { + let u = r.u16()?; + if units.len() < MAX_NAME_UNITS { + units.push(u); + } + } + while units.last() == Some(&0) { + units.pop(); + } + Ok(String::from_utf16_lossy(&units)) +} + +fn read_section(r: &mut Reader<'_>, named: bool) -> Result> { + let count = r.u16()? as usize; + // Each record is at least 10 bytes (plus a 4-byte name length in version 2). + r.check_count(count as u64, if named { 14 } else { 10 })?; + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let color = read_record(r)?; + let name = if named { read_name(r)? } else { String::new() }; + out.push(AcoSwatch { name, color }); + } + Ok(out) +} + +/// Parse an `.aco` file. Truncated sections, unknown versions and impossible counts are errors; +/// never panics. +pub fn parse(data: &[u8]) -> Result { + let mut r = Reader::new(data); + match r.u16()? { + 1 => { + let v1 = read_section(&mut r, false)?; + // A version 2 section follows in files written by Photoshop 6 and later; anything + // else after version 1 (padding some writers add) is ignored. + if r.remaining() >= 4 && r.peek_rest().get(..2) == Some(&[0, 2]) { + r.u16()?; + let v2 = read_section(&mut r, true)?; + return Ok(AcoFile { swatches: v2, has_names: true }); + } + Ok(AcoFile { swatches: v1, has_names: false }) + } + 2 => Ok(AcoFile { swatches: read_section(&mut r, true)?, has_names: true }), + v => Err(PsdError::UnsupportedVersion(v)), + } +} + +/// Write an `.aco` file: a version 1 section followed by a version 2 section with the names, +/// as Photoshop writes them. Fails when there are more than [`MAX_SWATCHES`] swatches. +pub fn write(swatches: &[AcoSwatch]) -> Result> { + let count = u16::try_from(swatches.len()).map_err(|_| PsdError::LimitExceeded("an .aco file holds at most 65535 swatches"))?; + let mut out = Vec::with_capacity(8 + swatches.len() * 40); + for version in [1u16, 2] { + out.put_u16(version); + out.put_u16(count); + for s in swatches { + let (space, v) = s.color.to_record(); + out.put_u16(space); + for x in v { + out.put_u16(x); + } + if version == 2 { + let units: Vec = s.name.encode_utf16().take(MAX_NAME_UNITS).collect(); + // The length counts the terminating null. + out.put_u32(units.len() as u32 + 1); + for u in units { + out.put_u16(u); + } + out.put_u16(0); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Vec { + vec![ + AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) }, + AcoSwatch { name: "Hue".into(), color: AcoColor::Hsb([12000, 65535, 40000]) }, + AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }, + AcoSwatch { name: "Lab −".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } }, + AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) }, + AcoSwatch { name: "Book 日本".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } }, + AcoSwatch { name: String::new(), color: AcoColor::Rgb([1, 2, 3]) }, + ] + } + + #[test] + fn round_trips_every_space_at_16_bits() { + let s = sample(); + let bytes = write(&s).unwrap(); + let f = parse(&bytes).unwrap(); + assert!(f.has_names); + assert_eq!(f.swatches, s); + assert_eq!(write(&f.swatches).unwrap(), bytes, "byte-stable"); + } + + #[test] + fn decodes_the_spec_encodings() { + // Pure cyan as the spec gives it: 0, 65535, 65535, 65535 (0 = 100 % ink). + assert_eq!(AcoColor::from_record(2, [0, 65535, 65535, 65535]), AcoColor::Cmyk([65535, 0, 0, 0])); + // White Lab = 10000, 0, 0; negative chrominance is two's complement. + assert_eq!(AcoColor::from_record(7, [10000, 0xFF38, 200, 0]), AcoColor::Lab { l: 10000, a: -200, b: 200 }); + assert_eq!(AcoColor::from_record(8, [10000, 0, 0, 0]), AcoColor::Gray(10000)); + assert_eq!(AcoColor::from_record(9, [7, 8, 9, 10]), AcoColor::Other { space: 9, values: [7, 8, 9, 10] }); + } + + #[test] + fn reads_version_1_only_and_version_2_only() { + // Version 1: two RGB colours, no names. + let v1 = [0u8, 1, 0, 2, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0, 8, 0x13, 0x88, 0, 0, 0, 0, 0, 0]; + let f = parse(&v1).unwrap(); + assert!(!f.has_names); + assert_eq!(f.swatches.iter().map(|s| s.color).collect::>(), vec![AcoColor::Rgb([65535, 0, 0]), AcoColor::Gray(5000)]); + // Trailing padding after a version 1 section is ignored. + let mut padded = v1.to_vec(); + padded.extend_from_slice(&[0, 0, 0, 0]); + assert_eq!(parse(&padded).unwrap().swatches.len(), 2); + // Version 2 only (the v1 section is skipped by some writers); name without its null. + let mut v2 = vec![0u8, 2, 0, 1, 0, 0, 0, 1, 0, 2, 0, 3, 0, 0]; + v2.extend_from_slice(&[0, 0, 0, 2, 0, b'O', 0, b'K']); + let f = parse(&v2).unwrap(); + assert_eq!(f.swatches, vec![AcoSwatch { name: "OK".into(), color: AcoColor::Rgb([1, 2, 3]) }]); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let good = write(&sample()).unwrap(); + for n in 0..good.len() { + // Every truncation inside a section fails (a cut exactly after v1 reads v1). + let r = parse(&good[..n]); + let v1_end = 4 + sample().len() * 10; + if n == v1_end || (n > v1_end && n < v1_end + 4) { + assert!(r.is_ok(), "{n}"); + } else { + assert!(r.is_err(), "{n}"); + } + } + assert!(parse(&[]).is_err()); + assert!(parse(&[0, 3, 0, 0]).is_err(), "unknown version"); + assert!(parse(&[0, 1, 0xFF, 0xFF]).is_err(), "huge count"); + // A name length far beyond the data. + let mut bad = vec![0u8, 2, 0, 1, 0, 0, 0, 1, 0, 2, 0, 3, 0, 0]; + bad.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0, 65]); + assert!(parse(&bad).is_err()); + // Garbage of every length never panics. + let mut seed = 0x1234_5678u32; + for len in 0..300 { + let junk: Vec = (0..len) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 24) as u8 + }) + .collect(); + let _ = parse(&junk); + let mut v = vec![0, 1]; + v.extend_from_slice(&junk); + let _ = parse(&v); + } + } + + #[test] + fn too_many_swatches_is_an_error() { + let many = vec![AcoSwatch { name: String::new(), color: AcoColor::Gray(0) }; MAX_SWATCHES + 1]; + assert!(write(&many).is_err()); + } + + #[test] + fn long_names_are_capped() { + let long = AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(0) }; + let f = parse(&write(&[long]).unwrap()).unwrap(); + assert_eq!(f.swatches[0].name.len(), MAX_NAME_UNITS); + } +} diff --git a/crates/psd/src/ase.rs b/crates/psd/src/ase.rs new file mode 100644 index 00000000..71f93e22 --- /dev/null +++ b/crates/psd/src/ase.rs @@ -0,0 +1,415 @@ +//! Adobe Swatch Exchange files (`.ase`), shared by Photoshop, Illustrator and InDesign. +//! +//! Layout from public descriptions of the format (big-endian throughout): the signature +//! `ASEF`, version `u16` major (1) and minor (0), a `u32` block count, then blocks of a `u16` +//! type, a `u32` body length and the body: +//! +//! - `0xC001` group start: a name; +//! - `0xC002` group end: empty; +//! - `0x0001` colour entry: a name, a four-character colour model (`RGB `, `CMYK`, `LAB `, +//! `Gray`), its components as `f32` (3, 4, 3 and 1 of them) and a `u16` colour type +//! (0 global, 1 spot, 2 normal/process). +//! +//! A name is a `u16` UTF-16 code-unit count that includes the trailing null, then the units. +//! Components are kept exactly as stored: RGB, CMYK and gray in 0..1 (gray 1 = white), Lab as +//! L*/100 followed by a* and b*. Unknown colour models and block types are kept verbatim +//! (colour models) or skipped (block types). Converting colours is the caller's job. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// File signature. +pub const SIGNATURE: &[u8; 4] = b"ASEF"; +/// Most colour entries read from one file. +pub const MAX_SWATCHES: usize = 100_000; +/// Longest name read or written, in UTF-16 code units. +pub const MAX_NAME_UNITS: usize = 1024; + +const GROUP_START: u16 = 0xC001; +const GROUP_END: u16 = 0xC002; +const COLOR: u16 = 0x0001; + +/// A colour as stored. +#[derive(Debug, Clone, PartialEq)] +pub enum AseColor { + /// Red, green, blue, 0..1. + Rgb([f32; 3]), + /// Cyan, magenta, yellow, black ink, 0..1. + Cmyk([f32; 4]), + /// L*/100 (0..1), a*, b*. + Lab([f32; 3]), + /// Gray level 0..1 (1 = white). + Gray(f32), + /// Any other colour model, kept verbatim: the model tag and the bytes after it, up to the + /// colour type. + Other { + /// Four-character model tag. + model: [u8; 4], + /// Component bytes as stored. + data: Vec, + }, +} + +/// The colour type of an entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AseKind { + /// A global colour (edits update every use). + Global, + /// A spot colour (its own ink). + Spot, + /// A normal (process) colour. + #[default] + Process, +} + +impl AseKind { + fn from_u16(v: u16) -> AseKind { + match v { + 0 => AseKind::Global, + 1 => AseKind::Spot, + _ => AseKind::Process, + } + } + fn to_u16(self) -> u16 { + match self { + AseKind::Global => 0, + AseKind::Spot => 1, + AseKind::Process => 2, + } + } +} + +/// One colour entry. +#[derive(Debug, Clone, PartialEq)] +pub struct AseSwatch { + /// Name. + pub name: String, + /// Colour. + pub color: AseColor, + /// Colour type. + pub kind: AseKind, +} + +/// A top-level entry: a colour, or a group of colours (the format has one level of groups). +#[derive(Debug, Clone, PartialEq)] +pub enum AseEntry { + /// A colour outside any group. + Swatch(AseSwatch), + /// A named group. + Group { + /// Group name. + name: String, + /// Its colours. + swatches: Vec, + }, +} + +/// A parsed `.ase` file. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AseFile { + /// Entries in file order. + pub entries: Vec, +} + +impl AseFile { + /// Number of colour entries (in and outside groups). + pub fn swatch_count(&self) -> usize { + self.entries + .iter() + .map(|e| match e { + AseEntry::Swatch(_) => 1, + AseEntry::Group { swatches, .. } => swatches.len(), + }) + .sum() + } +} + +fn f32_be(r: &mut Reader<'_>) -> Result { + let v = f32::from_bits(r.u32()?); + // Non-finite components would poison every conversion downstream. + Ok(if v.is_finite() { v } else { 0.0 }) +} + +fn read_name(r: &mut Reader<'_>) -> Result { + if r.is_empty() { + return Ok(String::new()); + } + let n = r.u16()? as usize; + r.check_count(n as u64, 2)?; + let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); + for _ in 0..n { + let u = r.u16()?; + if units.len() < MAX_NAME_UNITS { + units.push(u); + } + } + while units.last() == Some(&0) { + units.pop(); + } + Ok(String::from_utf16_lossy(&units)) +} + +fn read_color(body: &mut Reader<'_>) -> Result { + let name = read_name(body)?; + let model: [u8; 4] = body.array()?; + let color = match &model { + b"RGB " => AseColor::Rgb([f32_be(body)?, f32_be(body)?, f32_be(body)?]), + b"CMYK" => AseColor::Cmyk([f32_be(body)?, f32_be(body)?, f32_be(body)?, f32_be(body)?]), + b"LAB " => AseColor::Lab([f32_be(body)?, f32_be(body)?, f32_be(body)?]), + b"Gray" => AseColor::Gray(f32_be(body)?), + _ => { + // Unknown model: everything but the trailing colour type is its data. + let n = body.remaining().saturating_sub(2); + AseColor::Other { model, data: body.bytes(n)?.to_vec() } + } + }; + // Some writers omit the colour type; it defaults to process. + let kind = if body.remaining() >= 2 { AseKind::from_u16(body.u16()?) } else { AseKind::Process }; + Ok(AseSwatch { name, color, kind }) +} + +/// Parse an `.ase` file. A wrong signature or version, a truncated block, a block count beyond +/// the data or more than [`MAX_SWATCHES`] colours is an error; never panics. Unknown block +/// types are skipped, a group end without a start is ignored, and a group left open at the end +/// (or a group started inside another) is closed. +pub fn parse(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let sig: [u8; 4] = r.array()?; + if &sig != SIGNATURE { + return Err(PsdError::InvalidSignature { expected: "ASEF", found: sig }); + } + let major = r.u16()?; + let _minor = r.u16()?; + if major != 1 { + return Err(PsdError::UnsupportedVersion(major)); + } + let count = u64::from(r.u32()?); + // Every block has at least a type and a length. + r.check_count(count, 6)?; + let mut entries = Vec::new(); + let mut open: Option<(String, Vec)> = None; + let mut total = 0usize; + for _ in 0..count { + let kind = r.u16()?; + let len = u64::from(r.u32()?); + let mut body = r.sub(len)?; + match kind { + GROUP_START => { + if let Some((name, swatches)) = open.take() { + entries.push(AseEntry::Group { name, swatches }); + } + open = Some((read_name(&mut body)?, Vec::new())); + } + GROUP_END => { + if let Some((name, swatches)) = open.take() { + entries.push(AseEntry::Group { name, swatches }); + } + } + COLOR => { + total += 1; + if total > MAX_SWATCHES { + return Err(PsdError::LimitExceeded("too many swatches in an .ase file")); + } + let s = read_color(&mut body)?; + match open.as_mut() { + Some((_, v)) => v.push(s), + None => entries.push(AseEntry::Swatch(s)), + } + } + _ => {} + } + } + if let Some((name, swatches)) = open.take() { + entries.push(AseEntry::Group { name, swatches }); + } + Ok(AseFile { entries }) +} + +fn put_name(out: &mut Vec, name: &str) { + let units: Vec = name.encode_utf16().take(MAX_NAME_UNITS).collect(); + out.put_u16(units.len() as u16 + 1); + for u in units { + out.put_u16(u); + } + out.put_u16(0); +} + +fn put_block(out: &mut Vec, kind: u16, body: &[u8]) -> Result<()> { + out.put_u16(kind); + out.put_u32(u32::try_from(body.len()).map_err(|_| PsdError::LimitExceeded("swatch block too large"))?); + out.put(body); + Ok(()) +} + +fn put_color(out: &mut Vec, s: &AseSwatch) -> Result<()> { + let mut b = Vec::with_capacity(64); + put_name(&mut b, &s.name); + let floats = |b: &mut Vec, v: &[f32]| { + for x in v { + b.put_u32(x.to_bits()); + } + }; + match &s.color { + AseColor::Rgb(c) => { + b.put(b"RGB "); + floats(&mut b, c); + } + AseColor::Cmyk(c) => { + b.put(b"CMYK"); + floats(&mut b, c); + } + AseColor::Lab(c) => { + b.put(b"LAB "); + floats(&mut b, c); + } + AseColor::Gray(g) => { + b.put(b"Gray"); + floats(&mut b, &[*g]); + } + AseColor::Other { model, data } => { + b.put(model); + b.put(data); + } + } + b.put_u16(s.kind.to_u16()); + put_block(out, COLOR, &b) +} + +/// Write an `.ase` file (version 1.0). Fails only when a block or the block count overflows the +/// format's 32-bit fields or there are more than [`MAX_SWATCHES`] colours. +pub fn write(file: &AseFile) -> Result> { + if file.swatch_count() > MAX_SWATCHES { + return Err(PsdError::LimitExceeded("too many swatches for an .ase file")); + } + let mut body = Vec::with_capacity(64 * file.swatch_count().max(1)); + let mut blocks = 0u64; + for e in &file.entries { + match e { + AseEntry::Swatch(s) => { + put_color(&mut body, s)?; + blocks += 1; + } + AseEntry::Group { name, swatches } => { + let mut b = Vec::with_capacity(name.len() * 2 + 4); + put_name(&mut b, name); + put_block(&mut body, GROUP_START, &b)?; + for s in swatches { + put_color(&mut body, s)?; + } + put_block(&mut body, GROUP_END, &[])?; + blocks += 2 + swatches.len() as u64; + } + } + } + let mut out = Vec::with_capacity(12 + body.len()); + out.put(SIGNATURE); + out.put_u16(1); + out.put_u16(0); + out.put_u32(u32::try_from(blocks).map_err(|_| PsdError::LimitExceeded("too many blocks"))?); + out.put(&body); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sw(name: &str, color: AseColor, kind: AseKind) -> AseSwatch { + AseSwatch { name: name.into(), color, kind } + } + + fn sample() -> AseFile { + AseFile { + entries: vec![ + AseEntry::Swatch(sw("Loose red", AseColor::Rgb([1.0, 0.0, 0.0]), AseKind::Process)), + AseEntry::Group { + name: "Brand ✓".into(), + swatches: vec![ + sw("Ink", AseColor::Cmyk([0.1, 0.2, 0.3, 0.4]), AseKind::Spot), + sw("Lab", AseColor::Lab([0.5321, -12.25, 40.5]), AseKind::Global), + sw("Mid gray", AseColor::Gray(0.5), AseKind::Process), + sw("Odd", AseColor::Other { model: *b"HSV ", data: vec![1, 2, 3, 4, 5, 6] }, AseKind::Process), + ], + }, + AseEntry::Group { name: String::new(), swatches: vec![] }, + ], + } + } + + #[test] + fn round_trips_groups_models_and_types() { + let f = sample(); + let bytes = write(&f).unwrap(); + assert_eq!(&bytes[..4], b"ASEF"); + // 1 loose + (start + 4 + end) + (start + end). + assert_eq!(u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]), 9); + let back = parse(&bytes).unwrap(); + assert_eq!(back, f); + assert_eq!(write(&back).unwrap(), bytes, "byte-stable"); + assert_eq!(back.swatch_count(), 5); + } + + #[test] + fn lenient_structure() { + // A colour without a type, an unknown block, an end without start, an unclosed group. + let mut body = Vec::new(); + let mut c = Vec::new(); + put_name(&mut c, "A"); + c.put(b"Gray"); + c.put_u32(0.25f32.to_bits()); + put_block(&mut body, GROUP_END, &[]).unwrap(); + put_block(&mut body, 0x7777, &[9, 9, 9]).unwrap(); + let mut g = Vec::new(); + put_name(&mut g, "G"); + put_block(&mut body, GROUP_START, &g).unwrap(); + put_block(&mut body, COLOR, &c).unwrap(); + let mut f = b"ASEF\0\x01\0\0\0\0\0\x04".to_vec(); + f.extend_from_slice(&body); + let p = parse(&f).unwrap(); + assert_eq!(p.entries, vec![AseEntry::Group { name: "G".into(), swatches: vec![sw("A", AseColor::Gray(0.25), AseKind::Process)] }]); + } + + #[test] + fn non_finite_components_become_zero() { + let f = AseFile { entries: vec![AseEntry::Swatch(sw("n", AseColor::Rgb([f32::NAN, f32::INFINITY, 0.5]), AseKind::Process))] }; + let p = parse(&write(&f).unwrap()).unwrap(); + assert_eq!(p.entries, vec![AseEntry::Swatch(sw("n", AseColor::Rgb([0.0, 0.0, 0.5]), AseKind::Process))]); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let good = write(&sample()).unwrap(); + for n in 0..good.len() { + assert!(parse(&good[..n]).is_err(), "truncated at {n}"); + } + assert!(parse(b"ASEX\0\x01\0\0\0\0\0\0").is_err(), "signature"); + assert!(parse(b"ASEF\0\x02\0\0\0\0\0\0").is_err(), "version"); + assert!(parse(b"ASEF\0\x01\0\0\xff\xff\xff\xff").is_err(), "huge block count"); + // A block length beyond the data. + assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\xff\xff\xff\xff").is_err()); + // A name length beyond the block. + assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\0\0\0\x04\xff\xff\0A").is_err()); + // A colour block too short for its components. + assert!(parse(b"ASEF\0\x01\0\0\0\0\0\x01\0\x01\0\0\0\x0a\0\x01\0\0RGB \0\0").is_err()); + let mut seed = 0xDEAD_BEEFu32; + for len in 0..400 { + let junk: Vec = (0..len) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 24) as u8 + }) + .collect(); + let mut v = b"ASEF\0\x01\0\0\0\0\0\x10".to_vec(); + v.extend_from_slice(&junk); + let _ = parse(&v); + let _ = parse(&junk); + } + } + + #[test] + fn too_many_swatches() { + let s = sw("", AseColor::Gray(0.0), AseKind::Process); + let f = AseFile { entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![s; MAX_SWATCHES + 1] }] }; + assert!(write(&f).is_err()); + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 29913ddd..a3fb3c42 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -41,6 +41,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod abr; +pub mod aco; +pub mod ase; pub mod blend; pub mod builder; pub mod compression; diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 67e5e3d2..adb0ff3a 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -268,6 +268,11 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) -> Option { if widgets::secondary_button(ui, tl!("Cancel"), button_width).clicked() { outcome = Some(false); } + ui.add_space(6.0); + // Handled by the dialog host, which has the app (`take_add_swatch`). + if widgets::secondary_button(ui, tl!("Add to Swatches"), button_width).clicked() { + f.insert("__addSwatch".into(), json!(true)); + } }); }); ui.add_space(14.0); @@ -447,6 +452,13 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } } +/// Add to Swatches was clicked: add the picked colour to the Swatches panel. +pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map) { + if f.remove("__addSwatch").and_then(|v| v.as_bool()) == Some(true) { + crate::swatches_ui::add_from_picker(app, f); + } +} + /// OK: set the foreground or background colour. pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let target = f.get("__colorPicker").and_then(Value::as_str).unwrap_or("foreground"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1b25956e..13a8054a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -183,6 +183,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields), DialogKind::Command if crate::color_picker_ui::owns(&fields) => { outcome = crate::color_picker_ui::body(ui, &mut fields); + crate::color_picker_ui::take_add_swatch(app, &mut fields); } DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 9ab9a05d..646ae06d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -413,6 +413,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::layer_row_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Swatches") { + crate::swatches_ui::panel_menu(app, ui); + ui.separator(); + } if ui.button(if collapsed { tl!("Expand Panel Group") } else { tl!("Collapse Panel Group") }).clicked() { actions.push(Action::ToggleCollapse(g)); ui.close(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f030cd35..fd6c4853 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2145,6 +2145,17 @@ cameraRaw Color Barvy % of all commits % všech commitů Lines +/− Řádky +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, řádky +{added} / −{deleted} (Δ {delta}), binární soubory +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch Vytvořit nový vzorek + New Swatch… Nový vzorek… + New Swatch Group… Nová skupina vzorků… + Import Swatches… Importovat vzorky… + Replace Swatches… Nahradit vzorky… + Export Swatches… Exportovat vzorky… + Export Swatches for Exchange… Exportovat vzorky pro výměnu… + Reset Swatches Obnovit vzorky + Add to Swatches Přidat do vzorníku # Layer Style > Blending Options (Advanced Blending) Blend If: Prolnout, pokud: Channels: Kanály: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0e55d7e5..456bbf17 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2186,6 +2186,16 @@ Lines +/− Zeilen +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{binAdded} / −{binDeleted} Binärdateien, {first} – {last} +# Swatches panel + Create new swatch Neues Farbfeld erstellen + New Swatch… Neues Farbfeld… + New Swatch Group… Neue Farbfeldgruppe… + Import Swatches… Farbfelder importieren… + Replace Swatches… Farbfelder ersetzen… + Export Swatches… Farbfelder exportieren… + Export Swatches for Exchange… Farbfelder für Austausch exportieren… + Reset Swatches Farbfelder zurücksetzen + Add to Swatches Zu Farbfeldern hinzufügen # Layer Style > Blending Options (Advanced Blending) Blend If: Farbbereich: Channels: Kanäle: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0ee2cf18..c4410a19 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2216,6 +2216,17 @@ cameraRaw Amount Cantidad % of all commits % de todos los commits Lines +/− Líneas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{binAdded} / −{binDeleted} archivos binarios, {first} – {last} + +# Swatches panel + Create new swatch Crear una muestra nueva + New Swatch… Muestra nueva… + New Swatch Group… Grupo de muestras nuevo… + Import Swatches… Importar muestras… + Replace Swatches… Reemplazar muestras… + Export Swatches… Exportar muestras… + Export Swatches for Exchange… Exportar muestras para intercambio… + Reset Swatches Restablecer muestras + Add to Swatches Añadir a muestras # Layer Style > Blending Options (Advanced Blending) Blend If: Combinar si: Channels: Canales: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fc567df5..88daf505 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2576,6 +2576,17 @@ cameraRaw Blending Fusion % of all commits % de tous les commits Lines +/− Lignes +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{binAdded} / −{binDeleted} fichiers binaires, {first} – {last} + +# Swatches panel + Create new swatch Créer une nouvelle nuance + New Swatch… Nouvelle nuance… + New Swatch Group… Nouveau groupe de nuances… + Import Swatches… Importer des nuances… + Replace Swatches… Remplacer les nuances… + Export Swatches… Exporter les nuances… + Export Swatches for Exchange… Exporter les nuances pour l'échange… + Reset Swatches Réinitialiser les nuances + Add to Swatches Ajouter au nuancier # Layer Style > Blending Options (Advanced Blending) Blend If: Fusion si : Channels: Couches : diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a9ddd31c..78227d24 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2563,6 +2563,17 @@ cameraRaw Blending Pembauran % of all commits % dari semua commit Lines +/− Baris +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} baris (Δ {delta}), +{binAdded} / −{binDeleted} berkas biner, {first} – {last} + +# Swatches panel + Create new swatch Buat swatch baru + New Swatch… Swatch Baru… + New Swatch Group… Grup Swatch Baru… + Import Swatches… Impor Swatch… + Replace Swatches… Ganti Swatch… + Export Swatches… Ekspor Swatch… + Export Swatches for Exchange… Ekspor Swatch untuk Pertukaran… + Reset Swatches Atur Ulang Swatch + Add to Swatches Tambahkan ke Swatch # Layer Style > Blending Options (Advanced Blending) Blend If: Blend If: Channels: Channel: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c014e1da..28e6a3bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2223,6 +2223,16 @@ cameraRaw Amount Quantità Channels: Canali: Knockout Foratura +# Swatches panel + Create new swatch Crea nuovo campione + New Swatch… Nuovo campione… + New Swatch Group… Nuovo gruppo di campioni… + Import Swatches… Importa campioni… + Replace Swatches… Sostituisci campioni… + Export Swatches… Esporta campioni… + Export Swatches for Exchange… Esporta campioni per scambio… + Reset Swatches Ripristina campioni + Add to Swatches Aggiungi ai campioni Convert to Layers Converti in livelli Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ae751365..78a2694f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2128,6 +2128,17 @@ cameraRaw Color カラー % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs} 件、コミット {commits} 件、行 +{added} / −{deleted} (Δ {delta})、バイナリ +{binAdded} / −{binDeleted}、{first} – {last} + +# Swatches panel + Create new swatch 新規スウォッチを作成 + New Swatch… 新規スウォッチ… + New Swatch Group… 新規スウォッチグループ… + Import Swatches… スウォッチを読み込み… + Replace Swatches… スウォッチを置き換え… + Export Swatches… スウォッチを書き出し… + Export Swatches for Exchange… 交換用にスウォッチを書き出し… + Reset Swatches スウォッチを初期化 + Add to Swatches スウォッチに追加 # Layer Style > Blending Options (Advanced Blending) Blend If: 条件付き合成: Channels: チャンネル: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0a46b04f..726cb4b0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2735,6 +2735,17 @@ cameraRaw Color 색상 % of all commits 전체 커밋 대비 % Lines +/− 줄 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR {prs}개, 커밋 {commits}개, 줄 +{added} / −{deleted} (Δ {delta}), 바이너리 +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch 새 색상 견본 만들기 + New Swatch… 새 색상 견본… + New Swatch Group… 새 색상 견본 그룹… + Import Swatches… 색상 견본 가져오기… + Replace Swatches… 색상 견본 바꾸기… + Export Swatches… 색상 견본 내보내기… + Export Swatches for Exchange… 교환용 색상 견본 내보내기… + Reset Swatches 색상 견본 재설정 + Add to Swatches 색상 견본에 추가 # Layer Style > Blending Options (Advanced Blending) Blend If: 혼합 조건: Channels: 채널: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e4b73b48..ad8ddccc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3184,6 +3184,17 @@ cameraRaw Roughness Chropowatość More documents Więcej dokumentów Developing {name} Wywoływanie {name} Camera Raw ({name}) Camera Raw ({name}) + +# Swatches panel + Create new swatch Utwórz nową próbkę + New Swatch… Nowa próbka… + New Swatch Group… Nowa grupa próbek… + Import Swatches… Importuj próbki… + Replace Swatches… Zastąp próbki… + Export Swatches… Eksportuj próbki… + Export Swatches for Exchange… Eksportuj próbki do wymiany… + Reset Swatches Resetuj próbki + Add to Swatches Dodaj do próbek Blend If: Mieszaj, jeśli: Channels: Kanały: Knockout Wycięcie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 39c09764..7198a40a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2584,6 +2584,16 @@ cameraRaw Whites Brancos Lines +/− Linhas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{binAdded} / −{binDeleted} arquivos binários, {first} – {last} +# Swatches panel + Create new swatch Criar nova amostra + New Swatch… Nova amostra… + New Swatch Group… Novo grupo de amostras… + Import Swatches… Importar amostras… + Replace Swatches… Substituir amostras… + Export Swatches… Exportar amostras… + Export Swatches for Exchange… Exportar amostras para troca… + Reset Swatches Redefinir amostras + Add to Swatches Adicionar às amostras # Layer Style > Blending Options (Advanced Blending) Blend If: Mesclar se: Channels: Canais: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b1f340d8..ff4d7ba6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2422,6 +2422,17 @@ cameraRaw Roughness Неровность % of all commits % от всех коммитов Lines +/− Строки +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, коммиты: {commits}, строки +{added} / −{deleted} (Δ {delta}), двоичные файлы +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch Создать новый образец + New Swatch… Новый образец… + New Swatch Group… Новая группа образцов… + Import Swatches… Импортировать образцы… + Replace Swatches… Заменить образцы… + Export Swatches… Экспортировать образцы… + Export Swatches for Exchange… Экспортировать образцы для обмена… + Reset Swatches Восстановить образцы + Add to Swatches Добавить в образцы # Layer Style > Blending Options (Advanced Blending) Blend If: Изображение если: Channels: Каналы: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5a2ac844..f65777e3 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2153,6 +2153,17 @@ cameraRaw Blending 混合 % of all commits 占全部提交的 % Lines +/− 行数 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 个 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二进制文件 +{binAdded} / −{binDeleted},{first} – {last} + +# Swatches panel + Create new swatch 创建新色板 + New Swatch… 新建色板… + New Swatch Group… 新建色板组… + Import Swatches… 导入色板… + Replace Swatches… 替换色板… + Export Swatches… 导出色板… + Export Swatches for Exchange… 导出色板以供交换… + Reset Swatches 复位色板 + Add to Swatches 添加到色板 # Layer Style > Blending Options (Advanced Blending) Blend If: 混合颜色带: Channels: 通道: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d2ed310a..8e41ff56 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2122,6 +2122,17 @@ cameraRaw Blending 混合 % of all commits 佔全部提交的 % Lines +/− 行數 +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}:{prs} 個 PR,{commits} 次提交,行 +{added} / −{deleted}(Δ {delta}),二進位檔 +{binAdded} / −{binDeleted},{first} – {last} + +# Swatches panel + Create new swatch 建立新色票 + New Swatch… 新增色票… + New Swatch Group… 新增色票群組… + Import Swatches… 匯入色票… + Replace Swatches… 取代色票… + Export Swatches… 匯出色票… + Export Swatches for Exchange… 匯出色票以供交換… + Reset Swatches 重設色票 + Add to Swatches 加入色票 # Layer Style > Blending Options (Advanced Blending) Blend If: 混合範圍: Channels: 色版: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index affa296e..c0414940 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,6 +122,7 @@ pub mod state; pub mod stroke_constraint; pub mod stroke_trail; pub mod stylus; +pub mod swatches_ui; mod tab_strip; pub mod theme; pub mod tiff_options_ui; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1c4f7fa0..a7bad911 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1,6 +1,6 @@ //! Chrome around the canvas: title bar, options bar, toolbar, status bar, dock cards, Properties. -use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent, LayerId}; use serde_json::{Value, json}; @@ -1130,8 +1130,8 @@ fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate::dock::Gro (Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui), (Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui), (Group::Color, 0) if pro => color_field(app, ui), - (Group::Color, _) if pro => swatches(app, ui), - (Group::Color, 0) => swatches(app, ui), + (Group::Color, _) if pro => crate::swatches_ui::panel(app, ui), + (Group::Color, 0) => crate::swatches_ui::panel(app, ui), (Group::Color, _) => color_picker(app, ui), (Group::Properties, 0) => properties_body(app, ui), (Group::Properties, _) => adjustments_grid(app, ui), @@ -1280,78 +1280,6 @@ fn empty(ui: &mut egui::Ui, s: &str) { ui.add_space(6.0); } -const SWATCHES: [[u8; 3]; 40] = [ - [0, 0, 0], - [26, 26, 26], - [51, 51, 51], - [77, 77, 77], - [102, 102, 102], - [128, 128, 128], - [153, 153, 153], - [179, 179, 179], - [204, 204, 204], - [255, 255, 255], - [236, 128, 128], - [244, 176, 132], - [250, 224, 128], - [214, 240, 128], - [150, 232, 150], - [128, 232, 200], - [128, 220, 240], - [128, 176, 244], - [168, 144, 244], - [232, 144, 232], - [230, 40, 40], - [245, 120, 30], - [250, 210, 30], - [160, 220, 40], - [40, 200, 80], - [30, 200, 170], - [30, 170, 230], - [40, 100, 230], - [120, 70, 220], - [210, 50, 180], - [120, 20, 20], - [130, 60, 10], - [130, 110, 10], - [80, 120, 20], - [20, 100, 40], - [10, 100, 90], - [10, 80, 120], - [20, 50, 120], - [60, 30, 110], - [110, 20, 90], -]; - -fn swatches(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let cols = 10; - let gap = 4.0; - let w = ui.available_width(); - let cell = ((w - gap * (cols as f32 - 1.0)) / cols as f32).floor(); - let rows = SWATCHES.len().div_ceil(cols); - let (area, _) = ui.allocate_exact_size(vec2(w, rows as f32 * (cell + gap)), Sense::hover()); - for (i, s) in SWATCHES.iter().enumerate() { - let (cx, cy) = ((i % cols) as f32, (i / cols) as f32); - let r = Rect::from_min_size(area.min + vec2(cx * (cell + gap), cy * (cell + gap)), Vec2::splat(cell)); - let resp = ui.interact(r, ui.id().with(("sw", i)), Sense::click()); - ui.painter().rect_filled(r, 4.0, Color32::from_rgb(s[0], s[1], s[2])); - if resp.hovered() { - ui.painter().rect_stroke(r, 4.0, Stroke::new(1.5, t.text), StrokeKind::Outside); - } - let c = [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, 1.0]; - if resp.clicked() { - app.session.tools.foreground = c; - crate::type_tool::foreground_changed(app); - } - if resp.secondary_clicked() { - app.session.tools.background = c; - } - } - ui.add_space(2.0); - ui.label(RichText::new(tl!("Click sets foreground · right-click sets background")).small().color(t.text_faint)); -} - fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fg = app.session.tools.foreground; // Keep the last edited HSB while it still gives the foreground: black and greys have no hue or @@ -3169,37 +3097,8 @@ mod lock_tests { #[cfg(test)] mod swatch_type_tests { use super::*; - use egui::Modifiers; use egui_kittest::Harness; - /// Clicking a swatch while characters are selected recolours them, not just the foreground. - #[test] - fn clicking_a_swatch_recolours_selected_type() { - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); - let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); - app.ui.tool = Tool::Type; - app.ui.text_edit = - Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); - let mut h = Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(|ui, app: &mut PhotocraftApp| swatches(app, ui), app); - h.run_steps(2); - // The first swatch is the top-left cell of the panel's content. - let p = h.ctx.input(|i| i.viewport_rect()).min + vec2(12.0, 12.0); - h.hover_at(p); - h.run_steps(1); - h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); - h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - let s = SWATCHES[0]; - assert_eq!(h.state().session.tools.foreground, [s[0] as f32 / 255.0, s[1] as f32 / 255.0, s[2] as f32 / 255.0, 1.0]); - let st = h.state().session.active().unwrap(); - let Some(photocraft_doc::LayerContent::Text(t)) = st.doc.layer(photocraft_doc::LayerId(id)).map(|l| &l.content) else { panic!("type layer") }; - let runs = t.char_runs(); - assert_eq!(runs[0].len, 5, "the selection is its own run"); - assert_eq!(runs[0].style.color.to_rgba8(), [s[0], s[1], s[2], 255]); - assert_eq!(runs[1].style.color.to_rgba8(), [255, 255, 255, 255]); - } - /// The HSB sliders only act on an edit. Their h/s/v round trip isn't exact for every colour /// (#D8452E isn't), and comparing values used to rewrite the foreground every frame, which /// would recolour selected type the moment it was selected. @@ -3249,7 +3148,7 @@ mod type_flyout_tests { .widgets .layers() .flat_map(|(_, w)| w.iter()) - .filter(|w| w.rect.size() == Vec2::splat(bx) && w.sense.senses_click()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) .map(|w| w.rect) .collect() }); diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 0546c8b8..7ec8e55a 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -1,14 +1,16 @@ //! Preset files opened like documents (File › Open, drag-and-drop, the Brushes panel's //! Import Brushes…, the Preset Manager's Load…): Photoshop brushes (`.abr`) go to the brush //! library through `brush.presets.importAbr`, gradients (`.grd`) to the Gradients panel through -//! `gradient.presets.importGrd`, instead of opening as documents. +//! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through +//! `swatches.import`, instead of opening as documents. use serde_json::{Value, json}; use crate::PhotocraftApp; -/// Extensions handled here rather than by the document importer. -pub const PRESET_EXTS: &[&str] = &["abr", "grd"]; +/// Extensions handled here rather than by the document importer (`.aco`/`.ase` swatches go to +/// the Swatches panel through [`crate::swatches_ui::import_bytes`]). +pub const PRESET_EXTS: &[&str] = &["abr", "grd", "aco", "ase"]; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -34,6 +36,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("brush.presets.importAbr", "brushes"), "grd" => ("gradient.presets.importGrd", "gradients"), + "aco" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), _ => return None, }; let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Imported").into()); @@ -72,6 +75,8 @@ mod tests { fn recognises_preset_files() { assert!(is_preset_file("/a/b/Set.ABR")); assert!(is_preset_file("sunsets.grd")); + assert!(is_preset_file("Brand.ACO")); + assert!(is_preset_file("exchange.ase")); assert!(!is_preset_file("photo.psd")); assert!(!is_preset_file("abr")); } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 05c981e1..29468bfa 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -106,7 +106,7 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option { float_flag(&mut p, id).map(|f| *f) } -fn run(app: &mut PhotocraftApp, id: &str, p: Value) -> Option { +pub(crate) fn run(app: &mut PhotocraftApp, id: &str, p: Value) -> Option { match app.run(id, p) { Ok(v) => Some(v), Err(e) => { @@ -205,17 +205,17 @@ fn shape_texture(ctx: &egui::Context, sh: &photocraft_engine::presets::shapes::S // ------------------------------------------------------------------ the browser -struct ItemView { - key: String, - name: String, +pub(crate) struct ItemView { + pub(crate) key: String, + pub(crate) name: String, } -struct GroupView { - name: String, - items: Vec, +pub(crate) struct GroupView { + pub(crate) name: String, + pub(crate) items: Vec, } -enum Ev { +pub(crate) enum Ev { Select(String), Activate(String), Drop(String, Pos2), @@ -229,18 +229,19 @@ enum Ev { } fn group_open(st: &PresetUi, panel: &str, gi: usize, name: &str) -> bool { - (gi == 0) != st.toggled.contains(&format!("{panel}/{name}")) + // Swatch groups are small and all start open (Photoshop); other panels open the first only. + (gi == 0 || panel == crate::swatches_ui::PANEL) != st.toggled.contains(&format!("{panel}/{name}")) } /// Draws the folders and footer; `thumb` paints item `(group, item)` into a rect. /// Where a browser sits: the canvas (drop target), its height cap and the New button's tooltip. -struct Place<'a> { - canvas: Rect, - max_h: f32, - new_tip: &'a str, +pub(crate) struct Place<'a> { + pub(crate) canvas: Rect, + pub(crate) max_h: f32, + pub(crate) new_tip: &'a str, } -fn browser( +pub(crate) fn browser( ui: &mut egui::Ui, st: &mut PresetUi, panel: &str, diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs new file mode 100644 index 00000000..f313eb56 --- /dev/null +++ b/crates/ui-egui/src/swatches_ui.rs @@ -0,0 +1,370 @@ +//! Window › Swatches: the swatch library (`photocraft_engine::presets::swatches`) in the shared +//! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context +//! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete). +//! +//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); +//! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The +//! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, +//! `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a +//! `swatches.*` engine command; this module only draws and dispatches. Colours are shown in the +//! RGB working space through `photocraft-cms` (cached per library revision and colour settings). + +use std::sync::Arc; + +use egui::{Color32, CornerRadius, Rect}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::preset_panels::{Ev, GroupView, ItemView, Place, browser, run}; + +/// The browser's panel key (selection, folder state and thumbnail size live under it). +pub const PANEL: &str = "swatches"; +/// Default cell size: Photoshop's small swatches. +const DEFAULT_SIZE: f32 = 22.0; + +fn c32(c: [f32; 3]) -> Color32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(c[0]), q(c[1]), q(c[2])) +} + +/// Display colours of every swatch, rebuilt when the library or the working spaces change. +fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc>> { + let c = &app.session.color.settings; + let key = egui::Id::new(("swatch-colors", app.session.presets.rev, &c.working_rgb, &c.working_cmyk, &c.working_gray)); + if let Some(v) = ctx.data(|d| d.get_temp::>>>(key)) { + return v; + } + let conv = photocraft_engine::presets::swatches::SwatchConv::new(&app.session); + let v: Arc>> = Arc::new(app.session.presets.swatches.iter().map(|g| g.items.iter().map(|s| c32(conv.rgb(&s.color))).collect()).collect()); + ctx.data_mut(|d| { + // Only the newest table is kept. + if let Some(old) = d.get_temp::(egui::Id::new("swatch-colors-key")) { + d.remove::>>>(old); + } + d.insert_temp(egui::Id::new("swatch-colors-key"), key); + d.insert_temp(key, v.clone()); + }); + v +} + +/// Set the foreground (or background) colour from a swatch. +pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) { + let target = if background { "background" } else { "foreground" }; + if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background { + // Clicking a swatch while characters are selected recolours them (#364). + crate::type_tool::foreground_changed(app); + } +} + +/// The Swatches panel body. +pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let lib = &app.session.presets.swatches; + let groups: Vec = lib + .iter() + .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|s| ItemView { key: s.name.clone(), name: s.name.clone() }).collect() }) + .collect(); + let table = colors(app, ui.ctx()); + let canvas = app.last_canvas_rect; + let mut st = std::mem::take(&mut app.ui.presets_ui); + st.sizes.entry(PANEL.to_string()).or_insert(DEFAULT_SIZE); + let max_h = (ui.available_height() - 30.0).max(60.0); + let card_border = crate::theme::Tokens::get(ui.ctx()).card_border; + let events = browser(ui, &mut st, PANEL, &groups, Place { canvas, max_h, new_tip: tl!("Create new swatch") }, &mut |ui, r: Rect, gi, ii| { + let c = table.get(gi).and_then(|g| g.get(ii)).copied().unwrap_or(Color32::GRAY); + ui.painter().rect_filled(r, CornerRadius::same(2), c); + ui.painter().rect_stroke(r, CornerRadius::same(2), egui::Stroke::new(1.0, card_border), egui::StrokeKind::Inside); + }); + app.ui.presets_ui = st; + let mods = ui.input(|i| i.modifiers); + for e in events { + handle(app, e, mods.alt || mods.command || mods.ctrl); + } +} + +fn handle(app: &mut PhotocraftApp, e: Ev, background: bool) { + let sel = |app: &mut PhotocraftApp, k: &str| { + app.ui.presets_ui.selected.insert(PANEL.into(), k.to_string()); + }; + match e { + Ev::Select(k) => { + sel(app, &k); + use_swatch(app, &k, background); + } + Ev::Activate(k) => { + sel(app, &k); + app.ui.presets_ui.renaming = Some((PANEL.into(), k.clone(), k)); + } + // Swatches don't drop onto the canvas. + Ev::Drop(..) => {} + Ev::Rename(k, n) => { + if run(app, "swatches.rename", json!({"swatch": k, "name": n})).is_some() && app.ui.presets_ui.selected.get(PANEL) == Some(&k) { + sel(app, &n); + } + } + Ev::Delete(k) => { + if run(app, "swatches.delete", json!({"swatch": k})).is_some() { + app.ui.presets_ui.selected.remove(PANEL); + } + } + Ev::Move(k, g) => drop(run(app, "swatches.move", json!({"swatch": k, "to": g}))), + Ev::RenameGroup(g, n) => drop(run(app, "swatches.renameGroup", json!({"group": g, "name": n}))), + Ev::DeleteGroup(g) => drop(run(app, "swatches.deleteGroup", json!({"group": g}))), + Ev::NewGroup => new_group(app), + Ev::New => new_swatch(app), + } +} + +/// New Swatch: the foreground colour, named and selected, then renamed in place (Photoshop asks +/// for the name). +fn new_swatch(app: &mut PhotocraftApp) { + let group = app + .ui + .presets_ui + .selected + .get(PANEL) + .and_then(|k| app.session.presets.swatches.iter().find(|g| g.items.iter().any(|s| &s.name == k)).map(|g| g.name.clone())); + let p = group.map_or_else(|| json!({}), |g| json!({"group": g})); + if let Some(r) = run(app, "swatches.add", p) { + let name = r["name"].as_str().unwrap_or_default().to_string(); + app.ui.presets_ui.selected.insert(PANEL.into(), name.clone()); + app.ui.presets_ui.renaming = Some((PANEL.into(), name.clone(), name)); + } +} + +fn new_group(app: &mut PhotocraftApp) { + if let Some(r) = run(app, "swatches.newGroup", json!({"name": "Group"})) { + let g = r["group"].as_str().unwrap_or_default().to_string(); + app.ui.presets_ui.renaming = Some((PANEL.into(), format!("group:{g}"), g)); + } +} + +/// Command params for a picked file: a real path keeps the journal small; browser files arrive +/// as bytes. +fn source(name: &str, bytes: &[u8]) -> Value { + #[cfg(not(target_arch = "wasm32"))] + if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() { + return json!({"path": name}); + } + let _ = name; + json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) +} + +/// Import an `.aco`/`.ase` file's bytes (File › Open, drag and drop, the panel menu). +pub fn import_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8], replace: bool) -> Result { + let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()); + let mut p = source(name, bytes); + p["mode"] = json!(if replace { "replace" } else { "append" }); + if let Some(stem) = stem { + p["group"] = json!(stem); + } + let r = app.run("swatches.import", p)?; + let n = r["count"].as_u64().unwrap_or(0); + let warnings: Vec = r.get("warnings").and_then(|w| serde_json::from_value(w.clone()).ok()).unwrap_or_default(); + let shown = crate::file_open::display_name(name); + app.ui.status_error = false; + app.ui.status = match warnings.first() { + Some(w) => format!("Imported {n} swatches from {shown} ({} notes: {w})", warnings.len()), + None => format!("Imported {n} swatches from {shown}"), + }; + app.ui.panels.color = true; + Ok(r) +} + +fn import_dialog(app: &mut PhotocraftApp, replace: bool) -> Result { + let (name, bytes) = app.pick_file_bytes().ok_or("cancelled")??; + import_bytes(app, &name, &bytes, replace) +} + +fn export_dialog(app: &mut PhotocraftApp, format: &str) -> Result { + let r = app.run("swatches.export", json!({"format": format}))?; + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + let path = app.services.pick_save.as_mut().and_then(|f| f(&format!("Swatches.{format}"))).ok_or("cancelled")?; + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let n = r["count"].as_u64().unwrap_or(0); + app.ui.status_error = false; + app.ui.status = format!("Exported {n} swatches to {}", crate::file_open::display_name(&path)); + Ok(json!({"path": path, "count": n})) +} + +/// The Swatches entries of the panel group's menu ("…"). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let empty = app.session.presets.swatches.iter().all(|g| g.items.is_empty()); + let mut act: Option<&str> = None; + if ui.button(tl!("New Swatch…")).clicked() { + act = Some("new"); + } + if ui.button(tl!("New Swatch Group…")).clicked() { + act = Some("group"); + } + ui.separator(); + if ui.button(tl!("Import Swatches…")).clicked() { + act = Some("import"); + } + if ui.button(tl!("Replace Swatches…")).clicked() { + act = Some("replace"); + } + if ui.add_enabled(!empty, egui::Button::new(tl!("Export Swatches…"))).clicked() { + act = Some("aco"); + } + if ui.add_enabled(!empty, egui::Button::new(tl!("Export Swatches for Exchange…"))).clicked() { + act = Some("ase"); + } + ui.separator(); + if ui.button(tl!("Reset Swatches")).clicked() { + act = Some("reset"); + } + let Some(a) = act else { return }; + ui.close(); + let r = match a { + "new" => { + new_swatch(app); + Ok(Value::Null) + } + "group" => { + new_group(app); + Ok(Value::Null) + } + "import" => import_dialog(app, false), + "replace" => import_dialog(app, true), + "aco" | "ase" => export_dialog(app, a), + _ => app.run("swatches.reset", json!({})), + }; + if let Err(e) = r + && e != "cancelled" + { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// The Color Picker's Add to Swatches: the picked colour, in the model the user typed it in +/// (CMYK or Lab values kept by the picker), else RGB. +pub fn add_from_picker(app: &mut PhotocraftApp, f: &serde_json::Map) { + let color = if let Some(c) = f.get("__cmyk").and_then(|v| serde_json::from_value::<[f32; 4]>(v.clone()).ok()) { + json!({"model": "cmyk", "values": c.map(|v| v * 100.0)}) + } else if let Some(l) = f.get("__lab").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()) { + json!({"model": "lab", "values": l}) + } else { + json!(f.get("color").and_then(Value::as_str).unwrap_or("#000000")) + }; + if let Some(r) = run(app, "swatches.add", json!({"color": color})) { + let name = r["name"].as_str().unwrap_or_default().to_string(); + app.ui.status_error = false; + app.ui.status = format!("Added swatch {name}"); + app.ui.presets_ui.selected.insert(PANEL.into(), name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::Tool; + use egui::{Modifiers, vec2}; + use egui_kittest::Harness; + + fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) { + h.hover_at(p); + // Held modifiers, as when the user holds Alt while clicking. + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(2); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); + } + + /// The first swatch: below the first group header (22 px), after the 8 px grid inset. + fn first_cell(h: &Harness<'_, PhotocraftApp>) -> egui::Pos2 { + h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0) + } + + /// Clicking a swatch while characters are selected recolours them, not just the foreground. + #[test] + fn clicking_a_swatch_recolours_selected_type() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("type.create", json!({"text": "Hello world", "size": 40, "x": 20, "y": 100, "color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + app.ui.tool = Tool::Type; + app.ui.text_edit = + Some(crate::state::TextEdit { layer: id, caret: 0, anchor: 5, session: "s".into(), created: false, dragging: false, resize: None, preedit: None }); + // Recolour with a visible colour: put Red first. + app.run("swatches.move", json!({"swatch": "Red", "to": "Grays", "position": 0})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); + h.run_steps(2); + let p = first_cell(&h); + click(&mut h, p, Modifiers::NONE); + let red = [230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0, 1.0]; + assert_eq!(h.state().session.tools.foreground, red); + let st = h.state().session.active().unwrap(); + let Some(photocraft_doc::LayerContent::Text(t)) = st.doc.layer(photocraft_doc::LayerId(id)).map(|l| &l.content) else { panic!("type layer") }; + let runs = t.char_runs(); + assert_eq!(runs[0].len, 5, "the selection is its own run"); + assert_eq!(runs[0].style.color.to_rgba8(), [230, 40, 40, 255]); + assert_eq!(runs[1].style.color.to_rgba8(), [255, 255, 255, 255]); + } + + #[test] + fn alt_click_sets_the_background_and_selects() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.background = [0.0, 0.0, 1.0, 1.0]; + let mut h = Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); + h.run_steps(2); + let p = first_cell(&h); + click(&mut h, p, Modifiers::ALT); + assert_eq!(h.state().session.tools.background, [0.0, 0.0, 0.0, 1.0], "Black"); + assert_eq!(h.state().session.tools.foreground, [0.0, 0.0, 0.0, 1.0], "foreground untouched (default black)"); + assert_eq!(h.state().ui.presets_ui.selected.get(PANEL).map(String::as_str), Some("Black")); + // The footer's trash deletes the selection. + let n = h.state().session.presets.swatches[0].items.len(); + handle(h.state_mut(), Ev::Delete("Black".into()), false); + assert_eq!(h.state().session.presets.swatches[0].items.len(), n - 1); + } + + #[test] + fn new_swatch_from_the_foreground_and_picker() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [0.5, 0.25, 1.0, 1.0]; + app.ui.presets_ui.selected.insert(PANEL.into(), "Dark Red".into()); + new_swatch(&mut app); + let dark = app.session.presets.swatches.iter().find(|g| g.name == "Dark").unwrap(); + assert_eq!(dark.items.last().unwrap().name, "Color Swatch 1", "into the selected swatch's group"); + assert!(app.ui.presets_ui.renaming.is_some(), "renamed in place"); + let mut f = serde_json::Map::new(); + f.insert("color".into(), json!("#00ffff")); + f.insert("__cmyk".into(), json!([1.0, 0.0, 0.0, 0.0])); + add_from_picker(&mut app, &f); + let s = app.session.presets.swatches[0].items.last().unwrap(); + assert_eq!(s.color, photocraft_engine::presets::swatches::SwatchColor::Cmyk([1.0, 0.0, 0.0, 0.0])); + } + + #[test] + fn import_and_export_through_the_services() { + use std::sync::{Arc, Mutex}; + type Written = Arc)>>>; + let written: Written = Arc::default(); + let w = written.clone(); + let aco = + photocraft_psd::aco::write(&[photocraft_psd::aco::AcoSwatch { name: "Teal".into(), color: photocraft_psd::aco::AcoColor::Rgb([0, 32768, 32768]) }]) + .unwrap(); + let services = crate::Services { + pick_open: Some(Box::new(move || Some(("Ocean.aco".to_string(), Ok(aco.clone()))))), + pick_save: Some(Box::new(|s: &str| Some(format!("out/{s}")))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + import_dialog(&mut app, false).unwrap(); + assert_eq!(app.session.presets.swatches.last().unwrap().name, "Ocean"); + import_dialog(&mut app, true).unwrap(); + assert_eq!(app.session.presets.swatches.len(), 1, "replace"); + export_dialog(&mut app, "ase").unwrap(); + let out = written.lock().unwrap(); + assert_eq!(out[0].0, "out/Swatches.ase"); + assert!(out[0].1.starts_with(b"ASEF")); + } +} diff --git a/docs/scorecard.md b/docs/scorecard.md index 282a3cdd..4065b8df 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 3 | 12 | 12 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 4 | 12 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 9 | 14 | 8 | | | [Workspace and UI](#workspace-and-ui) | 0 | 5 | 18 | settings that do nothing: 56 of 140 (target 0) | | [Type](#type) | 0 | 3 | 6 | | @@ -102,7 +102,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | -| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] | +| FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 065263bf..5f05e544 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -99,9 +99,9 @@ note = "format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD vid [[item]] id = "FILE-215-14" target = "User swatch model with ACO/ASE import and export" -status = "missing" +status = "done" issue = 215 -note = "panels.rs:954 const SWATCHES: [[u8; 3]; 40]" +note = "engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches" [[item]] id = "FILE-215-15"