new features

This commit is contained in:
2026-04-24 05:01:04 +02:00
parent d980755621
commit f330d02944
5 changed files with 95 additions and 59 deletions

View File

@@ -17,3 +17,9 @@ walkdir = "2"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
[profile.release]
opt-level = 3
strip = true
lto = true
codegen-units = 1

View File

@@ -5,7 +5,7 @@ use rayon::prelude::*;
use tracing::info; use tracing::info;
use crate::{ use crate::{
color_space::ColorSpace, color_space::{ColorSpace, ColorSpaceKind},
palette::{FilePaletteSource, Palette}, palette::{FilePaletteSource, Palette},
processor::{process_image, ProcessResult}, processor::{process_image, ProcessResult},
}; };
@@ -33,13 +33,8 @@ struct Cli {
)] )]
extensions: Vec<String>, extensions: Vec<String>,
#[arg( #[arg(short, long, default_value = "rgb")]
short, color_space: ColorSpaceKind,
long,
default_value = "rgb",
value_parser = clap::builder::PossibleValuesParser::new(crate::color_space::available_names())
)]
color_space: String,
} }
pub fn expand_inputs(inputs: &[PathBuf], extensions: &[String]) -> Vec<PathBuf> { pub fn expand_inputs(inputs: &[PathBuf], extensions: &[String]) -> Vec<PathBuf> {
@@ -161,8 +156,8 @@ pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let space = crate::color_space::from_name(&cli.color_space)?; let space = cli.color_space.into_space();
info!("Color space: {}", cli.color_space); info!("Color space: {:?}", cli.color_space);
info!("Loading palette from {:?}", cli.palette); info!("Loading palette from {:?}", cli.palette);
let palette = Palette::load(&FilePaletteSource(cli.palette.clone()))?; let palette = Palette::load(&FilePaletteSource(cli.palette.clone()))?;
@@ -185,7 +180,7 @@ pub fn run() -> anyhow::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::color_space; use crate::color_space::ColorSpaceKind;
struct NoopReporter; struct NoopReporter;
impl Reporter for NoopReporter { impl Reporter for NoopReporter {
@@ -195,7 +190,7 @@ mod tests {
#[test] #[test]
fn run_batch_collects_all_errors_without_aborting() { 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 palette = vec![[255u8, 0, 0, 255]];
let inputs = vec![ let inputs = vec![
std::path::PathBuf::from("/nonexistent/a.png"), std::path::PathBuf::from("/nonexistent/a.png"),
@@ -215,7 +210,7 @@ mod tests {
#[test] #[test]
fn run_batch_returns_one_outcome_per_input() { 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 palette = vec![[0u8, 0, 0, 255]];
let inputs: Vec<std::path::PathBuf> = (0..5) let inputs: Vec<std::path::PathBuf> = (0..5)
.map(|i| std::path::PathBuf::from(format!("/nonexistent/{i}.png"))) .map(|i| std::path::PathBuf::from(format!("/nonexistent/{i}.png")))

View File

@@ -63,57 +63,30 @@ impl ColorSpace for OklabSpace {
} }
} }
struct ColorSpaceEntry { #[derive(clap::ValueEnum, Debug, Clone, Copy)]
name: &'static str, pub enum ColorSpaceKind {
build: fn() -> Box<dyn ColorSpace>, Rgb,
Hsl,
Lab,
Oklab,
} }
static REGISTRY: &[ColorSpaceEntry] = &[ impl ColorSpaceKind {
ColorSpaceEntry { name: "rgb", build: || Box::new(RgbSpace) }, pub fn into_space(self) -> Box<dyn ColorSpace> {
ColorSpaceEntry { name: "hsl", build: || Box::new(HslSpace) }, match self {
ColorSpaceEntry { name: "lab", build: || Box::new(LabSpace) }, Self::Rgb => Box::new(RgbSpace),
ColorSpaceEntry { name: "oklab", build: || Box::new(OklabSpace) }, Self::Hsl => Box::new(HslSpace),
]; Self::Lab => Box::new(LabSpace),
Self::Oklab => Box::new(OklabSpace),
pub fn available_names() -> impl Iterator<Item = &'static str> { }
REGISTRY.iter().map(|e| e.name) }
} }
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn lab_distance_positive_for_different_colors() { fn lab_distance_positive_for_different_colors() {
let space = LabSpace; let space = LabSpace;
@@ -142,4 +115,21 @@ mod tests {
assert!(space.distance(&white, &black) > 0.0); assert!(space.distance(&white, &black) > 0.0);
assert!(space.distance(&white, &white) < 1e-10); 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);
}
}
} }

View File

@@ -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 content = std::str::from_utf8(bytes).context("Palette text is not valid UTF-8")?;
let mut colors = Vec::new(); let mut colors = Vec::new();
for line in content.lines() { for line in content.lines() {
let hex = line.trim().trim_start_matches('#'); let stripped = line.trim().trim_start_matches('#');
if hex.is_empty() { if stripped.is_empty() {
continue; 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 { if hex.len() == 6 || hex.len() == 8 {
let r = u8::from_str_radix(&hex[0..2], 16).context("Invalid hex color")?; 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")?; let g = u8::from_str_radix(&hex[2..4], 16).context("Invalid hex color")?;
@@ -127,10 +133,30 @@ mod tests {
#[test] #[test]
fn hex_skips_wrong_length_lines() { 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); 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] #[test]
fn hex_rejects_invalid_hex_digits() { fn hex_rejects_invalid_hex_digits() {
let result = Palette::load(&src("hex", "gggggg\n")); let result = Palette::load(&src("hex", "gggggg\n"));

View File

@@ -24,10 +24,18 @@ pub fn remap_pixels(
if pixel[3] == 0 { if pixel[3] == 0 {
continue; 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 min_distance = f64::MAX;
let mut best_match: [u8; 4] = pixel.0; let mut best_match: [u8; 4] = pixel.0;
for palette_color in palette { 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 { if dist < min_distance {
min_distance = dist; min_distance = dist;
best_match = *palette_color; best_match = *palette_color;
@@ -110,6 +118,17 @@ mod tests {
assert_eq!(stats.pixels_changed, 0); 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] #[test]
fn process_image_dry_run_does_not_write() { fn process_image_dry_run_does_not_write() {
let tmp = std::env::temp_dir().join("ppc_proc_test_input.png"); let tmp = std::env::temp_dir().join("ppc_proc_test_input.png");