From 54ae38563cd7d45cb3a21cfcd1ef6e4f31888c48 Mon Sep 17 00:00:00 2001 From: nyxst4ck Date: Wed, 5 Aug 2026 00:17:05 -0300 Subject: [PATCH 1/7] Fixed loading transparent XPM images The 11.3.0 palette refactor stopped adding an entry for 'c None' colours, so palette.index(key) raised ValueError and any XPM with a transparent colour failed to load. Give the transparent colour a real palette entry (black, matching pre-11.3.0 rendering) and store its palette index (P mode) or colour tuple (RGB mode) in info['transparency']. --- Tests/test_file_xpm.py | 41 +++++++++++++++++++++++++++++++++++++++ src/PIL/XpmImagePlugin.py | 11 +++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 86d86602f17..ab966bac87b 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -33,6 +33,47 @@ def test_rgb() -> None: assert_image_similar(im, hopper(), 16) +def test_transparency() -> None: + data = b"""/* XPM */ +static char *test[] = { +"2 2 3 1", +" \tc None", +"r\tc #FF0000", +"b\tc #0000FF", +" r", +"b "}; +""" + with Image.open(BytesIO(data)) as im: + assert im.mode == "P" + assert im.info["transparency"] == 0 + + converted = im.convert("RGBA") + assert converted.getpixel((0, 0)) == (0, 0, 0, 0) + assert converted.getpixel((1, 0)) == (255, 0, 0, 255) + assert converted.getpixel((0, 1)) == (0, 0, 255, 255) + + +def test_transparency_rgb() -> None: + # With more than 256 colours, the image is opened as RGB + chars = "0123456789abcdefghijklmnopqrstuvwxy" + colours = [ + b'"%s\tc #%06x",' % ((chars[i // 35] + chars[i % 35]).encode(), i + 1) + for i in range(300) + ] + colours.append(b'"zz\tc None",') + data = ( + b'/* XPM */\nstatic char *test[] = {\n"2 1 301 2",\n' + + b"\n".join(colours) + + b'\n"00zz"};\n' + ) + with Image.open(BytesIO(data)) as im: + assert im.mode == "RGB" + assert im.info["transparency"] == (0, 0, 0) + + assert im.getpixel((0, 0)) == (0, 0, 1) + assert im.getpixel((1, 0)) == (0, 0, 0) + + def test_truncated_header() -> None: data = b"/* XPM */" with pytest.raises(SyntaxError, match="broken XPM file"): diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 80192f55e53..4d11246f6ab 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -61,6 +61,7 @@ def _open(self) -> None: # load palette description palette = {} + transparent_key = None for _ in range(palette_length): line = self.fp.readline().rstrip() @@ -73,7 +74,8 @@ def _open(self) -> None: # process colour key rgb = s[i + 1] if rgb == b"None": - self.info["transparency"] = c + transparent_key = c + palette[c] = b"\0\0\0" elif rgb.startswith(b"#"): rgb_int = int(rgb[1:], 16) palette[c] = ( @@ -96,10 +98,15 @@ def _open(self) -> None: if palette_length > 256: self._mode = "RGB" args = (bpp, palette) + if transparent_key is not None: + self.info["transparency"] = (0, 0, 0) else: self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette.values())) - args = (bpp, tuple(palette.keys())) + palette_keys = tuple(palette.keys()) + args = (bpp, palette_keys) + if transparent_key is not None: + self.info["transparency"] = palette_keys.index(transparent_key) self.tile = [ImageFile._Tile("xpm", (0, 0) + self.size, self.fp.tell(), args)] From ef4307d5a947f4569f9db6838dc5ff7c7292cffb Mon Sep 17 00:00:00 2001 From: nyxst4ck Date: Wed, 5 Aug 2026 11:19:44 -0300 Subject: [PATCH 2/7] Load transparent XPM with more than 256 colors as RGBA --- Tests/test_file_xpm.py | 13 +++++++------ src/PIL/XpmImagePlugin.py | 17 +++++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index ab966bac87b..34e3e3d5972 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -53,8 +53,9 @@ def test_transparency() -> None: assert converted.getpixel((0, 1)) == (0, 0, 255, 255) -def test_transparency_rgb() -> None: - # With more than 256 colours, the image is opened as RGB +def test_transparency_rgba() -> None: + # With more than 256 colours and a transparent colour, + # the image is opened as RGBA chars = "0123456789abcdefghijklmnopqrstuvwxy" colours = [ b'"%s\tc #%06x",' % ((chars[i // 35] + chars[i % 35]).encode(), i + 1) @@ -67,11 +68,11 @@ def test_transparency_rgb() -> None: + b'\n"00zz"};\n' ) with Image.open(BytesIO(data)) as im: - assert im.mode == "RGB" - assert im.info["transparency"] == (0, 0, 0) + assert im.mode == "RGBA" + assert "transparency" not in im.info - assert im.getpixel((0, 0)) == (0, 0, 1) - assert im.getpixel((1, 0)) == (0, 0, 0) + assert im.getpixel((0, 0)) == (0, 0, 1, 255) + assert im.getpixel((1, 0)) == (0, 0, 0, 0) def test_truncated_header() -> None: diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 4d11246f6ab..6eee878a266 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -96,10 +96,15 @@ def _open(self) -> None: args: tuple[int, dict[bytes, bytes] | tuple[bytes, ...]] if palette_length > 256: - self._mode = "RGB" - args = (bpp, palette) if transparent_key is not None: - self.info["transparency"] = (0, 0, 0) + self._mode = "RGBA" + palette = { + c: rgb + (b"\0" if c == transparent_key else b"\xff") + for c, rgb in palette.items() + } + else: + self._mode = "RGB" + args = (bpp, palette) else: self._mode = "P" self.palette = ImagePalette.raw("RGB", b"".join(palette.values())) @@ -131,8 +136,8 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: data = bytearray() bpp, palette = self.args dest_length = self.state.xsize * self.state.ysize - if self.mode == "RGB": - dest_length *= 3 + if self.mode in ("RGB", "RGBA"): + dest_length *= len(self.mode) pixel_header = False while len(data) < dest_length: line = self.fd.readline() @@ -144,7 +149,7 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: line = b'"'.join(line.split(b'"')[1:-1]) for i in range(0, len(line), bpp): key = line[i : i + bpp] - if self.mode == "RGB": + if self.mode in ("RGB", "RGBA"): data += palette[key] else: data += o8(palette.index(key)) From a4711674f932caa69cf9ed8f8079e573fb15159f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 08:56:41 +1000 Subject: [PATCH 3/7] Remove indent --- Tests/test_file_xpm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 34e3e3d5972..3a8aa7069bf 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -48,9 +48,9 @@ def test_transparency() -> None: assert im.info["transparency"] == 0 converted = im.convert("RGBA") - assert converted.getpixel((0, 0)) == (0, 0, 0, 0) - assert converted.getpixel((1, 0)) == (255, 0, 0, 255) - assert converted.getpixel((0, 1)) == (0, 0, 255, 255) + assert converted.getpixel((0, 0)) == (0, 0, 0, 0) + assert converted.getpixel((1, 0)) == (255, 0, 0, 255) + assert converted.getpixel((0, 1)) == (0, 0, 255, 255) def test_transparency_rgba() -> None: From e2bd6f706aed19d75a0b8d7dd9cceef2dd377d87 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 09:57:05 +1000 Subject: [PATCH 4/7] Use sets --- src/PIL/XpmImagePlugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 6eee878a266..1e0db4626ba 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -136,7 +136,7 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: data = bytearray() bpp, palette = self.args dest_length = self.state.xsize * self.state.ysize - if self.mode in ("RGB", "RGBA"): + if self.mode in {"RGB", "RGBA"}: dest_length *= len(self.mode) pixel_header = False while len(data) < dest_length: @@ -149,7 +149,7 @@ def decode(self, buffer: Image.DecoderInput) -> tuple[int, int]: line = b'"'.join(line.split(b'"')[1:-1]) for i in range(0, len(line), bpp): key = line[i : i + bpp] - if self.mode in ("RGB", "RGBA"): + if self.mode in {"RGB", "RGBA"}: data += palette[key] else: data += o8(palette.index(key)) From 6fd772e2e439811f75de5674a6160e5f10798104 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 09:39:05 +1000 Subject: [PATCH 5/7] Remove untested pixel --- Tests/test_file_xpm.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 3a8aa7069bf..25cd6223366 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -36,12 +36,11 @@ def test_rgb() -> None: def test_transparency() -> None: data = b"""/* XPM */ static char *test[] = { -"2 2 3 1", +"3 1 3 1", " \tc None", "r\tc #FF0000", "b\tc #0000FF", -" r", -"b "}; +" rb"}; """ with Image.open(BytesIO(data)) as im: assert im.mode == "P" @@ -50,7 +49,7 @@ def test_transparency() -> None: converted = im.convert("RGBA") assert converted.getpixel((0, 0)) == (0, 0, 0, 0) assert converted.getpixel((1, 0)) == (255, 0, 0, 255) - assert converted.getpixel((0, 1)) == (0, 0, 255, 255) + assert converted.getpixel((2, 0)) == (0, 0, 255, 255) def test_transparency_rgba() -> None: From f3b830dc426a5f187602f4ca8d55a24e3ac13d75 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 09:55:14 +1000 Subject: [PATCH 6/7] Simplified colour keys --- Tests/test_file_xpm.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 25cd6223366..eb07a7bfbe4 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -55,16 +55,12 @@ def test_transparency() -> None: def test_transparency_rgba() -> None: # With more than 256 colours and a transparent colour, # the image is opened as RGBA - chars = "0123456789abcdefghijklmnopqrstuvwxy" - colours = [ - b'"%s\tc #%06x",' % ((chars[i // 35] + chars[i % 35]).encode(), i + 1) - for i in range(300) - ] - colours.append(b'"zz\tc None",') + colours = [b'"%03d\tc #%06x",' % (i, i + 1) for i in range(300)] + colours.append(b'" \tc None",') data = ( - b'/* XPM */\nstatic char *test[] = {\n"2 1 301 2",\n' + b'/* XPM */\nstatic char *test[] = {\n"2 1 301 3",\n' + b"\n".join(colours) - + b'\n"00zz"};\n' + + b'\n"000 "};\n' ) with Image.open(BytesIO(data)) as im: assert im.mode == "RGBA" From 8df6e249ad9b1f2bca6de3abd1e404b25bfe3ee5 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 21 Aug 2026 09:59:34 +1000 Subject: [PATCH 7/7] Consistently use hexadecimal in a single line --- src/PIL/XpmImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 1e0db4626ba..07fbd8e4e4e 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -99,7 +99,7 @@ def _open(self) -> None: if transparent_key is not None: self._mode = "RGBA" palette = { - c: rgb + (b"\0" if c == transparent_key else b"\xff") + c: rgb + (b"\x00" if c == transparent_key else b"\xff") for c, rgb in palette.items() } else: