Extraer ícono de un archivo ejecutable (Windows)

Extraer ícono de un archivo ejecutable (Windows)

La API de Windows provee la función ExtractIconExW para extraer íconos de archivos ejecutables (.exe) y también de los propios archivos de íconos (.ico). A menudo dentro de un archivo .ico, esté incluido en un ejecutable o no, yacen múltiples imágenes de diversos tamaños: 16×16, 32×32, 48×48, etc. Por lo general, en Windows los íconos que Microsoft llama «chicos» (casi siempre de 16×16) son los que aparecen en las ventanas y en el explorador de archivos, mientras que los íconos «grandes» (generalmente de 32×32) se muestran en la barra de tareas y cuando el usuario presiona Alt + Tab para cambiar de aplicación.

Si queremos llamar a ExtractIconExW desde Python para obtener el ícono chico o grande (estas son las únicas dos opciones de esa función) de un archivo ejecutable, la solución óptima debería ser pywin32, que provee una interfaz pythonica alrededor de la API de Windows, pensada para ser consumida desde C. El problema es que, lamentablemente, pywin32 no implementa la función GetDIBits, necesaria para leer el contenido de un ícono (esto es, el bitmap, el mapa de bits, cada uno de los píxeles del ícono) a partir de un HICON, que es lo que obtenemos con ExtractIconExW. Así las cosas, deberemos hacer uso del módulo estándar ctypes para acceder directamente a la API de Windows, como ya explicamos en otro artículo.

Entre la verbosidad propia de C, de las funciones de la API de Windows y del mismo módulo ctypes, el código se vuelve un tanto voluminoso. Así que creamos una función extract_icon() para realizar el trabajo, que recibe como argumento la ruta de un archivo ejecutable o de un archivo .ico y el tamaño de ícono deseado (chico o grande), y retorna el mapa de bits como un vector de C (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

Por ejemplo, el siguiente código usa esta función para extraer los íconos chico y grande del intérprete de Python, cuya ruta está en sys.executable:

import sys

# Extraer el ícono de (por lo general) 16x16 del intérprete de Python.
small_icon = extract_icon(sys.executable, IconSize.SMALL)
# Extraer el ícono de 32x32.
large_icon = extract_icon(sys.executable, IconSize.LARGE)

El resultado de extract_icon() es una secuencia de bytes (números entre 0 y 255). Cada píxel de un ícono ocupa cuatro bytes: tres para el color (en el orden BGR = azul, verde, rojo) y uno para la transparencia. Si la imagen tiene un tamaño de 16×16, entonces la secuencia tiene un total de 16x16x4 = 1024 bytes.

print(len(small_icon))  # 1024 (por lo general)

Nuestra función devuelve el contenido de la imagen de arriba hacia abajo y de izquierda a derecha. Es decir, los primeros cuatro bytes de small_icon representan el color del primer píxel en x=1 e y=1, los próximos cuatro el del píxel en x=2 e y=1, y así sucesivamente. Tras haber consumido los primeros 64 (16×4) bytes del vector, tendremos completa la primera fila de píxeles de la imagen. Los próximos 64 bytes corresponderán a la segunda fila de la imagen, y así sucesivamente, hasta llegar a consumir las 16 filas, dándonos un total de 1024 (16×64) bytes. En un ícono de 32×32 el procedimiento es el mismo, pero en lugar de 64 bytes, la primera fila estará constituida por los primeros 128 bytes (32 píxeles de ancho por 4 bytes para cada píxel), y en lugar de 16 filas habrá 32, con un total de 4096 (32×128) bytes.

Para poner un ejemplo sobre cómo debería leerse píxel por píxel, consideremos el siguiente código que usa PyGame para dibujar el ícono grande (32×32) del intérprete de Python extraído vía nuestra función extract_icon(). Luego de leer e insertar cada píxel se pausa la ejecución durante 0.010 segundos para que podamos apreciar el orden (de arriba hacia abajo y de izquierda a derecha) en que se lee la información. Para no repetir todo el código anterior, guardémoslo en un archivo llamado winicon.py y en otro archivo hagamos:

from winicon import IconSize, extract_icon
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()

Dibujar ícono con PyGame

Lo central del código radica entre las líneas 24 y 32, donde se lee fila por fila (row) y columna por columna (col) los píxeles de de la imagen. Nótese que en las líneas 27 y 28 se convierte el píxel del formato BGRA, empleado internamente por Windows, al formato RGBA que requiere PyGame.

Sin embargo, en la mayoría de los escenarios no tendremos que manipular el vector de píxeles directamente. Por ejemplo, si queremos guardar los dos íconos extraídos del intérprete de Python en los archivos python1.bmp y python2.bmp, podríamos usar la librería PIL/Pillow para que se ocupe de eso.

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


# Extraer los íconos del intérprete de Python.
small_icon = extract_icon(sys.executable, IconSize.SMALL)
large_icon = extract_icon(sys.executable, IconSize.LARGE)
# Convertirlos a imágenes de PIL/Pillow.
img_small = win32_icon_to_image(small_icon, IconSize.SMALL)
img_large = win32_icon_to_image(large_icon, IconSize.LARGE)
# Guardarlos en archivos.
img_small.save("python1.bmp")
img_large.save("python2.bmp")

Hemos añadido la función win32_icon_to_image() para convertir el vector de bytes retornado por GetDIBits a una instancia de la clase Image provista por PIL/Pillow.

Curso online 👨‍💻

¡Ya lanzamos el curso oficial de Recursos Python en Udemy! Un curso moderno para aprender Python desde cero con programación orientada a objetos, SQL y tkinter en 2024.

Consultoría 💡

Ofrecemos servicios profesionales de desarrollo y capacitación en Python a personas y empresas. Consultanos por tu proyecto.

Deja una respuesta