Pillow icon indicating copy to clipboard operation
Pillow copied to clipboard

Image.open is generating a warning with icon files that contain PNG images

Open phpjunkie420 opened this issue 6 months ago • 5 comments

I'm posting this 'cause of a warning I've gotten when opening icon files, and what I did to get around the warning.

  • OS: Windows 11 24H2 (OS Build 261000.4315) 10.0.26100 <- this was generated by python, and python could have miss reporting my actual version number 'cause of my heavily modified installation of windows.
  • Python: 3.12.9
  • Pillow: 11.1

An .ico file is a container designed to hold multiple images at different sizes and color depths (e.g., 16x16, 32x32, 256x256 pixels). This allows Windows to pick the best-looking image for a specific situation, like the taskbar, a desktop shortcut, or a detailed view in Explorer.

  • Historically, these embedded images were always simple bitmaps (DIBs).
  • Today, you can place a compressed PNG image inside that container instead. This is highly recommended because PNGs offer excellent compression and full alpha transparency, resulting in smaller files and better-looking icons. .ico

The StackOverflow.ico contains 256x256, 512x512, 768x768, and 1024x1024 PNG images, plus the typical bitmap images (16x16 20x20, 24x24, 32x32, etc.).

import io
import struct
import typing
import pathlib

from PIL import Image

# PIL\IcoImagePlugin.py:357: UserWarning: Image was not the expected size warnings.warn("Image was not the expected size")
icon_file = pathlib.Path(__file__).parent / 'StackOverflow.ico'
image = Image.open(str(icon_file)) 

# workaround
icon_data = icon_file.read_bytes()

count = struct.unpack("<H", icon_data[4:6])[0]
for i in range(count):
    entry_offset = 6 + (16 * i)
    if entry_offset + 16 > len(icon_data):
        continue

    length = struct.unpack("<I", icon_data[entry_offset + 8:entry_offset + 12])[0]
    offset = struct.unpack("<I", icon_data[entry_offset + 12:entry_offset + 16])[0]

    image: typing.Optional[Image.Image] = None
    with io.BytesIO() as byte_stream:
        image_content = icon_data[offset:offset + length]
        if image_content.startswith(b'\x89PNG\r\n\x1a\n'):
            byte_stream.write(image_content)
        else:
            byte_stream.write(b'\x00\x00\x01\x00')
            byte_stream.write(struct.pack("<H", 1))
            byte_stream.write(icon_data[entry_offset:entry_offset + 12])
            byte_stream.write(struct.pack("<I", 22))
            byte_stream.write(image_content)

        byte_stream.seek(0)
        image = Image.open(byte_stream)
        image.load()

    with image:
        output_path = icon_file.parent / 'out' / f'icon_{image.width}x{image.height}.png'
        output_path.parent.mkdir(parents = True, exist_ok = True)
        image.save(fp = str(output_path), format = 'png' )

    print(output_path.name)

Through trial and error, I was able to figure out that it was these bytes above the PNG images that is generating the warning . . .

byte_stream.write(b'\x00\x00\x01\x00')
byte_stream.write(struct.pack("<H", 1))
byte_stream.write(icon_data[entry_offset:entry_offset + 12])
byte_stream.write(struct.pack("<I", 22))

StackOverflow.zip

phpjunkie420 avatar Jun 13 '25 01:06 phpjunkie420

Hi. If you just posted this so that others can find it, ok, great, thanks for contributing to the overall pool of knowledge about Pillow.

If you'd like us to alter Pillow to better support these images, please provide an example file. Ideally, one that we could include in our test suite and distributed under the Pillow license.

For the moment, I downloaded the StackOverflow favicon, from https://cdn.sstatic.net/Sites/stackoverflow/Img/favicon.ico?v=ec617d715196, and was able to open and load it with Pillow without any warnings.

radarhere avatar Jun 13 '25 02:06 radarhere

I couldn't attach the .ico file itself. The file type isn't supported, but I was able to zip it up and provide the zip file in my original post.

phpjunkie420 avatar Jun 13 '25 04:06 phpjunkie420

Thanks. Investigating, the size mismatch is because the header thinks the first image is (256, 256), but the first image is loaded by Pillow as (1024, 1024).

Converting to a PNG with ImageMagick, I get a (1024, 1024) image as the first frame.

Looking at through the code, the first image is set by https://github.com/python-pillow/Pillow/blob/a76dca9c459899a7022bde0fe04c35c36985690a/src/PIL/IcoImagePlugin.py#L160-L162

'See Wikipedia' would refer to https://en.wikipedia.org/wiki/ICO_(file_format)#Outline, where for the width and height,

Value 0 means image width is 256 pixels

So I think Pillow is operating correctly here, and the image is imperfect.

radarhere avatar Jun 13 '25 08:06 radarhere

You are correct on 0x0 being a default to 256x256, and all of the PNG images are 0x0 in the icon files, regardless of their actual size. It is why I'm seeing this warning.

In my original post, I got around this warning by getting the bytes for just the PNG images themselves.


The Core Problem: A Single Byte

The original file format specification was created in the early days of Windows when icons were small (e.g., 16x16, 32x32). .ico

  • The bWidth and bHeight fields in the ICONDIRENTRY structure were defined as being only one byte each.
  • A single unsigned byte can only hold values from 0 to 255.
  • Therefore, it is physically impossible to store the number 256 in the bWidth or bHeight field.

The Solution: A Clever "Hack"

When Microsoft introduced support for large 256x256 icons in Windows Vista, they had to work around this limitation. Instead of creating a whole new file format and breaking every existing icon parser, they repurposed a value.

Since an icon with a width or height of 0 is nonsensical and would never exist, they established a new rule:

If the bWidth or bHeight field contains a 0, it should be interpreted as 256.

Here is a simple table illustrating the rule:

ICONDIRENTRY bWidth / bHeight Value Actual Dimension in Pixels
1 to 255 1 to 255
0 256

So How Does a Program Know the Real Size?

This is why a robust icon parser cannot trust the ICONDIRENTRY for image dimensions when the format is PNG. The directory entry is merely a hint.

The correct parsing workflow is:

  1. Read the ICONDIRENTRY for an image.
  2. See that bWidth and bHeight are both 0. This tells the parser two things:
    • The dimensions are probably 256x256.
    • The image data is almost certainly in PNG format (as this convention was introduced along with PNG support).
  3. Jump to the dwImageOffset to find the start of the image data.
  4. Completely ignore the 0x0 dimensions from the directory. Instead, parse the image data itself to find the true dimensions. For a PNG, this means reading the IHDR chunk's width and height fields, which will correctly state 1024x1024.

A lazy parser that trusts the 0x0 from the directory would fail, whereas a correct parser understands this special rule and knows to look inside the PNG stream for the ground truth.

phpjunkie420 avatar Jun 13 '25 19:06 phpjunkie420

It sounds to me like this is just a limitation of the format, conflicting with Pillow's model - we presume that you can read the header of an image and know the size.

https://github.com/python-pillow/Pillow/pull/7311#issuecomment-1723004045

For me this is one of the key concepts in Pillow: you can safely open the image file without decoding the whole image (which is much more expensive operation) and get any info from the file.

We do ultimately use the 'real size' after loading, and tell the user through a warning that the initial size was incorrect.

radarhere avatar Jun 14 '25 02:06 radarhere