Merge pull request #78 from alexwlchan/bounding-box
- ID
06c8fdd- date
2025-12-09 20:57:02+00:00- author
Alex Chan <alex@alexwlchan.net>- parents
af76a5a,9ad976b- message
Merge pull request #78 from alexwlchan/bounding-box Allow passing both width/height to set a bounding box- changed files
6 files, 110 additions, 104 deletions
Changed files
CHANGELOG.md (348) → CHANGELOG.md (506)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c20333e..ce4db8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## v1.1.0 - 2025-12-09
+
+You can now pass `--width` and `--height` together, and the thumbnail will be the smallest image that fits inside that bounding box.
+
## v1.0.2 - 2025-09-08
Pay attention to EXIF orientation in input images, so thumbnails have the same rotation/reflection as the original.
Cargo.lock (34849) → Cargo.lock (34849)
diff --git a/Cargo.lock b/Cargo.lock
index 7442bb8..b7b7339 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -333,7 +333,7 @@ dependencies = [
[[package]]
name = "create_thumbnail"
-version = "1.0.2"
+version = "1.1.0"
dependencies = [
"assert_cmd",
"clap",
Cargo.toml (206) → Cargo.toml (206)
diff --git a/Cargo.toml b/Cargo.toml
index b96822d..42e6ceb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "create_thumbnail"
-version = "1.0.2"
+version = "1.1.0"
edition = "2021"
[dependencies]
README.md (1842) → README.md (1810)
diff --git a/README.md b/README.md
index a1c6974..496c4af 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@ It takes three arguments:
* Your original image;
* The directory where you're storing thumbnails;
-* The max allowed height or width of the thumbnail you want.
- You constrain in one dimension, and it resizes the image to fit, preserving the aspect ratio of the original image.
+* The max allowed height and/or width of the thumbnail you want.
+ It resizes the image to fit, preserving the aspect ratio of the original image.
The tool prints the path to the newly-created thumbnail.
Here are two examples:
src/get_thumbnail_dimensions.rs (3836) → src/get_thumbnail_dimensions.rs (5123)
diff --git a/src/get_thumbnail_dimensions.rs b/src/get_thumbnail_dimensions.rs
index ae71c1d..003d46f 100644
--- a/src/get_thumbnail_dimensions.rs
+++ b/src/get_thumbnail_dimensions.rs
@@ -5,9 +5,8 @@ use image::GenericImageView;
use crate::errors::ThumbnailError;
/// Represents the target dimensions of the thumbnail.
-///
-/// The user can choose a max width, or a max height, but not both.
pub enum TargetDimension {
+ BoundingBox(u32, u32),
MaxWidth(u32),
MaxHeight(u32),
}
@@ -15,10 +14,6 @@ pub enum TargetDimension {
/// Given the path to the original image and the target width/height,
/// calculate the dimensions of the new image.
///
-/// This function expects that exactly one of width/height will be
-/// specified, and then the image will be resized to be no larger
-/// than this dimension.
-///
/// If the image is smaller than the target dimensions, it will be
/// left as-is.
///
@@ -31,15 +26,38 @@ pub fn get_thumbnail_dimensions(
) -> Result<(u32, u32), ThumbnailError> {
let img = image::open(path)?;
- let (new_width, new_height) = match target {
- TargetDimension::MaxWidth(w) if w >= img.width() => img.dimensions(),
- TargetDimension::MaxHeight(h) if h >= img.height() => img.dimensions(),
-
- TargetDimension::MaxWidth(w) => (w, w * img.height() / img.width()),
- TargetDimension::MaxHeight(h) => (h * img.width() / img.height(), h),
- };
+ Ok(calculate_dimensions(img.dimensions(), target))
+}
- Ok((new_width, new_height))
+// Calculate the dimensions of the new image, given the original dimensions
+// and target dimensions.
+fn calculate_dimensions(dimensions: (u32, u32), target: TargetDimension) -> (u32, u32) {
+ let (img_w, img_h) = dimensions;
+
+ match target {
+ TargetDimension::MaxWidth(max_w) if max_w >= img_w => dimensions,
+ TargetDimension::MaxHeight(max_h) if max_h >= img_h => dimensions,
+
+ TargetDimension::MaxWidth(max_w) => (
+ max_w,
+ ((max_w as f64) * (img_h as f64) / (img_w as f64)).round() as u32,
+ ),
+ TargetDimension::MaxHeight(max_h) => (
+ ((max_h as f64) * (img_w as f64) / (img_h as f64)).round() as u32,
+ max_h,
+ ),
+
+ // The bounding box has a wider aspect ratio than the original image,
+ // so filter by height.
+ TargetDimension::BoundingBox(max_w, max_h)
+ if (max_w as f64) / (max_h as f64) >= (img_w as f64) / (img_h as f64) =>
+ {
+ calculate_dimensions(dimensions, TargetDimension::MaxHeight(max_h))
+ }
+ TargetDimension::BoundingBox(max_w, _) => {
+ calculate_dimensions(dimensions, TargetDimension::MaxWidth(max_w))
+ }
+ }
}
#[cfg(test)]
@@ -48,66 +66,54 @@ mod test_get_thumbnail_dimensions {
use super::*;
- // The `red.png` file used in this test has dimensions 100×200
-
- #[test]
- fn with_target_width() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxWidth(50);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (50, 100));
- }
-
- #[test]
- fn with_target_height() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxHeight(100);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (50, 100));
+ macro_rules! get_thumb_dimensions_tests {
+ ($($name:ident: $value:expr,)*) => {
+ $(
+ #[test]
+ fn $name() {
+ let (input, target, expected) = $value;
+
+ let dimensions = calculate_dimensions(input, target);
+ assert_eq!(dimensions, expected);
+ }
+ )*
+ }
}
- #[test]
- fn leaves_image_as_is_if_target_width_greater_than_width() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxWidth(500);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (100, 200));
- }
-
- #[test]
- fn leaves_image_as_is_if_target_width_equal_to_width() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxWidth(500);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (100, 200));
- }
-
- #[test]
- fn leaves_image_as_is_if_target_height_greater_than_height() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxHeight(500);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (100, 200));
- }
-
- #[test]
- fn leaves_image_as_is_if_target_height_equal_to_height() {
- let p = PathBuf::from("src/tests/red.png");
-
- let target = TargetDimension::MaxHeight(500);
-
- let dimensions = get_thumbnail_dimensions(&p, target);
- assert_eq!(dimensions.unwrap(), (100, 200));
+ get_thumb_dimensions_tests! {
+ width_lt: ((100, 200), TargetDimension::MaxWidth(50), ( 50, 100)),
+ width_eq: ((100, 200), TargetDimension::MaxWidth(100), (100, 200)),
+ width_gt: ((100, 200), TargetDimension::MaxWidth(200), (100, 200)),
+
+ height_lt: ((100, 200), TargetDimension::MaxHeight(100), ( 50, 100)),
+ height_eq: ((100, 200), TargetDimension::MaxHeight(200), (100, 200)),
+ height_gt: ((100, 200), TargetDimension::MaxHeight(400), (100, 200)),
+
+ // bounding box which is larger than the image in one or both
+ // dimensions
+ bbox_larger_w: ((100, 200), TargetDimension::BoundingBox(500, 200), (100, 200)),
+ bbox_larger_h: ((100, 200), TargetDimension::BoundingBox(100, 500), (100, 200)),
+ bbox_larger_wh: ((100, 200), TargetDimension::BoundingBox(500, 500), (100, 200)),
+
+ // bounding box with an equal aspect ratio to the image
+ bbox_equal_lt: ((100, 200), TargetDimension::BoundingBox( 50, 100), ( 50, 100)),
+ bbox_equal_eq: ((100, 200), TargetDimension::BoundingBox(100, 200), (100, 200)),
+ bbox_equal_gt: ((100, 200), TargetDimension::BoundingBox(200, 400), (100, 200)),
+
+ // bounding box which is skinnier than the image
+ bbox_skinnier_lt: ((100, 200), TargetDimension::BoundingBox(10, 100), (10, 20)),
+ bbox_skinnier_eq: ((100, 200), TargetDimension::BoundingBox(20, 200), (20, 40)),
+ bbox_skinnier_gt: ((100, 200), TargetDimension::BoundingBox(40, 400), (40, 80)),
+
+ // bounding box which is wider than the image
+ bbox_wider_lt: ((100, 200), TargetDimension::BoundingBox(100, 20), (10, 20)),
+ bbox_wider_eq: ((100, 200), TargetDimension::BoundingBox(200, 40), (20, 40)),
+ bbox_wider_gt: ((100, 200), TargetDimension::BoundingBox(400, 80), (40, 80)),
+
+ // case to ensure we do floating point division correctly, and
+ // aren't making rounding errors
+ fp_width: ((500, 333), TargetDimension::MaxWidth(300), (300, 200)),
+ fp_height: ((333, 500), TargetDimension::MaxHeight(300), (200, 300)),
}
#[test]
src/main.rs (6676) → src/main.rs (6651)
diff --git a/src/main.rs b/src/main.rs
index 2a36531..b36a905 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,7 @@
use std::path::PathBuf;
-use clap::{ArgGroup, Parser};
+use clap::Parser;
mod create_parent_directory;
mod create_thumbnail;
@@ -15,11 +15,6 @@ use crate::get_thumbnail_dimensions::TargetDimension;
#[derive(Debug, Parser)]
#[clap(version, about)]
-#[clap(group(
- ArgGroup::new("dimensions")
- .required(true)
- .args(&["height", "width"]),
-))]
struct Cli {
/// Path to the image to be thumbnailed
path: PathBuf,
@@ -41,9 +36,15 @@ fn main() {
let cli = Cli::parse();
let target = match (cli.width, cli.height) {
+ (Some(w), Some(h)) => TargetDimension::BoundingBox(w, h),
(Some(w), None) => TargetDimension::MaxWidth(w),
(None, Some(h)) => TargetDimension::MaxHeight(h),
- _ => unreachable!(),
+ _ => {
+ eprintln!(
+ "Failed to create thumbnail: you must pass at least one of --width or --height"
+ );
+ std::process::exit(1);
+ }
};
match create_thumbnail(&cli.path, &cli.out_dir, target) {
@@ -95,41 +96,35 @@ mod test_cli {
}
#[test]
- fn it_fails_if_you_pass_width_and_height() {
- let invalid_args = predicate::str::is_match(
- r"the argument '--width <WIDTH>' cannot be used with '--height <HEIGHT>'",
- )
- .unwrap();
-
+ fn it_creates_a_thumbnail_with_a_bounding_box() {
Command::cargo_bin("create_thumbnail")
.unwrap()
.args(&[
- "src/tests/red.png",
- "--width=100",
- "--height=100",
+ "src/tests/noise.jpg",
+ "--width=64",
+ "--height=64",
"--out-dir=/tmp",
])
.assert()
- .failure()
- .code(2)
- .stdout("")
- .stderr(invalid_args);
+ .success()
+ .stdout("/tmp/noise.jpg")
+ .stderr("");
+
+ assert_eq!(get_dimensions(&PathBuf::from("/tmp/noise.jpg")), (32, 64));
}
#[test]
fn it_fails_if_you_pass_neither_width_nor_height() {
- let is_missing_args_err =
- predicate::str::is_match(r"the following required arguments were not provided:")
- .unwrap();
-
Command::cargo_bin("create_thumbnail")
.unwrap()
.args(&["src/tests/red.png", "--out-dir=/tmp"])
.assert()
.failure()
- .code(2)
+ .code(1)
.stdout("")
- .stderr(is_missing_args_err);
+ .stderr(
+ "Failed to create thumbnail: you must pass at least one of --width or --height\n",
+ );
}
#[test]
@@ -201,8 +196,9 @@ mod test_cli {
#[test]
fn it_prints_the_help() {
- // Match strings like `dominant_colours 1.2.3`
- let is_help_text = predicate::str::is_match(r"create_thumbnail --out-dir").unwrap();
+ // Match strings like `create_thumbnail 1.2.3`
+ let is_help_text =
+ predicate::str::is_match(r"create_thumbnail \[OPTIONS\] --out-dir").unwrap();
Command::cargo_bin("create_thumbnail")
.unwrap()