new features
This commit is contained in:
@@ -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
|
||||||
|
|||||||
21
src/cli.rs
21
src/cli.rs
@@ -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")))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user