Swatches: user swatch model with .aco/.ase import and export (FILE-215-14, part of #215) (#886)

- photocraft-psd: clean-room .aco (v1/v2, UTF-16 names, RGB/HSB/CMYK/Lab/Gray at 16 bits,
  other spaces kept verbatim) and .ase (groups, global/spot/process, RGB/CMYK/LAB/Gray floats,
  unknown models kept) readers and writers; byte-stable round trips, hostile input -> Err.
- engine: presets::swatches (colours in their own model, display/foreground conversion through
  photocraft-cms working spaces, persisted with the prefs, default 40 colours in 4 groups) and
  swatch_cmds (swatches.list/add/delete/rename/move/newGroup/renameGroup/deleteGroup/use/reset/
  import/export) with graceful-failure tests.
- ui: swatches_ui.rs replaces the const grid (groups, tooltips, click = foreground and selected
  type, Alt/Ctrl-click = background, rename/delete/move, panel menu Import/Replace/Export/Reset),
  Color Picker "Add to Swatches", .aco/.ase via File > Open and drag-and-drop; strings translated
  in every complete catalog.
- Scorecard FILE-215-14 -> done.

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
ljkx 2026-10-09 02:43:01 +02:00 committed by GitHub
parent bf8d52ca74
commit 5bef14dbdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2825 additions and 132 deletions

View File

@ -13,10 +13,10 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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";

View File

@ -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<String>)> {
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<String>)> = 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) => {

View File

@ -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, &params).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, &params).is_ok(), "{id}: {params}");
}

View File

@ -1036,6 +1036,7 @@ fn build() -> Vec<CommandSpec> {
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());

View File

@ -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;

View File

@ -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<String, Value>,
/// Swatches panel library.
pub swatches: Vec<Group<swatches::Swatch>>,
/// 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<Vec<crate::edit_menu_cmds::CustomShape>>,
#[serde(default)]
layer_defaults: Option<BTreeMap<String, Value>>,
swatches: Option<Vec<Group<swatches::Swatch>>>,
}
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;
}

View File

@ -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<SwatchKind> {
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<f64> {
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<SwatchColor, String> {
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<f32> = 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::<Result<_, _>>()?;
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<Arc<Transform>>,
lab: Option<Arc<Transform>>,
gray: Option<Arc<Transform>>,
}
fn working(s: Option<&Session>, mode: ColorMode) -> Arc<Profile> {
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<Arc<Transform>>, 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<Group<Swatch>> {
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<Swatch> {
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::<usize>(), 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<Swatch> = serde_json::from_value(v).unwrap();
assert_eq!(back, sw);
}
}

View File

@ -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<Group<Swatch>>) {
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<Swatch>]) -> 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<Option<String>> {
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<Swatch>], 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<usize> {
s.presets.swatches.iter().position(|g| g.name == name).ok_or_else(|| bad(cmd, format!("no swatch group \"{name}\"")))
}
fn unique_group(groups: &[Group<Swatch>], 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<Value> {
let conv = SwatchConv::new(s);
let groups: Vec<Value> =
s.presets.swatches.iter().map(|g| json!({"name": g.name, "swatches": g.items.iter().map(|sw| swatch_json(&conv, sw)).collect::<Vec<_>>()})).collect();
Ok(json!({"groups": groups, "count": total(&s.presets.swatches)}))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Swatch> = 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Format> {
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> {
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<SwatchColor> {
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<AseColor> {
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<Group<Swatch>>, Vec<String>), String> {
let mut warnings = Vec::new();
let mut out: Vec<Group<Swatch>> = 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<Swatch> {
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<Swatch> = 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<Swatch>], format: Format) -> std::result::Result<(Vec<u8>, usize, Vec<String>), String> {
let mut warnings = Vec::new();
match format {
Format::Aco => {
let swatches: Vec<AcoSwatch> =
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<AseSwatch> = 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<Value> {
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<Group<Swatch>> = 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<String, usize> = std::collections::HashMap::new();
let mut taken: std::collections::HashSet<String> = 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<Value> {
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<Group<Swatch>> = 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<CommandSpec> {
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;

View File

@ -0,0 +1,291 @@
use super::*;
use photocraft_paint::tile::{b64_decode, b64_encode};
fn names(s: &Session) -> Vec<String> {
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<u8> {
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::<Vec<_>>(),
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);
}

299
crates/psd/src/aco.rs Normal file
View File

@ -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<AcoSwatch>,
/// Whether the file had a version 2 section (names).
pub has_names: bool,
}
fn read_record(r: &mut Reader<'_>) -> Result<AcoColor> {
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<String> {
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<Vec<AcoSwatch>> {
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<AcoFile> {
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<Vec<u8>> {
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<u16> = 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<AcoSwatch> {
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<_>>(), 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<u8> = (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);
}
}

415
crates/psd/src/ase.rs Normal file
View File

@ -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<u8>,
},
}
/// 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<AseSwatch>,
},
}
/// A parsed `.ase` file.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AseFile {
/// Entries in file order.
pub entries: Vec<AseEntry>,
}
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<f32> {
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<String> {
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<AseSwatch> {
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<AseFile> {
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<AseSwatch>)> = 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<u8>, name: &str) {
let units: Vec<u16> = 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<u8>, 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<u8>, s: &AseSwatch) -> Result<()> {
let mut b = Vec::with_capacity(64);
put_name(&mut b, &s.name);
let floats = |b: &mut Vec<u8>, 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<Vec<u8>> {
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<u8> = (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());
}
}

View File

@ -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;

View File

@ -268,6 +268,11 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) -> Option<bool> {
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<String, Value>, 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<String, Value>) {
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<String, Value>) -> Result<Value, String> {
let target = f.get("__colorPicker").and_then(Value::as_str).unwrap_or("foreground");

View File

@ -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),

View File

@ -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();

View File

@ -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:

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

View File

@ -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:

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

View File

@ -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:

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

View File

@ -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 :

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

View File

@ -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:

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

View File

@ -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

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

View File

@ -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: チャンネル:

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

View File

@ -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: 채널:

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

View File

@ -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

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

View File

@ -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:

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

View File

@ -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: Каналы:

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

View File

@ -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: 通道:

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

View File

@ -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: 色版:

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

View File

@ -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;

View File

@ -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()
});

View File

@ -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<Result<
let (cmd, what) = match ext(name).as_str() {
"abr" => ("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"));
}

View File

@ -106,7 +106,7 @@ pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
float_flag(&mut p, id).map(|f| *f)
}
fn run(app: &mut PhotocraftApp, id: &str, p: Value) -> Option<Value> {
pub(crate) fn run(app: &mut PhotocraftApp, id: &str, p: Value) -> Option<Value> {
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<ItemView>,
pub(crate) struct GroupView {
pub(crate) name: String,
pub(crate) items: Vec<ItemView>,
}
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,

View File

@ -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<Vec<Vec<Color32>>> {
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::<Arc<Vec<Vec<Color32>>>>(key)) {
return v;
}
let conv = photocraft_engine::presets::swatches::SwatchConv::new(&app.session);
let v: Arc<Vec<Vec<Color32>>> = 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>(egui::Id::new("swatch-colors-key")) {
d.remove::<Arc<Vec<Vec<Color32>>>>(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<GroupView> = 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<Value, String> {
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<String> = 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<Value, String> {
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<Value, String> {
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<String, Value>) {
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<Mutex<Vec<(String, Vec<u8>)>>>;
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"));
}
}

View File

@ -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 |

View File

@ -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"