new features
This commit is contained in:
@@ -17,3 +17,9 @@ walkdir = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
21
src/cli.rs
21
src/cli.rs
@@ -5,7 +5,7 @@ use rayon::prelude::*;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
color_space::ColorSpace,
|
||||
color_space::{ColorSpace, ColorSpaceKind},
|
||||
palette::{FilePaletteSource, Palette},
|
||||
processor::{process_image, ProcessResult},
|
||||
};
|
||||
@@ -33,13 +33,8 @@ struct Cli {
|
||||
)]
|
||||
extensions: Vec<String>,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "rgb",
|
||||
value_parser = clap::builder::PossibleValuesParser::new(crate::color_space::available_names())
|
||||
)]
|
||||
color_space: String,
|
||||
#[arg(short, long, default_value = "rgb")]
|
||||
color_space: ColorSpaceKind,
|
||||
}
|
||||
|
||||
pub fn expand_inputs(inputs: &[PathBuf], extensions: &[String]) -> Vec<PathBuf> {
|
||||
@@ -161,8 +156,8 @@ pub fn run() -> anyhow::Result<()> {
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let space = crate::color_space::from_name(&cli.color_space)?;
|
||||
info!("Color space: {}", cli.color_space);
|
||||
let space = cli.color_space.into_space();
|
||||
info!("Color space: {:?}", cli.color_space);
|
||||
|
||||
info!("Loading palette from {:?}", cli.palette);
|
||||
let palette = Palette::load(&FilePaletteSource(cli.palette.clone()))?;
|
||||
@@ -185,7 +180,7 @@ pub fn run() -> anyhow::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::color_space;
|
||||
use crate::color_space::ColorSpaceKind;
|
||||
|
||||
struct NoopReporter;
|
||||
impl Reporter for NoopReporter {
|
||||
@@ -195,7 +190,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_batch_collects_all_errors_without_aborting() {
|
||||
let space = color_space::from_name("rgb").unwrap();
|
||||
let space = ColorSpaceKind::Rgb.into_space();
|
||||
let palette = vec![[255u8, 0, 0, 255]];
|
||||
let inputs = vec![
|
||||
std::path::PathBuf::from("/nonexistent/a.png"),
|
||||
@@ -215,7 +210,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_batch_returns_one_outcome_per_input() {
|
||||
let space = color_space::from_name("rgb").unwrap();
|
||||
let space = ColorSpaceKind::Rgb.into_space();
|
||||
let palette = vec![[0u8, 0, 0, 255]];
|
||||
let inputs: Vec<std::path::PathBuf> = (0..5)
|
||||
.map(|i| std::path::PathBuf::from(format!("/nonexistent/{i}.png")))
|
||||
|
||||
@@ -63,57 +63,30 @@ impl ColorSpace for OklabSpace {
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorSpaceEntry {
|
||||
name: &'static str,
|
||||
build: fn() -> Box<dyn ColorSpace>,
|
||||
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
|
||||
pub enum ColorSpaceKind {
|
||||
Rgb,
|
||||
Hsl,
|
||||
Lab,
|
||||
Oklab,
|
||||
}
|
||||
|
||||
static REGISTRY: &[ColorSpaceEntry] = &[
|
||||
ColorSpaceEntry { name: "rgb", build: || Box::new(RgbSpace) },
|
||||
ColorSpaceEntry { name: "hsl", build: || Box::new(HslSpace) },
|
||||
ColorSpaceEntry { name: "lab", build: || Box::new(LabSpace) },
|
||||
ColorSpaceEntry { name: "oklab", build: || Box::new(OklabSpace) },
|
||||
];
|
||||
|
||||
pub fn available_names() -> impl Iterator<Item = &'static str> {
|
||||
REGISTRY.iter().map(|e| e.name)
|
||||
impl ColorSpaceKind {
|
||||
pub fn into_space(self) -> Box<dyn ColorSpace> {
|
||||
match self {
|
||||
Self::Rgb => Box::new(RgbSpace),
|
||||
Self::Hsl => Box::new(HslSpace),
|
||||
Self::Lab => Box::new(LabSpace),
|
||||
Self::Oklab => Box::new(OklabSpace),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> anyhow::Result<Box<dyn ColorSpace>> {
|
||||
REGISTRY
|
||||
.iter()
|
||||
.find(|e| e.name == name)
|
||||
.map(|e| (e.build)())
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown color space: {name}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_name_rgb_returns_working_space() {
|
||||
let space = from_name("rgb").unwrap();
|
||||
let white = [255u8, 255, 255, 255];
|
||||
let black = [0u8, 0, 0, 255];
|
||||
assert!(space.distance(&white, &black) > 0.0);
|
||||
assert_eq!(space.distance(&white, &white), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_name_unknown_is_err() {
|
||||
assert!(from_name("xyz").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_coherent() {
|
||||
let names: Vec<_> = available_names().collect();
|
||||
assert_eq!(names.len(), 4, "expected 4 registered color spaces");
|
||||
for name in &names {
|
||||
assert!(from_name(name).is_ok(), "from_name failed for {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_distance_positive_for_different_colors() {
|
||||
let space = LabSpace;
|
||||
@@ -142,4 +115,21 @@ mod tests {
|
||||
assert!(space.distance(&white, &black) > 0.0);
|
||||
assert!(space.distance(&white, &white) < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_produce_working_spaces() {
|
||||
let variants = [
|
||||
ColorSpaceKind::Rgb,
|
||||
ColorSpaceKind::Hsl,
|
||||
ColorSpaceKind::Lab,
|
||||
ColorSpaceKind::Oklab,
|
||||
];
|
||||
let white = [255u8, 255, 255, 255];
|
||||
let black = [0u8, 0, 0, 255];
|
||||
for variant in variants {
|
||||
let space = variant.into_space();
|
||||
assert!(space.distance(&white, &black) > 0.0);
|
||||
assert!(space.distance(&white, &white) < 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,16 @@ fn parse_hex_text(bytes: &[u8]) -> anyhow::Result<Vec<[u8; 4]>> {
|
||||
let content = std::str::from_utf8(bytes).context("Palette text is not valid UTF-8")?;
|
||||
let mut colors = Vec::new();
|
||||
for line in content.lines() {
|
||||
let hex = line.trim().trim_start_matches('#');
|
||||
if hex.is_empty() {
|
||||
let stripped = line.trim().trim_start_matches('#');
|
||||
if stripped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let expanded: String = if stripped.len() == 3 || stripped.len() == 4 {
|
||||
stripped.chars().flat_map(|c| [c, c]).collect()
|
||||
} else {
|
||||
stripped.to_string()
|
||||
};
|
||||
let hex = expanded.as_str();
|
||||
if hex.len() == 6 || hex.len() == 8 {
|
||||
let r = u8::from_str_radix(&hex[0..2], 16).context("Invalid hex color")?;
|
||||
let g = u8::from_str_radix(&hex[2..4], 16).context("Invalid hex color")?;
|
||||
@@ -127,10 +133,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hex_skips_wrong_length_lines() {
|
||||
let p = Palette::load(&src("hex", "#fff\n#ff0000\n")).unwrap();
|
||||
// 5-char hex is invalid after shorthand expansion
|
||||
let p = Palette::load(&src("hex", "#fffff\n#ff0000\n")).unwrap();
|
||||
assert_eq!(p.colors().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_expands_three_char_shorthand() {
|
||||
let p = Palette::load(&src("hex", "#FFF\n")).unwrap();
|
||||
assert_eq!(p.colors(), &[[255u8, 255, 255, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_expands_three_char_shorthand_lowercase() {
|
||||
let p = Palette::load(&src("hex", "#fff\n")).unwrap();
|
||||
assert_eq!(p.colors(), &[[255u8, 255, 255, 255]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_expands_four_char_shorthand_with_alpha() {
|
||||
// #F00A → FF0000AA → [255, 0, 0, 170]
|
||||
let p = Palette::load(&src("hex", "#F00A\n")).unwrap();
|
||||
assert_eq!(p.colors(), &[[255u8, 0, 0, 170]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejects_invalid_hex_digits() {
|
||||
let result = Palette::load(&src("hex", "gggggg\n"));
|
||||
|
||||
@@ -24,10 +24,18 @@ pub fn remap_pixels(
|
||||
if pixel[3] == 0 {
|
||||
continue;
|
||||
}
|
||||
// Premul is exact for RGB; non-linear spaces (Lab, Oklab, HSL) treat premul'd bytes as straight sRGB.
|
||||
let alpha = pixel[3];
|
||||
let premul = [
|
||||
(pixel[0] as u16 * alpha as u16 / 255) as u8,
|
||||
(pixel[1] as u16 * alpha as u16 / 255) as u8,
|
||||
(pixel[2] as u16 * alpha as u16 / 255) as u8,
|
||||
alpha,
|
||||
];
|
||||
let mut min_distance = f64::MAX;
|
||||
let mut best_match: [u8; 4] = pixel.0;
|
||||
for palette_color in palette {
|
||||
let dist = color_space.distance(&pixel.0, palette_color);
|
||||
let dist = color_space.distance(&premul, palette_color);
|
||||
if dist < min_distance {
|
||||
min_distance = dist;
|
||||
best_match = *palette_color;
|
||||
@@ -110,6 +118,17 @@ mod tests {
|
||||
assert_eq!(stats.pixels_changed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semi_transparent_pixel_uses_premultiplied_distance() {
|
||||
// [255, 0, 0, 128] premultiplied → [128, 0, 0]
|
||||
// palette: bright-red [255,0,0] vs dark-red [128,0,0]
|
||||
// premul distance to dark-red = 0, so dark-red wins; original alpha preserved
|
||||
let mut img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255u8, 0, 0, 128]));
|
||||
let palette = vec![[255u8, 0, 0, 255], [128u8, 0, 0, 255]];
|
||||
remap_pixels(&mut img, &palette, &RgbSpace);
|
||||
assert_eq!(img.get_pixel(0, 0).0, [128, 0, 0, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_image_dry_run_does_not_write() {
|
||||
let tmp = std::env::temp_dir().join("ppc_proc_test_input.png");
|
||||
|
||||
Reference in New Issue
Block a user