diff --git a/Cargo.toml b/Cargo.toml index a0725b0..2deb827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,9 @@ walkdir = "2" [dev-dependencies] tempfile = "3" + +[profile.release] +opt-level = 3 +strip = true +lto = true +codegen-units = 1 diff --git a/src/cli.rs b/src/cli.rs index 440c6a6..644da49 100644 --- a/src/cli.rs +++ b/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, - #[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 { @@ -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 = (0..5) .map(|i| std::path::PathBuf::from(format!("/nonexistent/{i}.png"))) diff --git a/src/color_space.rs b/src/color_space.rs index d079919..5144efa 100644 --- a/src/color_space.rs +++ b/src/color_space.rs @@ -63,57 +63,30 @@ impl ColorSpace for OklabSpace { } } -struct ColorSpaceEntry { - name: &'static str, - build: fn() -> Box, +#[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 { - REGISTRY.iter().map(|e| e.name) +impl ColorSpaceKind { + pub fn into_space(self) -> Box { + 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> { - 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); + } + } } diff --git a/src/palette.rs b/src/palette.rs index f269438..fd3bd83 100644 --- a/src/palette.rs +++ b/src/palette.rs @@ -27,10 +27,16 @@ fn parse_hex_text(bytes: &[u8]) -> anyhow::Result> { 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")); diff --git a/src/processor.rs b/src/processor.rs index 03b6367..766c45f 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -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");