Skip to main content

Allow passing both width/height to set a bounding box

ID
9ad976b
date
2025-12-09 20:54:54+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
af76a5a
message
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()