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]
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 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")))

View File

@@ -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);
}
}
}

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 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"));

View File

@@ -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");