From 54ae38563cd7d45cb3a21cfcd1ef6e4f31888c48 Mon Sep 17 00:00:00 2001 From: nyxst4ck Date: Wed, 5 Aug 2026 00:17:05 -0300 Subject: [PATCH 1/2] 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/2] 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))