Skip to content

Fix reading XPM hex colours that do not have 2 digits per channel - #10315

Open
bacescuandrei wants to merge 5 commits into
python-pillow:mainfrom
bacescuandrei:fix/xpm-hex-colour-lengths
Open

bacescuandrei wants to merge 5 commits into
python-pillow:mainfrom
bacescuandrei:fix/xpm-hex-colour-lengths

Conversation

@bacescuandrei

@bacescuandrei bacescuandrei commented Oct 25, 2026 •

Copy link
Copy Markdown

XPM colours can use 1 to 4 hex digits per channel (#RGB, #RRGGBB, #RRRGGGBBB,
#RRRRGGGGBBBB): libXpm passes colour strings to Xlib's XParseColor, which accepts these four
forms. XpmImagePlugin parsed the value as one integer and used its lowest 24 bits, so only
#RRGGBB was right:

colour before after
#F00 (0, 15, 0) (240, 0, 0)
#FFFF00000000 (0, 0, 0) (255, 0, 0)
#C71BC30BC71B (11, 199, 27) (199, 195, 199)
#123456789 (69, 103, 137) (18, 69, 120)

The 12-digit form is used in real files, for example wanderlust etc/icons/news.xpm
(#9E799A699E79) and openbabel src/GUI/babel.xpm (#FFFFF7F7E7E7).

Changes proposed in this pull request:

  • Decode 1, 3 and 4 digit colours as XParseColor does: the digits are the most significant
    bits of each 16-bit channel ("#3a7 is the same as #3000a0007000", XParseColor(3)), and the
    top 8 bits are used, as an X server does for a 24-bit visual. #RRGGBB is unchanged.
  • Keep the lowest-24-bits reading for other lengths. XParseColor rejects them, but real files use 8 digits, for example MiniLibX textures, which that reading already decodes correctly.
  • Add a parametrized test.

Tests (Python 3.12): Tests/test_file_xpm.py 18 passed; the full suite on Ubuntu 24.04 before the
last commit: 5724 passed, 89 skipped, 3 xfailed. ruff check and black 26.10.0 are clean.

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.
@akx

akx commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Can you point out an XPM spec, or an XPM file in the wild, that shows these new color formats?

All I see in https://www.xfree86.org/current/xpm.pdf (though I don't know if there's a newer spec?) is

Colors can be specified by giving the colorname, a # followed by the RGB code in hexadecimal, or a % followed by the HSV code (not implemented).

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.
@bacescuandrei

Copy link
Copy Markdown
Author

The XPM manual doesn't define the hex syntax itself; libXpm hands the colour string to Xlib's XParseColor (src/create.c in libXpm). XParseColor(3) accepts #RGB, #RRGGBB, #RRRGGGBBB and #RRRRGGGGBBBB, and says: "When fewer than 16 bits each are specified, they represent the most significant bits of the value", e.g. #3a7 is the same as #3000a0007000. libX11's src/ParseCol.c accepts exactly 3, 6, 9 or 12 digits.

Files in the wild use the 12-digit form, for example:

  • wanderlust etc/icons/news.xpm: c #9E799A699E79, c #CF3CD34CCF3C
  • openbabel src/GUI/babel.xpm: c #FFFFF7F7E7E7, c #FFFFEFEFCECE

Pillow currently reads #9E799A699E79 as (105, 158, 121), the lowest 24 bits; libXpm gives (158, 154, 158).

Looking into this, I changed the conversion. Before, it scaled the values as ImageMagick does, so #F00 gave 255. Now it follows XParseColor: the digits become the most significant bits, so #F00 gives 240, and a 12-digit value keeps its top byte. That is also what an X server does on a 24-bit visual (miResolveColor keeps the top 8 bits). For the real colours above, and the test values, the result matches a port of ParseCol.c + miResolveColor; #RRGGBB is unchanged.

@Muguett-DBY Muguett-DBY left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this check that the hex length is exactly 3, 6, 9, or 12 before constructing the palette? A malformed colour can otherwise silently shift the next valid colour:

from io import BytesIO
from PIL import Image

data = b'''/* XPM */
static char *test[] = {
"2 1 2 1",
"a c #1234",
"b c #00ff00",
"ab"};
'''
with Image.open(BytesIO(data)) as im:
    rgb = im.convert("RGB")
    print(rgb.getpixel((1, 0)))

On the base this second pixel is green (0, 255, 0); with this patch it becomes (64, 0, 255). Four digits produce four bytes for the first palette entry, misaligning the following entry. Rejecting the invalid colour would avoid returning a corrupted image.

I exercised the exact base and PR _open methods using the existing Pillow 12.3 decoder; I did not run the full test suite.

Since the simplification, every # colour went through the per-channel
decoding, so a value such as python-pillow#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.
@bacescuandrei

Copy link
Copy Markdown
Author

Thanks, confirmed. Since e59890c every # colour goes through the per-channel decoding, so #1234 gives four palette bytes and the next colour is shifted.

I pushed a fix that only decodes 3, 6, 9 or 12 digits per channel (the lengths XParseColor accepts) and keeps the previous lowest-24-bits reading for other lengths. Your example now gives (0, 255, 0) for the second pixel again, and a new test covers #1234 and #12345678.

I didn't reject other lengths: test_undefined_color_key has a colour line without a trailing comma, so the plugin sees #00000 there, and rejecting it would change what main currently accepts.

@radarhere radarhere added the 🤖-assisted AI-assisted label Oct 30, 2026
@radarhere

radarhere commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

A malformed colour can otherwise

I find this confusing. You acknowledge that such a file would be malformed. I don't think we really know what would have been meant by #1234. If you know of a file that was written like this, let me know. If you don't, then... I'm not sure why we're spending time trying to make sure that a theoretical image is only slightly broken.

If you are treating this as a handwritten format, rather than a traditionally encoded image, and as such there may be mistakes, then I can understand that perspective. However, I think we would all agree that the programmer writing this image by hand won't get very far as a programmer if they don't understand that a single typo can cause problems in code.

@bacescuandrei

Copy link
Copy Markdown
Author

Agreed that #1234 is theoretical. I don't know of a file written like that, and libXpm wouldn't load one anyway, because XParseColor only accepts 3, 6, 9 or 12 digits.

8-digit colours are different, though, and I should have led with them. They are just as invalid for libXpm, but real files use them. A GitHub code search aimed at unusual colours returned 218 XPM files, and 46 of them have 8-digit colours, all game textures from so_long and cub3D projects. That search was targeted, so it says nothing about how common they are overall. It found no 4, 5, 7, 10 or 11 digit colours.

Those projects use MiniLibX, which reads the value with strtol(name+1,0,16), so the RGB value is in the low 24 bits. That is what main already does. Removing the check, as in e59890c, turns these files from correct colours into a shifted palette:

file first colours main and this PR without the check
mm00.xpm #00121211 #ff000000 #00b1b5a9 (18, 18, 17) (0, 0, 0) (177, 181, 169) (0, 18, 18) (17, 255, 0) (0, 0, 0)
arrow.xpm #FF000000 #696969 #ED1C24 (0, 0, 0) (105, 105, 105) (237, 28, 36) (255, 0, 0) (0, 105, 105) (105, 237, 28)
BrickHouse.xpm #FF000000 #080E17 #2C110E (0, 0, 0) (8, 14, 23) (44, 17, 14) (255, 0, 0) (0, 8, 14) (23, 44, 17)
numsy16-00.xpm #00000000 #FFFFFF00 (0, 0, 0) (255, 255, 0) (0, 0, 0) (0, 255, 255)

Eight files from these projects were compared, these four and four others. All eight decode to identical pixels on main and on this PR, and all eight change without the check. Pillow ignores the top byte on both.

So I'd keep the check and leave the code as it is. It isn't there to make a broken image slightly less broken. It keeps this PR from changing colours that main already reads correctly.

The one thing I'd happily drop is #1234 in test_other_hex_colour_lengths, keeping only #12345678. Let me know if you want that, or push it directly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖-assisted AI-assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants