From ef7be8e050d425f80c06ba038df64a72173bea46 Mon Sep 17 00:00:00 2001 From: Jeffrey 'Alex' Clark Date: Fri, 30 Oct 2026 22:51:13 -0400 Subject: [PATCH 1/2] Take libtiff geometry tags from the image when saving When saving with libtiff, only BITSPERSAMPLE was taken from the image. IMAGEWIDTH, IMAGELENGTH, SAMPLESPERPIXEL and PLANAR_CONFIGURATION could be overridden by tiffinfo, exif or the original file's IFD. Since ec5f1cbcf, values describing a larger row than the image raise an OSError; other values could still produce an invalid file. Use the image's geometry for all of these tags instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- Tests/test_file_libtiff.py | 12 +++++++----- docs/releasenotes/13.1.0.rst | 9 +++++++++ src/PIL/TiffImagePlugin.py | 11 ++++++++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index acb2f66bf0f..2d7c5bd38ba 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -399,21 +399,23 @@ def test_non_bytes( "tiffinfo", ( {TiffImagePlugin.IMAGEWIDTH: 1 << 27}, + {TiffImagePlugin.IMAGELENGTH: 1 << 27}, {TiffImagePlugin.SAMPLESPERPIXEL: 3}, + {TiffImagePlugin.PLANAR_CONFIGURATION: 2}, ), ) def test_geometry_tags( self, tiffinfo: dict[int, int], monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - # libtiff reads a scanline of the size described by the IMAGEWIDTH, - # SAMPLESPERPIXEL and BITSPERSAMPLE tags from a buffer that only holds one - # row of the image, so a tag describing a larger row must be rejected. + # The geometry of the image is used, not the geometry from tiffinfo. + # Otherwise libtiff would read past the end of the row buffer. monkeypatch.setattr(TiffImagePlugin, "WRITE_LIBTIFF", True) out = tmp_path / "temp.tif" im = hopper("L") - with pytest.raises(OSError, match="encoder error -8"): - im.save(out, tiffinfo=tiffinfo) + im.save(out, tiffinfo=tiffinfo) + + assert_image_equal_tofile(im, out) @pytest.mark.parametrize( "tag, value", diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 7782570bba8..43999d2d20f 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -59,6 +59,15 @@ use, now copy an image before writing to it if its memory is read-only, as memory-mapped image files, or modify the buffer behind an image created by :py:func:`~PIL.Image.frombuffer` or :py:func:`~PIL.Image.fromarrow`. +Saving TIFF images with libtiff geometry tags +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving a TIFF image with libtiff, the ImageWidth, ImageLength, +SamplesPerPixel and PlanarConfiguration tags are now always taken from the +image, rather than from ``tiffinfo``, ``exif`` or the IFD of the original file. +Previously, values that described a larger row than the image raised an +:py:exc:`OSError`, and other values could produce an invalid file. + Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 6dd34ea80fb..9722c7dbe9b 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -2030,7 +2030,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ] # bits per sample is a single short in the tiff directory, not a list. - atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} + # The tags that determine the size of a scanline are taken from the image, + # not from tiffinfo/exif: libtiff reads a scanline of the size that they + # describe from a buffer that holds one row of the image. + atts: dict[int, Any] = { + BITSPERSAMPLE: bits[0], + SAMPLESPERPIXEL: len(bits), + IMAGEWIDTH: im.size[0], + IMAGELENGTH: im.size[1], + PLANAR_CONFIGURATION: 1, + } # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file. From 2983d4899bb8ed53ef09c8cb343a599245aa9b5a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 31 Oct 2026 20:43:30 +1100 Subject: [PATCH 2/2] Set PLANAR_CONFIGURATION and SAMPLESPERPIXEL when saving without libtiff --- src/PIL/TiffImagePlugin.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 9722c7dbe9b..b821edb6474 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1825,9 +1825,6 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: libtiff = WRITE_LIBTIFF or compression != "raw" - # required for color libtiff images - ifd[PLANAR_CONFIGURATION] = 1 - ifd[IMAGEWIDTH] = im.size[0] ifd[IMAGELENGTH] = im.size[1] @@ -1846,6 +1843,8 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if isinstance(info, ImageFileDirectory_v1): info = info.to_v2() for key in info: + if libtiff and key in {IMAGEWIDTH, IMAGELENGTH}: + continue if isinstance(info, Image.Exif) and key in TiffTags.TAGS_V2_GROUPS: ifd[key] = info.get_ifd(key) else: @@ -1855,6 +1854,9 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: except Exception: pass # might not be an IFD. Might not have populated type + # required for color libtiff images + ifd[PLANAR_CONFIGURATION] = 1 + legacy_ifd = {} if hasattr(im, "tag"): legacy_ifd = im.tag.to_v2() @@ -1926,8 +1928,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if bits != (1,): ifd[BITSPERSAMPLE] = bits - if len(bits) != 1: - ifd[SAMPLESPERPIXEL] = len(bits) + ifd[SAMPLESPERPIXEL] = len(bits) if extra is not None: ifd[EXTRASAMPLES] = extra if format != 1: @@ -2030,16 +2031,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ] # bits per sample is a single short in the tiff directory, not a list. - # The tags that determine the size of a scanline are taken from the image, - # not from tiffinfo/exif: libtiff reads a scanline of the size that they - # describe from a buffer that holds one row of the image. - atts: dict[int, Any] = { - BITSPERSAMPLE: bits[0], - SAMPLESPERPIXEL: len(bits), - IMAGEWIDTH: im.size[0], - IMAGELENGTH: im.size[1], - PLANAR_CONFIGURATION: 1, - } + atts: dict[int, Any] = {BITSPERSAMPLE: bits[0]} # Merge the ones that we have with (optional) more bits from # the original file, e.g x,y resolution so that we can # save(load('')) == original file.