Repository: python-pillow/Pillow

Problem statement:
Function to downscale to minimum size
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What do you want to do?

I want to downscale/resize an image to a minimum size without changing its aspect ratio. This could be useful for shrinking large profile pictures, or shrinking an image to save processing time during future operations on an image.

### How would you expect it to be implemented?

I'd like to call a function on an `Image` object, like `downscale_to_min(img, min_width, min_height)`. 

Example:
```python
img = Image.open("filepath/to/my/picture.png")
print(img.size) # (1400, 100)
img.downscale_to_min(500, 500)
print(img.size) # (700, 500)
```

### Any context?

There a similar function to this, `thumbnail()`, where you set the _maximum_ size to scale an image to. It's like drawing a box around an image, and making sure no side of the image grows over the borders. I tried to visualize it here, red are the borders (the maximum size passed to `thumbnail()`, and green is the image:

<details><summary>Click to expand</summary>

![Thumbnail](https://github.com/python-pillow/Pillow/assets/119804311/289bd185-8ae7-4189-bf76-321b2f3f7630)

</details>

This function would be similar, except the box is now on the inside of the image, and the no side can shrink _under_ the borders
Again, visualized:

<details><summary>Click to expand</summary>

![Down to min](https://github.com/python-pillow/Pillow/assets/119804311/f544d4fb-c55b-4ae7-bc11-a438adeb2633)

</details>

### What are your OS, Python and Pillow versions?

* OS: Fedora Linux 38
* Python: 3.11
* Pillow: 10.0.1

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->

This is a basic function that implements the functionality using existing PIL functions.

```python
def downscale_to_min(img, min_width, min_height):
    desired_ratio = min_height / min_width
    img_ratio = img.height / img.width

    if img_ratio > desired_ratio:
        desired_height = min_height
        downscale_factor = min_height / img.height
        desired_width = img.width * downscale_factor

    else:
        desired_width = min_width
        downscale_factor = min_width / img.width
        desired_height = img.height * downscale_factor

    img.resize((desired_width, desired_height))
```



Hints:
I think that `ImageOps` is a better home for this than `Image`, since we have similar methods there already. I've created PR #7412 to add the new function.

Patch:
diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py
--- a/src/PIL/ImageOps.py
+++ b/src/PIL/ImageOps.py
@@ -242,7 +242,7 @@ def contain(image, size, method=Image.Resampling.BICUBIC):
     Returns a resized version of the image, set to the maximum width and height
     within the requested size, while maintaining the original aspect ratio.
 
-    :param image: The image to resize and crop.
+    :param image: The image to resize.
     :param size: The requested output size in pixels, given as a
                  (width, height) tuple.
     :param method: Resampling method to use. Default is
@@ -266,6 +266,35 @@ def contain(image, size, method=Image.Resampling.BICUBIC):
     return image.resize(size, resample=method)
 
 
+def cover(image, size, method=Image.Resampling.BICUBIC):
+    """
+    Returns a resized version of the image, so that the requested size is
+    covered, while maintaining the original aspect ratio.
+
+    :param image: The image to resize.
+    :param size: The requested output size in pixels, given as a
+                 (width, height) tuple.
+    :param method: Resampling method to use. Default is
+                   :py:attr:`~PIL.Image.Resampling.BICUBIC`.
+                   See :ref:`concept-filters`.
+    :return: An image.
+    """
+
+    im_ratio = image.width / image.height
+    dest_ratio = size[0] / size[1]
+
+    if im_ratio != dest_ratio:
+        if im_ratio < dest_ratio:
+            new_height = round(image.height / image.width * size[0])
+            if new_height != size[1]:
+                size = (size[0], new_height)
+        else:
+            new_width = round(image.width / image.height * size[1])
+            if new_width != size[0]:
+                size = (new_width, size[1])
+    return image.resize(size, resample=method)
+
+
 def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)):
     """
     Returns a resized and padded version of the image, expanded to fill the


Test patch:
diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py
--- a/Tests/test_imageops.py
+++ b/Tests/test_imageops.py
@@ -39,6 +39,9 @@ def test_sanity():
     ImageOps.contain(hopper("L"), (128, 128))
     ImageOps.contain(hopper("RGB"), (128, 128))
 
+    ImageOps.cover(hopper("L"), (128, 128))
+    ImageOps.cover(hopper("RGB"), (128, 128))
+
     ImageOps.crop(hopper("L"), 1)
     ImageOps.crop(hopper("RGB"), 1)
 
@@ -119,6 +122,20 @@ def test_contain_round():
     assert new_im.height == 5
 
 
+@pytest.mark.parametrize(
+    "image_name, expected_size",
+    (
+        ("colr_bungee.png", (1024, 256)),  # landscape
+        ("imagedraw_stroke_multiline.png", (256, 640)),  # portrait
+        ("hopper.png", (256, 256)),  # square
+    ),
+)
+def test_cover(image_name, expected_size):
+    with Image.open("Tests/images/" + image_name) as im:
+        new_im = ImageOps.cover(im, (256, 256))
+        assert new_im.size == expected_size
+
+
 def test_pad():
     # Same ratio
     im = hopper()