mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-psd: the reading rules for .ase swatch files (#984)
The rules VectorCraft's swatch reader applies on top of the format
(crates/color/src/palette_io.rs at storytold/vectorcraft 7f3a046b), as
separate functions next to `ase::parse`, which still keeps what the file
says:
- `AseColor::lab_lightness`: Lab lightness is stored as L*/100, but some
writers store L* itself, which shows as a value above 1;
- `AseColor::value_name`: names for unnamed colours, as new swatches are
named ("C=10 M=20 Y=30 K=0", "R=255 G=128 B=0", "Gray K=40",
"L=52 a=70 b=-30");
- `UniqueNames` and `AseFile::apply_names`: trimmed names, "Color Group"
for an unnamed group, and a number for a name already taken ("Black 2"),
shared by colours and groups. `UniqueNames::reserve` lets an application
number a file's names against its own library;
- `ase::sniff`: a swatch exchange file, not an Aseprite sprite with the
same extension.
These are the rules craft-libs#140 asks for.
This commit is contained in:
1 parent
8c6dbccb8b
commit
45de2ed928
1 file changed
+194
@@ -14,6 +14,16 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! [`parse`] and [`write`] keep what the file says. The reading rules an application applies
|
||||
//! when it shows a file's swatches are separate, so each can choose them:
|
||||
//! [`AseColor::lab_lightness`] (some writers store L* itself), [`AseColor::value_name`] (names
|
||||
//! for unnamed colours, as new swatches are named) and [`AseFile::apply_names`] with
|
||||
//! [`UniqueNames`] (trimmed names, "Color Group" for an unnamed group, and a number for a name
|
||||
//! already taken: "Black 2"). [`sniff`] tells a swatch exchange file from an Aseprite sprite,
|
||||
//! which uses the same extension. The rules come from VectorCraft's swatch reader.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::error::{PsdError, Result};
|
||||
use crate::io::{Reader, WriteExt};
|
||||
@@ -129,6 +139,114 @@ impl AseFile {
|
||||
}
|
||||
}
|
||||
|
||||
impl AseColor {
|
||||
/// A stored Lab lightness as L* (0..100). The format stores L*/100; some writers store L*
|
||||
/// itself, which shows as a value above 1. Out-of-range values are clamped.
|
||||
pub fn lab_lightness(stored: f32) -> f32 {
|
||||
if stored > 1.0 { stored.min(100.0) } else { stored.clamp(0.0, 1.0) * 100.0 }
|
||||
}
|
||||
|
||||
/// The colour's values in its own model, as new swatches are named: "R=255 G=128 B=0",
|
||||
/// "C=10 M=20 Y=30 K=0" (percent), "Gray K=40" (percent of ink), "L=52 a=70 b=-30".
|
||||
/// Components are clamped to their ranges first. `None` for another colour model.
|
||||
pub fn value_name(&self) -> Option<String> {
|
||||
let unit = |v: f32| v.clamp(0.0, 1.0);
|
||||
// `+ 0.0` turns a rounded -0 into 0.
|
||||
let pct = |v: f32| (unit(v) * 100.0).round() + 0.0;
|
||||
let byte = |v: f32| (unit(v) * 255.0).round() + 0.0;
|
||||
Some(match self {
|
||||
AseColor::Rgb([r, g, b]) => format!("R={} G={} B={}", byte(*r), byte(*g), byte(*b)),
|
||||
AseColor::Cmyk([c, m, y, k]) => format!("C={} M={} Y={} K={}", pct(*c), pct(*m), pct(*y), pct(*k)),
|
||||
// A gray level of 1 is white: no ink.
|
||||
AseColor::Gray(level) => format!("Gray K={}", pct(1.0 - unit(*level))),
|
||||
AseColor::Lab([l, a, b]) => {
|
||||
let ab = |v: f32| v.clamp(-128.0, 127.0).round() + 0.0;
|
||||
format!("L={} a={} b={}", Self::lab_lightness(*l).round() + 0.0, ab(*a), ab(*b))
|
||||
}
|
||||
AseColor::Other { .. } => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when `head` (the first bytes of a file) starts like a swatch exchange file. Aseprite
|
||||
/// sprites use the same `.ase` extension and start with a size and the magic `E0 A5` instead.
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
head.starts_with(SIGNATURE)
|
||||
}
|
||||
|
||||
/// The swatch and group names a reader has handed out. Swatches and groups share one set of
|
||||
/// names, as in a document; a name already taken gets the first free number ("Black 2").
|
||||
/// [`Self::reserve`] marks names that exist before the file is read (an application's own
|
||||
/// library), so the file's names are numbered against them.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UniqueNames {
|
||||
taken: HashSet<String>,
|
||||
/// The last number tried for each repeated name, so repeats don't search the numbers used
|
||||
/// before.
|
||||
last: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl UniqueNames {
|
||||
/// No names taken.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Marks `name` as taken.
|
||||
pub fn reserve(&mut self, name: impl Into<String>) {
|
||||
self.taken.insert(name.into());
|
||||
}
|
||||
|
||||
/// `base` when it is free, else `base` with the first free number from 2 up. The result is
|
||||
/// taken from then on.
|
||||
pub fn unique(&mut self, base: String) -> String {
|
||||
if self.taken.insert(base.clone()) {
|
||||
return base;
|
||||
}
|
||||
// Each pass tries the next number, and only finitely many names are taken.
|
||||
let n = self.last.entry(base.clone()).or_insert(1);
|
||||
loop {
|
||||
*n += 1;
|
||||
let name = format!("{base} {n}");
|
||||
if self.taken.insert(name.clone()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of a group that has none.
|
||||
pub const UNNAMED_GROUP: &str = "Color Group";
|
||||
|
||||
impl AseFile {
|
||||
/// Applies the naming rules, in file order: names are trimmed; an unnamed colour is named
|
||||
/// by its values ([`AseColor::value_name`]; one in another colour model stays unnamed) and
|
||||
/// an unnamed group [`UNNAMED_GROUP`]; then every name goes through `names`, which numbers
|
||||
/// a name already taken. Colours and groups share `names`. The colours are not changed.
|
||||
pub fn apply_names(&mut self, names: &mut UniqueNames) {
|
||||
let mut name_swatch = |s: &mut AseSwatch| {
|
||||
let trimmed = s.name.trim();
|
||||
let base = if trimmed.is_empty() { s.color.value_name() } else { Some(trimmed.to_string()) };
|
||||
s.name = base.map_or_else(String::new, |b| names.unique(b));
|
||||
};
|
||||
for e in &mut self.entries {
|
||||
match e {
|
||||
AseEntry::Swatch(s) => name_swatch(s),
|
||||
AseEntry::Group { name, swatches } => {
|
||||
// The group's name comes before its colours', as they appear in the file.
|
||||
let trimmed = name.trim();
|
||||
let base = if trimmed.is_empty() { UNNAMED_GROUP.to_string() } else { trimmed.to_string() };
|
||||
// `name_swatch` holds `names`: take the group's through it by a placeholder swatch.
|
||||
let mut holder = AseSwatch { name: base, color: AseColor::Gray(0.0), kind: AseKind::Process };
|
||||
name_swatch(&mut holder);
|
||||
*name = holder.name;
|
||||
swatches.iter_mut().for_each(&mut name_swatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn f32_be(r: &mut Reader<'_>) -> Result<f32> {
|
||||
let v = f32::from_bits(r.u32()?);
|
||||
// Non-finite components would poison every conversion downstream.
|
||||
@@ -427,6 +545,82 @@ mod tests {
|
||||
assert!(write(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_tells_a_swatch_file_from_an_aseprite_sprite() {
|
||||
assert!(sniff(&write(&sample()).unwrap()[..4]));
|
||||
// An Aseprite header: file size (little-endian), then the magic 0xA5E0.
|
||||
assert!(!sniff(&[0x80, 0x01, 0, 0, 0xE0, 0xA5, 1, 0]));
|
||||
assert!(!sniff(b"ASE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_lightness_reads_both_l_star_and_the_fraction_of_100() {
|
||||
assert_eq!(AseColor::lab_lightness(0.52), 52.0);
|
||||
assert_eq!(AseColor::lab_lightness(52.0), 52.0);
|
||||
assert_eq!(AseColor::lab_lightness(1.0), 100.0);
|
||||
assert_eq!(AseColor::lab_lightness(150.0), 100.0);
|
||||
assert_eq!(AseColor::lab_lightness(-3.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_names_follow_the_new_swatch_names() {
|
||||
assert_eq!(AseColor::Rgb([1.0, 0.5, 0.0]).value_name().as_deref(), Some("R=255 G=128 B=0"));
|
||||
assert_eq!(AseColor::Cmyk([0.1, 0.2, 0.3, 0.0]).value_name().as_deref(), Some("C=10 M=20 Y=30 K=0"));
|
||||
assert_eq!(AseColor::Gray(0.6).value_name().as_deref(), Some("Gray K=40"));
|
||||
assert_eq!(AseColor::Lab([52.0, 70.2, -30.4]).value_name().as_deref(), Some("L=52 a=70 b=-30"));
|
||||
assert_eq!(AseColor::Lab([0.52, -0.2, 200.0]).value_name().as_deref(), Some("L=52 a=0 b=127"));
|
||||
assert_eq!(AseColor::Rgb([-1.0, 2.0, -0.001]).value_name().as_deref(), Some("R=0 G=255 B=0"));
|
||||
assert_eq!(AseColor::Other { model: *b"HSV ", data: vec![] }.value_name(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_names_numbers_repeats_names_the_unnamed_and_trims() {
|
||||
let mut f = AseFile {
|
||||
entries: vec![
|
||||
AseEntry::Swatch(sw("Black", AseColor::Gray(0.0), AseKind::Process)),
|
||||
AseEntry::Swatch(sw(" Black ", AseColor::Gray(0.0), AseKind::Process)),
|
||||
AseEntry::Swatch(sw("", AseColor::Rgb([1.0, 0.0, 0.0]), AseKind::Process)),
|
||||
AseEntry::Swatch(sw(" ", AseColor::Other { model: *b"HSV ", data: vec![] }, AseKind::Process)),
|
||||
AseEntry::Group {
|
||||
name: "".into(),
|
||||
swatches: vec![sw("Black", AseColor::Gray(0.0), AseKind::Spot), sw("Color Group", AseColor::Gray(1.0), AseKind::Process)],
|
||||
},
|
||||
AseEntry::Group { name: " ".into(), swatches: vec![] },
|
||||
],
|
||||
};
|
||||
let colors = |f: &AseFile| {
|
||||
f.entries
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
AseEntry::Swatch(s) => s.name.clone(),
|
||||
AseEntry::Group { name, swatches } => {
|
||||
format!("{name}: {}", swatches.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut plain = f.clone();
|
||||
plain.apply_names(&mut UniqueNames::new());
|
||||
assert_eq!(colors(&plain), ["Black", "Black 2", "R=255 G=0 B=0", "", "Color Group: Black 3, Color Group 2", "Color Group 3: "]);
|
||||
// Numbered against names that exist before the file is read.
|
||||
let mut library = UniqueNames::new();
|
||||
library.reserve("Black");
|
||||
f.apply_names(&mut library);
|
||||
assert_eq!(colors(&f)[..2], ["Black 2", "Black 3"]);
|
||||
// The colours are not changed.
|
||||
assert_eq!(f.swatch_count(), 6);
|
||||
}
|
||||
|
||||
/// A file of the same name repeated up to the cap names every colour once, without
|
||||
/// searching the numbers already used.
|
||||
#[test]
|
||||
fn many_repeats_are_numbered_in_order() {
|
||||
let mut f = AseFile { entries: (0..MAX_SWATCHES).map(|_| AseEntry::Swatch(sw("Same", AseColor::Gray(0.0), AseKind::Process))).collect() };
|
||||
f.apply_names(&mut UniqueNames::new());
|
||||
let AseEntry::Swatch(last) = &f.entries[MAX_SWATCHES - 1] else { panic!() };
|
||||
assert_eq!(last.name, format!("Same {MAX_SWATCHES}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_groups() {
|
||||
let g = AseEntry::Group { name: "g".into(), swatches: vec![] };
|
||||
|
||||
Reference in new issue
Block a user