Repository: python-pillow/Pillow

Issue:
TiffImagePlugin doesn't honor the ROWSPERSTRIP tag value
<!--
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 did you do?

I tried to save a PIL.Image.Image (mode=RGBA, size=335x333, 8 bits per sample) to a TIFF file with LZW compression and 9 rows per strip.

```python
img.save('output.tiff', compression='tiff_lzw', tiffinfo={278: 9})
```

### What did you expect to happen?

The file to be saved as a TIFF file with LZW compression and 9 rows per strip.

### What actually happened?

The file was saved as a TIFF file with LZW compression and 48 rows per strip.

### What are your OS, Python and Pillow versions?

* OS: Windows 10 64-bit
* Python: 3.9.5
* Pillow: 9.2.0

<!--
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.
-->



Discussion:
As context, ROWSPERSTRIP has been set by TiffImagePlugin since PIL.

If I make a change to allow ROWSPERSTRIP to be set by the user, the following test code starts failing.
https://github.com/python-pillow/Pillow/blob/53b6e5f4bf9a6a68e29109c457b12d17761d0035/Tests/test_file_tiff_metadata.py#L124-L128

The error is
https://github.com/python-pillow/Pillow/blob/53b6e5f4bf9a6a68e29109c457b12d17761d0035/src/PIL/TiffImagePlugin.py#L906
@radarhere, thanks.
Opening an image with a certain number of rows per strip and just saving it without any manipulation and with the same tags:
```Python
sourceimg = Image.open('input.tiff')
tiffinfo = sourceimg.tag_v2
sourceimg.save('output', tiffinfo=tiffinfo)
```
then the saved image will have a different number of rows per strip.

Anyway, changing the TiffImagePlugin.ROWSPERSTRIP tag number to another bogus non conflicting tag number, e.g. 10, it is possible to trick the TiffImagePlugin, so it will write its calculated value of the rows per strip to such bogus tag number 10 so the tiff library will use the rows per strip provided by the tiffinfo:

```Python
sourceimg = Image.open('input.tiff')
tiffinfo = sourceimg.tag_v2
TiffImagePlugin.ROWSPERSTRIP = 10
sourceimg.save('output', tiffinfo=tiffinfo)
```
However, this is not a complete workaround, as the saved image will have a spurious tag number 10.
Changing `TiffImagePlugin.ROWSPERSTRIP` is an inventive idea, but I think it might break reading TIFF files afterwards.

If you're looking for a workaround, see what you think of this.
```python
from PIL import Image, TiffImagePlugin

def setRowsPerStrip(im, rows_per_strip):
    TiffImagePlugin.WRITE_LIBTIFF = True
    
    bits = TiffImagePlugin.SAVE_INFO[im.mode][4]
    stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8)
    TiffImagePlugin.STRIP_SIZE = rows_per_strip * stride

im = Image.open("Tests/images/hopper.tif")
setRowsPerStrip(im, 110)
im.save("out.tif")
```

Resolving patch:
diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py
--- a/src/PIL/TiffImagePlugin.py
+++ b/src/PIL/TiffImagePlugin.py
@@ -1705,20 +1705,23 @@ def _save(im, fp, filename):
         ifd[COLORMAP] = colormap
     # data orientation
     stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8)
-    # aim for given strip size (64 KB by default) when using libtiff writer
-    if libtiff:
-        im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE)
-        rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, im.size[1])
-        # JPEG encoder expects multiple of 8 rows
-        if compression == "jpeg":
-            rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1])
-    else:
-        rows_per_strip = im.size[1]
-    if rows_per_strip == 0:
-        rows_per_strip = 1
-    strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip
-    strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip
-    ifd[ROWSPERSTRIP] = rows_per_strip
+    if ROWSPERSTRIP not in ifd:
+        # aim for given strip size (64 KB by default) when using libtiff writer
+        if libtiff:
+            im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE)
+            rows_per_strip = (
+                1 if stride == 0 else min(im_strip_size // stride, im.size[1])
+            )
+            # JPEG encoder expects multiple of 8 rows
+            if compression == "jpeg":
+                rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1])
+        else:
+            rows_per_strip = im.size[1]
+        if rows_per_strip == 0:
+            rows_per_strip = 1
+        ifd[ROWSPERSTRIP] = rows_per_strip
+    strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP]
+    strips_per_image = (im.size[1] + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP]
     if strip_byte_counts >= 2**16:
         ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG
     ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + (


Test patch:
diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py
--- a/Tests/test_file_tiff.py
+++ b/Tests/test_file_tiff.py
@@ -612,6 +612,14 @@ def test_roundtrip_tiff_uint16(self, tmp_path):
 
             assert_image_equal_tofile(im, tmpfile)
 
+    def test_rowsperstrip(self, tmp_path):
+        outfile = str(tmp_path / "temp.tif")
+        im = hopper()
+        im.save(outfile, tiffinfo={278: 256})
+
+        with Image.open(outfile) as im:
+            assert im.tag_v2[278] == 256
+
     def test_strip_raw(self):
         infile = "Tests/images/tiff_strip_raw.tif"
         with Image.open(infile) as im:
diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py
--- a/Tests/test_file_tiff_metadata.py
+++ b/Tests/test_file_tiff_metadata.py
@@ -123,6 +123,7 @@ def test_write_metadata(tmp_path):
     """Test metadata writing through the python code"""
     with Image.open("Tests/images/hopper.tif") as img:
         f = str(tmp_path / "temp.tiff")
+        del img.tag[278]
         img.save(f, tiffinfo=img.tag)
 
         original = img.tag_v2.named()
@@ -159,6 +160,7 @@ def test_change_stripbytecounts_tag_type(tmp_path):
     out = str(tmp_path / "temp.tiff")
     with Image.open("Tests/images/hopper.tif") as im:
         info = im.tag_v2
+        del info[278]
 
         # Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT
         im = im.resize((500, 500))