3use std::io::IsTerminal;
7use palette::{FromColor, Lab, Srgb};
9mod find_dominant_colors;
13#[derive(Parser, Debug)]
14#[command(version, about = "Find the dominant colours in an image", long_about=None)]
16 /// Path to the image to inspect
19 /// How many colours to find
20 #[arg(long = "max-colours", default_value_t = 5)]
23 /// Find a single colour that will look best against this background
24 #[arg(long = "best-against-bg")]
25 background: Option<Srgb<u8>>,
27 /// Just print the hex values, not colour previews
28 #[arg(long = "no-palette")]
33 let cli = Cli::parse();
35 let lab: Vec<Lab> = match get_image_colors::get_image_colors(&cli.path) {
39 std::process::exit(1);
43 assert!(lab.len() > 0);
45 let dominant_colors = find_dominant_colors::find_dominant_colors(&lab, cli.max_colours);
47 let selected_colors = match cli.background {
48 Some(bg) => find_dominant_colors::choose_best_color_for_bg(dominant_colors.clone(), &bg),
49 None => dominant_colors,
52 let rgb_colors = selected_colors
54 .map(|c| Srgb::from_color(*c).into_format())
55 .collect::<Vec<Srgb<u8>>>();
57 // Should we print with colours in the terminal, or just sent text?
59 // When I created this tool, I had a `--no-palette` flag to suppress the
60 // terminal colours, but I've since realised that I can look for the
61 // presence of a TTY and disable colours if we're not in a terminal,
62 // even if the user hasn't passed `--no-palette`.
64 // I'm keeping the old flag for backwards compatibility, but I might
65 // retire it in a future v2 update.
67 // Note: because of the difficulty of simulating a TTY in automated tests,
68 // this isn't tested properly -- but I'll notice quickly if this breaks!
69 let include_bg_color = if cli.no_palette {
71 } else if std::io::stdout().is_terminal() {
78 printing::print_color(c, &cli.background, include_bg_color);
84 use assert_cmd::Command;
85 use predicates::prelude::*;
87 // Note: for the purposes of these tests, I mostly trust the k-means code
88 // provided by the external library.
91 fn it_prints_the_colour() {
92 Command::cargo_bin("dominant_colours")
94 .arg("./src/tests/red.png")
102 fn it_can_look_at_png_images() {
103 assert_gets_colours_from_image("./src/tests/red.png", "#fe0000\n");
107 fn it_can_look_at_jpeg_images() {
108 assert_gets_colours_from_image("./src/tests/black.jpg", "#000000\n");
112 fn it_can_look_at_static_gif_images() {
113 assert_gets_colours_from_image("./src/tests/yellow.gif", "#fffb00\n");
117 fn it_can_look_at_tiff_images() {
118 assert_gets_colours_from_image("./src/tests/green.tiff", "#04ff02\n");
122 fn it_omits_the_escape_codes_with_no_palette() {
123 assert_gets_colours_from_image("./src/tests/red.png", "#fe0000\n");
127 fn it_defaults_to_five_colours() {
128 let has_five_lines = predicate::str::is_match(r"^(#[a-f0-9]{6}\n){5}$").unwrap();
130 Command::cargo_bin("dominant_colours")
132 .arg("./src/tests/noise.jpg")
135 .stdout(has_five_lines)
140 fn it_lets_you_choose_the_max_colours() {
141 let has_eight_lines = predicate::str::is_match(r"^(#[a-f0-9]{6}\n){8}$").unwrap();
143 Command::cargo_bin("dominant_colours")
145 .args(&["./src/tests/noise.jpg", "--max-colours=8"])
148 .stdout(has_eight_lines)
152 // The image created in the next two tests was created with the
153 // following command:
158 // -dispose previous \
159 // red.png blue.png \
160 // red.png blue.png \
161 // red.png blue.png \
162 // red.png blue.png \
163 // animated_squares.gif
165 // It creates an animated GIF that has alternating red/blue frames.
168 fn it_looks_at_multiple_frames_in_an_animated_gif() {
169 assert_gets_colours_from_image("./src/tests/animated_squares.gif", "#0200ff\n#ff0000\n");
173 fn it_looks_at_multiple_frames_in_an_animated_gif_uppercase() {
174 assert_gets_colours_from_image(
175 "./src/tests/animated_upper_squares.GIF",
176 "#0200ff\n#ff0000\n",
180 // This is an animated WebP that has alternating red/blue frames.
182 // It needs to look at multiple frames to see both colours.
184 fn it_looks_at_multiple_frames_in_an_animated_webp() {
185 assert_gets_colours_from_image(
186 "./src/tests/animated_squares.webp",
187 "#0200ff\n#ff0100\n#ff0002\n",
191 /// Get the dominant colours for an image, and check it succeeds
192 /// with the given stdout.
193 fn assert_gets_colours_from_image(
195 expected_stdout: &str,
196 ) -> assert_cmd::assert::Assert {
197 Command::cargo_bin("dominant_colours")
202 .stdout(predicate::eq(expected_stdout))
207 fn it_fails_if_you_pass_an_invalid_max_colours() {
208 Command::cargo_bin("dominant_colours")
210 .args(&["./src/tests/red.png", "--max-colours=NaN"])
215 .stderr("error: invalid value 'NaN' for '--max-colours <MAX_COLOURS>': invalid digit found in string\n\nFor more information, try '--help'.\n");
219 fn it_fails_if_you_pass_an_nonexistent_file() {
220 assert_file_fails_with_error(
221 "./doesnotexist.jpg",
222 "No such file or directory (os error 2)\n",
227 fn it_fails_if_you_pass_an_nonexistent_gif() {
228 assert_file_fails_with_error(
229 "./doesnotexist.gif",
230 "No such file or directory (os error 2)\n",
235 fn it_fails_if_you_pass_a_non_image_file() {
236 assert_file_fails_with_error(
238 "Unable to determine image format from file extension\n",
243 fn it_fails_if_you_pass_an_unsupported_image_format() {
244 assert_file_fails_with_error(
245 "./src/tests/orange.heic",
246 "Unable to determine image format from file extension\n",
251 fn it_fails_if_you_pass_a_malformed_image() {
252 assert_file_fails_with_error(
253 "./src/tests/malformed.txt.png",
254 "Format error decoding Png: Invalid PNG signature.\n",
259 fn it_fails_if_you_pass_a_malformed_gif() {
260 assert_file_fails_with_error(
261 "./src/tests/malformed.txt.gif",
262 "Format error decoding Gif: malformed GIF header\n",
267 fn it_fails_if_you_pass_a_malformed_webp() {
268 assert_file_fails_with_error(
269 "./src/tests/malformed.txt.webp",
270 "Format error decoding WebP: Invalid Chunk header: [52, 49, 46, 46]\n",
275 fn it_fails_if_you_pass_a_path_without_a_file_extension() {
276 assert_file_fails_with_error(
277 "./src/tests/noextension",
278 "Path has no file extension, so could not determine image format\n",
282 /// Try to get the dominant colours for a file, and check it fails
283 /// with the given error message.
284 fn assert_file_fails_with_error(
286 expected_stderr: &str,
287 ) -> assert_cmd::assert::Assert {
288 Command::cargo_bin("dominant_colours")
295 .stderr(predicate::eq(expected_stderr))
299 fn it_chooses_the_right_color_for_a_dark_background() {
300 Command::cargo_bin("dominant_colours")
303 "src/tests/stripes.png",
305 "--best-against-bg=#222",
314 fn it_chooses_the_right_color_for_a_light_background() {
315 Command::cargo_bin("dominant_colours")
318 "src/tests/stripes.png",
320 "--best-against-bg=#fff",
329 fn it_prints_the_version() {
330 // Match strings like `dominant_colours 1.2.3`
331 let is_version_string =
332 predicate::str::is_match(r"^dominant_colours [0-9]+\.[0-9]+\.[0-9]+\n$").unwrap();
334 Command::cargo_bin("dominant_colours")
339 .stdout(is_version_string)