Files
twblue/src/mysc/image_utils.py
T
Juanjo M b6681df03c Fix "Unknown image format" error when showing or updating profiles
wx only decodes the image formats its own handlers support. Mastodon
instances commonly serve avatars and headers as WebP, so wx.Image failed
and raised a modal "Unknown image format" dialog, which blocked the user
from updating their profile or from reading someone's profile.

Images are now decoded with Pillow and their raw pixel data handed over
to wx, through a new mysc.image_utils module shared by the Mastodon and
Bluesky profile dialogs. Undecodable or unreachable images log the
problem and leave an empty placeholder instead of raising.

Also accept WebP and JPEG files in the header/avatar file pickers, warn
the user with a common message dialog when the picked file cannot be
read, and fix the parentless wx.StaticBitmap placeholders that were
created when a profile image could not be downloaded.
2026-08-02 15:08:25 +01:00

78 lines
3.0 KiB
Python

# -*- coding: utf-8 -*-
""" Helpers to turn arbitrary image data into wx.Image objects.
wx only knows about the image formats its own handlers support. Fediverse
instances (and Bluesky) happily serve avatars and headers in formats such as
WebP, which makes wx.Image fail and pop up a modal "Unknown image format"
error dialog, blocking the user. Pillow understands many more formats, so we
decode everything with it and hand the raw pixel data over to wx.
"""
from io import BytesIO
from logging import getLogger
from typing import Optional, Tuple, Union
import wx
from PIL import Image as PILImage
log = getLogger("mysc.image_utils")
ImageSource = Union[bytes, bytearray, str]
#: Pillow modes that carry per pixel transparency.
_TRANSPARENT_MODES = ("RGBA", "LA", "PA")
def decode_image(source: ImageSource) -> Tuple[int, int, bytes, Optional[bytes]]:
""" Decodes an image into raw pixel data suitable for wx.
:param source: Raw image bytes or a path to an image file.
:returns: A (width, height, rgb_data, alpha_data) tuple. alpha_data is None
when the image has no transparency.
:raises Exception: Whatever Pillow raises when the data cannot be decoded.
"""
if isinstance(source, (bytes, bytearray)):
source = BytesIO(bytes(source))
with PILImage.open(source) as image:
image.load()
has_alpha = image.mode in _TRANSPARENT_MODES or (image.mode == "P" and "transparency" in image.info)
if has_alpha:
image = image.convert("RGBA")
return image.width, image.height, image.convert("RGB").tobytes(), image.getchannel("A").tobytes()
image = image.convert("RGB")
return image.width, image.height, image.tobytes(), None
def load_image(source: ImageSource) -> Optional[wx.Image]:
""" Builds a wx.Image from raw image bytes or a path to an image file.
:param source: Raw image bytes or a path to an image file.
:returns: The decoded wx.Image, or None if the data could not be decoded.
Callers are expected to skip drawing when None is returned, instead of
letting wx show its own error dialog.
"""
try:
width, height, rgb_data, alpha_data = decode_image(source)
except Exception:
log.exception("Unable to decode image data.")
return None
image = wx.Image(width, height)
image.SetData(rgb_data)
if alpha_data is not None:
image.SetAlpha(alpha_data)
return image
def load_scaled_image(source: ImageSource, width: int, height: int) -> Optional[wx.Image]:
""" Same as load_image, but rescales the result to the given size.
:param source: Raw image bytes or a path to an image file.
:param width: Width, in pixels, of the resulting image.
:param height: Height, in pixels, of the resulting image.
:returns: The rescaled wx.Image, or None if the data could not be decoded.
"""
image = load_image(source)
if image is None:
return None
image.Rescale(width, height, wx.IMAGE_QUALITY_HIGH)
return image