From 2b837c617e11122fd8fd536fea07df4e9a517e50 Mon Sep 17 00:00:00 2001 From: Guillaume Marty Date: Mon, 26 Oct 2026 11:35:27 +0100 Subject: [PATCH 1/3] Scale tRNS grey level for 2-bit and 4-bit grayscale PNG images Co-Authored-By: Claude Opus 5.5 (1M context) --- Tests/test_file_png.py | 22 ++++++++++++++++++++++ src/PIL/PngImagePlugin.py | 7 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index e767600b5d4..af91e57a05d 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -355,6 +355,28 @@ def test_save_grayscale_transparency(self, tmp_path: Path) -> None: assert colors is not None assert colors[0][0] == num_transparent + @pytest.mark.parametrize("bits", (2, 4)) + def test_load_low_bit_grayscale_transparency(self, bits: int) -> None: + # Two pixels, white then black. White is the transparent grey level + max_value = (1 << bits) - 1 + data = ( + MAGIC + + chunk(b"IHDR", o32(2), o32(1), bytes((bits, 0)), b"\0\0\0") + + chunk(b"tRNS", PngImagePlugin.o16(max_value)) + + chunk(b"IDAT", zlib.compress(bytes((0, max_value << (8 - bits))))) + + IEND + ) + with load(data) as im: + assert im.mode == "L" + assert im.info["transparency"] == 255 + assert im.convert("LA").get_flattened_data() == ((255, 0), (0, 255)) + + # Images saved by Pillow with fewer bits can still be read + im = Image.new("L", (2, 1)) + im.putpixel((0, 0), 255) + with roundtrip(im, bits=bits, transparency=255) as reloaded: + assert reloaded.info["transparency"] == 255 + def test_save_1_transparency(self, tmp_path: Path) -> None: out = tmp_path / "temp.png" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index ab480fd0bc9..6f19f128319 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -529,7 +529,12 @@ def chunk_tRNS(self, pos: int, length: int) -> bytes: if self.im_mode == "1": self.im_info["transparency"] = 255 if i16(s) else 0 elif self.im_mode in ("L", "I;16"): - self.im_info["transparency"] = i16(s) + value = i16(s) + if self.im_rawmode in ("L;2", "L;4"): + # Scale the grey level to match the unpacked 8-bit pixels + max_value = (1 << int(self.im_rawmode[-1])) - 1 + value = (value & max_value) * 255 // max_value + self.im_info["transparency"] = value elif self.im_mode == "RGB": self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) return s From f6054f425a807805fe7416b1e41edd58eaddb131 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 30 Oct 2026 10:58:14 +1100 Subject: [PATCH 2/3] Use test image --- Tests/images/tbbn0g04.png | Bin 0 -> 429 bytes Tests/test_file_png.py | 25 +++++++------------------ src/PIL/PngImagePlugin.py | 2 +- 3 files changed, 8 insertions(+), 19 deletions(-) create mode 100644 Tests/images/tbbn0g04.png diff --git a/Tests/images/tbbn0g04.png b/Tests/images/tbbn0g04.png new file mode 100644 index 0000000000000000000000000000000000000000..39a7050d27af28f3bcb7bfc6b071cae91f8c091b GIT binary patch literal 429 zcmV;e0aE^nP)eGf1AoYPn=%bN?oV&q3&xZP#{- zd#wGeV8okcyH@o)cboHb1rrf^jB^?sG2T2?@G1n)CJc@hyhCf)&E8%^0~1oF;v>7S z;6sBJiji@r0Yq=mmR;)Ojr>$W46R-Sau7HO7xZFtI1{|4z%-nLbW=B z3o0EAC)h$;8WIR1rohQ#w444G0#1m$Dj66xet|L#R>4C36e7xMM0T)(aSAa2XfV)D zcEU#my$(kCaRx~QL^LgQ_;=)3gHxFOXBZJd{maEJLqY3H<>rDuET^1Yth7z0>+1gi X None: assert colors is not None assert colors[0][0] == num_transparent - @pytest.mark.parametrize("bits", (2, 4)) - def test_load_low_bit_grayscale_transparency(self, bits: int) -> None: - # Two pixels, white then black. White is the transparent grey level - max_value = (1 << bits) - 1 - data = ( - MAGIC - + chunk(b"IHDR", o32(2), o32(1), bytes((bits, 0)), b"\0\0\0") - + chunk(b"tRNS", PngImagePlugin.o16(max_value)) - + chunk(b"IDAT", zlib.compress(bytes((0, max_value << (8 - bits))))) - + IEND - ) - with load(data) as im: + def test_low_bit_grayscale_transparency(self) -> None: + with Image.open("Tests/images/tbbn0g04.png") as im: assert im.mode == "L" assert im.info["transparency"] == 255 - assert im.convert("LA").get_flattened_data() == ((255, 0), (0, 255)) + assert im.convert("LA").getpixel((0, 0)) == (255, 0) - # Images saved by Pillow with fewer bits can still be read - im = Image.new("L", (2, 1)) - im.putpixel((0, 0), 255) - with roundtrip(im, bits=bits, transparency=255) as reloaded: - assert reloaded.info["transparency"] == 255 + for bits in (2, 4): + im2 = Image.new("L", (1, 1)) + with roundtrip(im2, bits=bits, transparency=255) as reloaded: + assert reloaded.info["transparency"] == 255 def test_save_1_transparency(self, tmp_path: Path) -> None: out = tmp_path / "temp.png" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 3d5ae4f566d..1fc74677cdb 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -530,7 +530,7 @@ def chunk_tRNS(self, pos: int, length: int) -> bytes: self.im_info["transparency"] = 255 if i16(s) else 0 elif self.im_mode in ("L", "I;16"): value = i16(s) - if self.im_rawmode in ("L;2", "L;4"): + if self.im_rawmode in {"L;2", "L;4"}: # Scale the grey level to match the unpacked 8-bit pixels max_value = (1 << int(self.im_rawmode[-1])) - 1 value = (value & max_value) * 255 // max_value From 28659e8d350de524aeb3be2893e351401ad899a0 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:56:22 +1100 Subject: [PATCH 3/3] Simplify code --- src/PIL/PngImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 1fc74677cdb..f36690e0098 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -533,7 +533,7 @@ def chunk_tRNS(self, pos: int, length: int) -> bytes: if self.im_rawmode in {"L;2", "L;4"}: # Scale the grey level to match the unpacked 8-bit pixels max_value = (1 << int(self.im_rawmode[-1])) - 1 - value = (value & max_value) * 255 // max_value + value = int(min(1, value / max_value) * 255) self.im_info["transparency"] = value elif self.im_mode == "RGB": self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4)