Repository: python-pillow/Pillow

Issue:
Provide an error message when user gives incorrect arguments to Image.crop()
### What did you do?

I called `Image.crop()`, but I got confused about what the arguments are meant to be.

It's meant to be a `(left, upper, right, lower)`-tuple, but I supplied a `(left, upper, width, height)`-tuple instead. The error message I got was:

```
SystemError: tile cannot extend outside image
```

which took me a while to figure out, because all the crops looked like they were inside the image – I think possibly this is a side effect of `width` being lower than `left`, and `height` being lower than `upper`?

### What did you expect to happen?

I'd get an error message explaining my mistake.
Maybe the `crop()` function could throw an error like:

```
ValueError: crop bounds should be a (left, upper, right, lower)-tuple, but right=100 was less than left=50
```

because I don't think cropping ever makes sense if `right > left` and `lower > upper` aren't satisfied.

### What actually happened?

I got confused.

### What are your OS, Python and Pillow versions?

* OS: macOS 10.15.7
* Python: Python 3.9.9
* Pillow: Pillow==9.0.0

### Steps to reproduce

This is a 100×100 pixel image:

<img width="100" alt="gradient" src="https://user-images.githubusercontent.com/301220/149421173-a36f2aa2-3ad8-4556-8330-cce7cac68b81.png">

```python
from PIL import Image

im = Image.open("gradient.png")
print(im.size)

# What I think I'm doing:
# left=0, upper=0, width=25, height=25
#
# This works, because box[2] > box[0] and box[3] > box[1]
crop1 = im.crop(box=(0, 0, 25, 25))
crop1.save("crop1.png")

# What I think I'm doing:
# left=50, upper=50, width=25, height=25
#
# What I should actually have done:
# left=50, upper=50, right=75, upper=75
crop2 = im.crop(box=(50, 50, 25, 25))
crop2.save("crop2.png")
```

In case it's helpful, here's the full stack trace:

<details><summary>Stack trace</summary>

```
Traceback (most recent call last):
  File "/Users/alexwlchan/Library/Python/3.9/lib/python/site-packages/PIL/ImageFile.py", line 495, in _save
    fh = fp.fileno()
AttributeError: '_idat' object has no attribute 'fileno'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "./example.py", line 19, in <module>
    crop2.save("crop2.png")
  File "/Users/alexwlchan/Library/Python/3.9/lib/python/site-packages/PIL/Image.py", line 2212, in save
    save_handler(self, fp, filename)
  File "/Users/alexwlchan/Library/Python/3.9/lib/python/site-packages/PIL/PngImagePlugin.py", line 1348, in _save
    ImageFile._save(im, _idat(fp, chunk), [("zip", (0, 0) + im.size, 0, rawmode)])
  File "/Users/alexwlchan/Library/Python/3.9/lib/python/site-packages/PIL/ImageFile.py", line 503, in _save
    e.setimage(im.im, b)
SystemError: tile cannot extend outside image
```

</details>


Discussion:
To be clear, the error message you're seeing isn't coming from `crop()`.
Your `crop()` operation results in a `(0, 0)` image.
The error is coming when you try to save that `(0, 0)` image.

So I would say that you're not asking for us to change an error message - you're asking us to add an error message when performing a... "negative"... `crop()`, rather than just happily returning a `(0, 0)` image.
> you're asking us to add an error message when performing a... "negative"... crop(), rather than just happily returning a (0, 0) image

Ah, I see – yes, that's what I was thinking.

Resolving patch:
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -1145,6 +1145,11 @@ def crop(self, box=None):
         if box is None:
             return self.copy()
 
+        if box[2] < box[0]:
+            raise ValueError("Coordinate 'right' is less than 'left'")
+        elif box[3] < box[1]:
+            raise ValueError("Coordinate 'lower' is less than 'upper'")
+
         self.load()
         return self._new(self._crop(self.im, box))
 


Test patch:
diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py
--- a/Tests/test_decompression_bomb.py
+++ b/Tests/test_decompression_bomb.py
@@ -86,21 +86,12 @@ def testEnlargeCrop(self):
             pytest.warns(Image.DecompressionBombWarning, src.crop, box)
 
     def test_crop_decompression_checks(self):
-
         im = Image.new("RGB", (100, 100))
 
-        good_values = ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990))
-
-        warning_values = ((-160, -160, 99, 99), (160, 160, -99, -99))
-
-        error_values = ((-99909, -99990, 99999, 99999), (99909, 99990, -99999, -99999))
-
-        for value in good_values:
+        for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)):
             assert im.crop(value).size == (9, 9)
 
-        for value in warning_values:
-            pytest.warns(Image.DecompressionBombWarning, im.crop, value)
+        pytest.warns(Image.DecompressionBombWarning, im.crop, (-160, -160, 99, 99))
 
-        for value in error_values:
-            with pytest.raises(Image.DecompressionBombError):
-                im.crop(value)
+        with pytest.raises(Image.DecompressionBombError):
+            im.crop((-99909, -99990, 99999, 99999))
diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py
--- a/Tests/test_image_crop.py
+++ b/Tests/test_image_crop.py
@@ -47,16 +47,12 @@ def crop(*bbox):
     assert crop(-25, 75, 25, 125) == (1875, 625)
 
 
-def test_negative_crop():
-    # Check negative crop size (@PIL171)
-
-    im = Image.new("L", (512, 512))
-    im = im.crop((400, 400, 200, 200))
+@pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2)))
+def test_negative_crop(box):
+    im = Image.new("RGB", (10, 10))
 
-    assert im.size == (0, 0)
-    assert len(im.getdata()) == 0
-    with pytest.raises(IndexError):
-        im.getdata()[0]
+    with pytest.raises(ValueError):
+        im.crop(box)
 
 
 def test_crop_float():