- 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:
parent
bf8d52ca74
commit
5bef14dbdc
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -258,10 +258,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
|
|||
|
||||
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
match id {
|
||||
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" => {
|
||||
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
|
||||
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
|
||||
params.get("data").is_none() && params.get("path").is_some()
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"swatches.export" => params.get("path").is_some(),
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
|
|
@ -506,6 +508,8 @@ mod tests {
|
|||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("plugin.reload", serde_json::json!({})),
|
||||
("plugin.install", serde_json::json!({"path": " "})),
|
||||
("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),
|
||||
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}");
|
||||
}
|
||||
|
|
@ -513,6 +517,8 @@ mod tests {
|
|||
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
|
||||
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
|
||||
("plugin.install", serde_json::json!({"data": "QUJD"})),
|
||||
("swatches.import", serde_json::json!({"data": "QUJD"})),
|
||||
("swatches.export", serde_json::json!({"format": "ase"})),
|
||||
] {
|
||||
assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue