Extract Icon From Executable File (Windows)

The Windows API provides the ExtractIconExW function to extract icons from executable files (.exe) and from the icon files themselves (.ico). Inside a .ico file, whether it is included in an executable or not, there are often multiple images of various sizes: 16x16, 32x32, 48x48, etc. Icons that Microsoft calls "small" (usually 16x16) are generally the ones that appear in windows and the file explorer, while "large" icons (usually 32x32) are shown in the taskbar and when the user presses Alt + Tab to switch between applications.

If you want to call ExtractIconExW from Python to get the small or large icon (the function only supports these two sizes) of an executable file, the optimal solution should be pywin32, which provides a pythonic interface around the Windows API, intended to be consumed from C. Unfortunately, pywin32 does not implement the GetDIBits function, necessary to read the content (= the bitmap, each pixel) of an icon from a HICON, which is what we get from ExtractIconExW. Thus, we must make use of the old reliable, low-level ctypes standard module to access the Windows API.

Between the verbosity of C, the Windows API functions, and the ctypes module itself, the code gets a bit bulky. So we created a extract_icon() function to do the job, which takes the path of an executable or .ico file and the desired icon size (small or large), and returns the bitmap as a C array (ctypes.Array).

from ctypes import Array, byref, c_char, memset, sizeof
from ctypes import c_int, c_void_p, POINTER
from ctypes.wintypes import *
from enum import Enum
import ctypes
BI_RGB = 0
DIB_RGB_COLORS = 0
class ICONINFO(ctypes.Structure):
    _fields_ = [
        ("fIcon", BOOL),
        ("xHotspot", DWORD),
        ("yHotspot", DWORD),
        ("hbmMask", HBITMAP),
        ("hbmColor", HBITMAP)
    ]
class RGBQUAD(ctypes.Structure):
    _fields_ = [
        ("rgbBlue", BYTE),
        ("rgbGreen", BYTE),
        ("rgbRed", BYTE),
        ("rgbReserved", BYTE),
    ]
class BITMAPINFOHEADER(ctypes.Structure):
    _fields_ = [
        ("biSize", DWORD),
        ("biWidth", LONG),
        ("biHeight", LONG),
        ("biPlanes", WORD),
        ("biBitCount", WORD),
        ("biCompression", DWORD),
        ("biSizeImage", DWORD),
        ("biXPelsPerMeter", LONG),
        ("biYPelsPerMeter", LONG),
        ("biClrUsed", DWORD),
        ("biClrImportant", DWORD)
    ]
class BITMAPINFO(ctypes.Structure):
    _fields_ = [
        ("bmiHeader", BITMAPINFOHEADER),
        ("bmiColors", RGBQUAD * 1),
    ]
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
user32 = ctypes.WinDLL("user32", use_last_error=True)
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
gdi32.CreateCompatibleDC.argtypes = [HDC]
gdi32.CreateCompatibleDC.restype = HDC
gdi32.GetDIBits.argtypes = [
    HDC, HBITMAP, UINT, UINT, LPVOID, c_void_p, UINT
]
gdi32.GetDIBits.restype = c_int
gdi32.DeleteObject.argtypes = [HGDIOBJ]
gdi32.DeleteObject.restype = BOOL
shell32.ExtractIconExW.argtypes = [
    LPCWSTR, c_int, POINTER(HICON), POINTER(HICON), UINT
]
shell32.ExtractIconExW.restype = UINT
user32.GetIconInfo.argtypes = [HICON, POINTER(ICONINFO)]
user32.GetIconInfo.restype = BOOL
user32.DestroyIcon.argtypes = [HICON]
user32.DestroyIcon.restype = BOOL
class IconSize(Enum):
    SMALL = 1
    LARGE = 2
    @staticmethod
    def to_wh(size: "IconSize") -> tuple[int, int]:
        """
        Return the actual (width, height) values for the specified icon size.
        """
        size_table = {
            IconSize.SMALL: (16, 16),
            IconSize.LARGE: (32, 32)
        }
        return size_table[size]
def extract_icon(filename: str, size: IconSize) -> Array[c_char]:
    """
    Extract the icon from the specified `filename`, which might be
    either an executable or an `.ico` file.
    """
    dc: HDC = gdi32.CreateCompatibleDC(0)
    if dc == 0:
        raise ctypes.WinError()
    hicon: HICON = HICON()
    extracted_icons: UINT = shell32.ExtractIconExW(
        filename,
        0,
        byref(hicon) if size == IconSize.LARGE else None,
        byref(hicon) if size == IconSize.SMALL else None,
        1
    )
    if extracted_icons != 1:
        raise ctypes.WinError()
    def cleanup() -> None:
        if icon_info.hbmColor != 0:
            gdi32.DeleteObject(icon_info.hbmColor)
        if icon_info.hbmMask != 0:
            gdi32.DeleteObject(icon_info.hbmMask)
        user32.DestroyIcon(hicon)
    icon_info: ICONINFO = ICONINFO(0, 0, 0, 0, 0)
    if not user32.GetIconInfo(hicon, byref(icon_info)):
        cleanup()
        raise ctypes.WinError()
    w, h = IconSize.to_wh(size)
    bmi: BITMAPINFO = BITMAPINFO()
    memset(byref(bmi), 0, sizeof(bmi))
    bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER)
    bmi.bmiHeader.biWidth = w
    bmi.bmiHeader.biHeight = -h
    bmi.bmiHeader.biPlanes = 1
    bmi.bmiHeader.biBitCount = 32
    bmi.bmiHeader.biCompression = BI_RGB
    bmi.bmiHeader.biSizeImage = w * h * 4
    bits = ctypes.create_string_buffer(bmi.bmiHeader.biSizeImage)
    copied_lines = gdi32.GetDIBits(
        dc, icon_info.hbmColor, 0, h, bits, byref(bmi), DIB_RGB_COLORS
    )
    if copied_lines == 0:
        cleanup()
        raise ctypes.WinError()
    cleanup()
    return bits

For example, the following code uses this function to extract the small and large icons from the Python interpreter, whose path is in sys.executable:

import sys
# Extract the (usually) 16x16 icon from the Python interpreter.
small_icon = extract_icon(sys.executable, IconSize.SMALL)
# Extract the 32x32 icon.
large_icon = extract_icon(sys.executable, IconSize.LARGE)

The result of extract_icon() is a sequence of bytes (numbers between 0 and 255). Each pixel of an icon occupies four bytes: three bytes for the color (in BGR order = blue, green, red) and one for transparency. If the image is 16x16, then the sequence has a total of 16x16x4 = 1024 bytes.

print(len(small_icon))  # 1024 (usually)

Our function returns the content of the image from top to bottom and from left to right. That is, the first four bytes of small_icon represent the color of the first pixel at x=1 and y=1, the next four represent that of the pixel at x=2 and y=1, and so on. After having consumed the first 64 (16x4) bytes of the array, we will have the first row of pixels of the image complete. The next 64 bytes will contain the second row of the image, and so on, until all 16 rows are consumed, giving us a total of 1024 (16x64) bytes. In a 32x32 icon the procedure is the same, but instead of 64 bytes, the first row will be made up of the first 128 bytes (32 pixels wide by 4 bytes for each pixel), and instead of 16 rows there will be 32, making a total of 4096 (32x128) bytes.

To understand how to read pixel by pixel from the bitmap array, consider the following code that uses PyGame to draw the large (32x32) extracted icon of the Python interpreter via our extract_icon() function. After reading and drawing each pixel, the execution is paused for 0.010 seconds so that we can appreciate the order (from top to bottom and from left to right) in which the information is read. In order not to repeat all the previous code, let's save it in a file called winicon.py and in another file let's do:

from winicon import IconSize, extract_icon      # Import the previous code.
import pygame
import sys
pygame.init()
size = width, height = 320, 240
black = 0, 0, 0
screen = pygame.display.set_mode(size)
icon_size = IconSize.LARGE
w, h = IconSize.to_wh(icon_size)
icon_large = extract_icon(sys.executable, icon_size)
rendered = False
offset_x = 50
offset_y = 50
while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
    screen.fill(black)
    for row in range(0, h):
        for col in range(0, w):
            index = row*w*4 + col*4
            b, g, r, a = icon_large[index:index + 4]
            color = r, g, b, a
            screen.set_at((offset_x + col, offset_y + row), color)
            if not rendered:
                pygame.time.wait(10)
                pygame.display.flip()
    rendered = True
    pygame.display.flip()
/images/extract-icon-from-executable-file-windows/draw-icon-pygame.gif

The core of the code lies between lines 24 and 32, where the pixels of the image are read row by row and column by column. Note that lines 27 and 28 convert the pixel from the BGRA format, used internally by Windows, to the RGBA format required by PyGame.

However, in most scenarios we won't need to manipulate the pixel array directly. For example, if we want to save the two icons extracted from the Python interpreter in the files python1.bmp and python2.bmp, we could just use the PIL/Pillow:

from ctypes import Array, c_char
from PIL import Image
from winicon import extract_icon, IconSize
import sys
def win32_icon_to_image(icon_bits: Array[c_char], size: IconSize) -> Image:
    """
    Convert a Windows GDI bitmap to a PIL `Image` instance.
    """
    w, h = IconSize.to_wh(size)
    img = Image.frombytes("RGBA", (w, h), icon_bits, "raw", "BGRA")
    return img
# Extract the icons from the Python interpreter.
small_icon = extract_icon(sys.executable, IconSize.SMALL)
large_icon = extract_icon(sys.executable, IconSize.LARGE)
# Convert them to PIL/Pillow images.
img_small = win32_icon_to_image(small_icon, IconSize.SMALL)
img_large = win32_icon_to_image(large_icon, IconSize.LARGE)
# Save them to files.
img_small.save("python1.bmp")
img_large.save("python2.bmp")

We added the win32_icon_to_image() function to convert the array of bytes returned by GetDIBits to an instance of the Image class provided by PIL/Pillow.

Comments