From b4b43a962878faabe690092162ce5f91f2b0481b Mon Sep 17 00:00:00 2001 From: Andrei Bacescu <9010245+bacescuandrei@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:02:39 +0200 Subject: [PATCH 1/4] Fix reading XPM hex colours that do not have 2 digits per channel XPM colours may use 1 to 4 hex digits per channel (#RGB, #RRGGBB, #RRRGGGBBB, #RRRRGGGGBBBB). The value was parsed as a single integer and its lowest 24 bits were used, so only #RRGGBB was decoded correctly: #FFFF00000000 was read as black and #F00 as (0, 15, 0). Scale each channel to 8 bits instead, which matches ImageMagick and netpbm. --- Tests/test_file_xpm.py | 21 +++++++++++++++++++++ src/PIL/XpmImagePlugin.py | 8 ++++++++ 2 files changed, 29 insertions(+) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 1697d3d6ff9..72b2a48bca4 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -52,6 +52,27 @@ def test_transparency() -> None: assert converted.getpixel((2, 0)) == (0, 0, 255, 255) +@pytest.mark.parametrize( + "colour, expected", + ( + (b"#F00", (255, 0, 0)), + (b"#FF8000", (255, 128, 0)), + (b"#123456789", (18, 69, 120)), + (b"#C71BC30BC71B", (198, 194, 198)), + (b"#FFFF00000000", (255, 0, 0)), + ), +) +def test_hex_colour_lengths(colour: bytes, expected: tuple[int, int, int]) -> None: + data = b"""/* XPM */ +static char *test[] = { +"1 1 1 1", +"a\tc %s", +"a"}; +""" % colour + with Image.open(BytesIO(data)) as im: + assert im.convert("RGB").getpixel((0, 0)) == expected + + def test_transparency_rgba() -> None: # With more than 256 colours and a transparent colour, # the image is opened as RGBA diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index ed277478066..19d0b2ac686 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -78,6 +78,14 @@ def _open(self) -> None: if rgb == b"None": transparent_key = c palette[c] = b"\0\0\0" + elif rgb.startswith(b"#") and len(rgb) in (4, 10, 13): + # #RGB, #RRRGGGBBB or #RRRRGGGGBBBB + digits = (len(rgb) - 1) // 3 + maxval = 16**digits - 1 + palette[c] = bytes( + (int(rgb[i : i + digits], 16) * 255 + maxval // 2) // maxval + for i in range(1, len(rgb), digits) + ) elif rgb.startswith(b"#"): rgb_int = int(rgb[1:], 16) palette[c] = ( From cbf17767f8506920469259d2dc1a580a61609e57 Mon Sep 17 00:00:00 2001 From: Andrei Bacescu <9010245+bacescuandrei@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:17:44 +0200 Subject: [PATCH 2/4] Decode short and long XPM hex colours like XParseColor libXpm passes colour specifications to XParseColor, which uses the hex digits as the most significant bits of each 16-bit channel instead of scaling them, so #F00 is 0xF000 and becomes 240 with 8 bits per channel. --- Tests/test_file_xpm.py | 4 ++-- src/PIL/XpmImagePlugin.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 72b2a48bca4..bc8dad02601 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -55,10 +55,10 @@ def test_transparency() -> None: @pytest.mark.parametrize( "colour, expected", ( - (b"#F00", (255, 0, 0)), + (b"#F00", (240, 0, 0)), (b"#FF8000", (255, 128, 0)), (b"#123456789", (18, 69, 120)), - (b"#C71BC30BC71B", (198, 194, 198)), + (b"#C71BC30BC71B", (199, 195, 199)), (b"#FFFF00000000", (255, 0, 0)), ), ) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 19d0b2ac686..51c1a5bf87e 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -79,11 +79,12 @@ def _open(self) -> None: transparent_key = c palette[c] = b"\0\0\0" elif rgb.startswith(b"#") and len(rgb) in (4, 10, 13): - # #RGB, #RRRGGGBBB or #RRRRGGGGBBBB + # #RGB, #RRRGGGBBB or #RRRRGGGGBBBB. libXpm parses colours + # with XParseColor, which uses the digits as the most + # significant bits of each channel instead of scaling them digits = (len(rgb) - 1) // 3 - maxval = 16**digits - 1 palette[c] = bytes( - (int(rgb[i : i + digits], 16) * 255 + maxval // 2) // maxval + int(rgb[i : i + digits], 16) << (16 - 4 * digits) >> 8 for i in range(1, len(rgb), digits) ) elif rgb.startswith(b"#"): From e59890c2f7a4205eadff6bb66653e79c22eee83e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 27 Oct 2026 23:25:07 +1100 Subject: [PATCH 3/4] Simplify code --- src/PIL/XpmImagePlugin.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 51c1a5bf87e..7ae73379bb2 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -78,22 +78,16 @@ def _open(self) -> None: if rgb == b"None": transparent_key = c palette[c] = b"\0\0\0" - elif rgb.startswith(b"#") and len(rgb) in (4, 10, 13): - # #RGB, #RRRGGGBBB or #RRRRGGGGBBBB. libXpm parses colours - # with XParseColor, which uses the digits as the most - # significant bits of each channel instead of scaling them + elif rgb.startswith(b"#"): + # #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB + # libXpm parses colours with XParseColor, + # which uses the digits as the most significant bits + # of each channel instead of scaling them digits = (len(rgb) - 1) // 3 palette[c] = bytes( int(rgb[i : i + digits], 16) << (16 - 4 * digits) >> 8 for i in range(1, len(rgb), digits) ) - elif rgb.startswith(b"#"): - rgb_int = int(rgb[1:], 16) - palette[c] = ( - o8((rgb_int >> 16) & 255) - + o8((rgb_int >> 8) & 255) - + o8(rgb_int & 255) - ) else: # unknown colour msg = "cannot read this XPM file" From e15314cdbfa2a27fe53e5ff40c19052416b902a2 Mon Sep 17 00:00:00 2001 From: Andrei Bacescu <9010245+bacescuandrei@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:17:58 +0200 Subject: [PATCH 4/4] Keep the previous reading for other XPM hex colour lengths Since the simplification, every # colour went through the per-channel decoding, so a value such as #1234 produced four palette bytes and shifted the next colour. Only 3, 6, 9 or 12 digits are decoded per channel again, and other lengths keep using the lowest 24 bits. --- Tests/test_file_xpm.py | 20 ++++++++++++++++++++ src/PIL/XpmImagePlugin.py | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index bc8dad02601..cc2ce15ead7 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -98,6 +98,26 @@ def test_truncated_header() -> None: pass +@pytest.mark.parametrize( + "colour, expected", ((b"#1234", (0, 18, 52)), (b"#12345678", (52, 86, 120))) +) +def test_other_hex_colour_lengths( + colour: bytes, expected: tuple[int, int, int] +) -> None: + # Other lengths keep using the lowest 24 bits, without shifting the next colour + data = b"""/* XPM */ +static char *test[] = { +"2 1 2 1", +"a\tc %s", +"b\tc #00ff00", +"ab"}; +""" % colour + with Image.open(BytesIO(data)) as im: + rgb = im.convert("RGB") + assert rgb.getpixel((0, 0)) == expected + assert rgb.getpixel((1, 0)) == (0, 255, 0) + + def test_cannot_read_color() -> None: with open(TEST_FILE, "rb") as fp: data = fp.read().split(b"#")[0] diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index 7ae73379bb2..e95eafcb3e3 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -78,7 +78,7 @@ def _open(self) -> None: if rgb == b"None": transparent_key = c palette[c] = b"\0\0\0" - elif rgb.startswith(b"#"): + elif rgb.startswith(b"#") and len(rgb) in (4, 7, 10, 13): # #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB # libXpm parses colours with XParseColor, # which uses the digits as the most significant bits @@ -88,6 +88,13 @@ def _open(self) -> None: int(rgb[i : i + digits], 16) << (16 - 4 * digits) >> 8 for i in range(1, len(rgb), digits) ) + elif rgb.startswith(b"#"): + rgb_int = int(rgb[1:], 16) + palette[c] = ( + o8((rgb_int >> 16) & 255) + + o8((rgb_int >> 8) & 255) + + o8(rgb_int & 255) + ) else: # unknown colour msg = "cannot read this XPM file"