Merge pull request #978 from kastwey/fix/unknown-image-format-profiles

Fix "Unknown image format" error when showing or updating profiles
This commit is contained in:
2026-08-02 21:15:34 -06:00
committed by GitHub
8 changed files with 241 additions and 42 deletions
+1
View File
@@ -53,6 +53,7 @@ This version introduces comprehensive support for the AT Protocol (ATProto), ena
* Mastodon: * Mastodon:
* Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860)) * Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860))
* Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893)) * Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893))
* Fixed an error that displayed an "Unknown image format" dialog when viewing someone's profile or updating your own, if the avatar or header was served in a format TWBlue could not read, such as WebP. ([#977](https://github.com/mcv-software/twblue/issues/977))
## Changes in version 2026.01.13 ## Changes in version 2026.01.13
+77
View File
@@ -0,0 +1,77 @@
# -*- 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
View File
+97
View File
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
""" Tests for mysc.image_utils.
These are regression tests for the "Unknown image format" modal error dialog
that wx raised when a Mastodon instance served profile pictures in a format
wx cannot decode by itself, such as WebP.
"""
from io import BytesIO
import pytest
import wx
from PIL import Image as PILImage
from mysc import image_utils
@pytest.fixture(scope="module")
def app():
""" wx needs an application object before image objects can be created. """
application = wx.App()
yield application
application.Destroy()
def make_image_bytes(image_format, mode="RGB", size=(64, 32), color=(10, 20, 30)):
""" Generates an in memory image in the requested format. """
image = PILImage.new(mode, size, color)
buffer = BytesIO()
image.save(buffer, format=image_format)
return buffer.getvalue()
@pytest.mark.parametrize("image_format", ["PNG", "JPEG", "GIF", "WEBP", "BMP"])
def test_decode_image_supports_common_formats(image_format):
""" All formats served by fediverse instances should decode, WebP included. """
width, height, rgb_data, alpha_data = image_utils.decode_image(make_image_bytes(image_format))
assert (width, height) == (64, 32)
assert len(rgb_data) == 64 * 32 * 3
assert alpha_data is None
def test_decode_image_keeps_alpha_channel():
""" Transparent images should keep their alpha channel separated for wx. """
data = make_image_bytes("PNG", mode="RGBA", color=(10, 20, 30, 128))
width, height, rgb_data, alpha_data = image_utils.decode_image(data)
assert len(rgb_data) == width * height * 3
assert alpha_data is not None
assert len(alpha_data) == width * height
assert set(alpha_data) == {128}
def test_decode_image_accepts_a_path(tmp_path):
""" Images picked by the user are passed around as filesystem paths. """
path = tmp_path / "avatar.webp"
path.write_bytes(make_image_bytes("WEBP"))
width, height, rgb_data, alpha_data = image_utils.decode_image(str(path))
assert (width, height) == (64, 32)
assert len(rgb_data) == 64 * 32 * 3
def test_decode_image_raises_on_invalid_data():
with pytest.raises(Exception):
image_utils.decode_image(b"this is definitely not an image")
@pytest.mark.parametrize("image_format", ["PNG", "JPEG", "GIF", "WEBP"])
def test_load_image_returns_a_wx_image(app, image_format):
image = image_utils.load_image(make_image_bytes(image_format))
assert image is not None
assert image.IsOk()
assert (image.GetWidth(), image.GetHeight()) == (64, 32)
def test_load_image_sets_alpha_on_transparent_images(app):
image = image_utils.load_image(make_image_bytes("PNG", mode="RGBA", color=(10, 20, 30, 128)))
assert image is not None
assert image.HasAlpha()
def test_load_image_returns_none_on_invalid_data(app):
""" Broken or unsupported data must not raise, so no error dialog is shown. """
assert image_utils.load_image(b"not an image at all") is None
def test_load_image_returns_none_on_empty_data(app):
""" Servers answering with an empty body should not break profile dialogs. """
assert image_utils.load_image(b"") is None
def test_load_scaled_image_rescales(app):
image = image_utils.load_scaled_image(make_image_bytes("WEBP"), 150, 150)
assert image is not None
assert (image.GetWidth(), image.GetHeight()) == (150, 150)
def test_load_scaled_image_returns_none_on_invalid_data(app):
assert image_utils.load_scaled_image(b"nope", 150, 150) is None
+3
View File
@@ -64,3 +64,6 @@ def common_error(message):
"""Show a generic error dialog with the provided message.""" """Show a generic error dialog with the provided message."""
dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR) dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR)
return dlg.ShowModal() return dlg.ShowModal()
def unsupported_image():
return wx.MessageDialog(None, _("TWBlue was unable to load the selected image. Please make sure the file is a valid image and try again."), _("Error"), wx.ICON_ERROR).ShowModal()
+6 -5
View File
@@ -4,10 +4,11 @@ import logging
import languageHandler import languageHandler
import builtins import builtins
import requests import requests
from io import BytesIO
from threading import Thread from threading import Thread
from pubsub import pub from pubsub import pub
from mysc.image_utils import load_scaled_image
_ = getattr(builtins, "_", lambda s: s) _ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -233,13 +234,13 @@ class ShowUserProfileDialog(wx.Dialog):
"""Draws downloaded images on the bitmap controls.""" """Draws downloaded images on the bitmap controls."""
try: try:
if banner_bytes: if banner_bytes:
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY) banner_image = load_scaled_image(banner_bytes, 300, 100)
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH) if banner_image is not None:
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap()) self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
if avatar_bytes: if avatar_bytes:
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY) avatar_image = load_scaled_image(avatar_bytes, 150, 150)
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if avatar_image is not None:
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap()) self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
self.Layout() self.Layout()
+5 -5
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Wx dialogs for showing a user's profile.""" """Wx dialogs for showing a user's profile."""
from io import BytesIO
from pubsub import pub from pubsub import pub
from typing import Tuple from typing import Tuple
import requests import requests
@@ -10,6 +9,7 @@ from logging import getLogger
from threading import Thread from threading import Thread
from sessions.mastodon.utils import html_filter from sessions.mastodon.utils import html_filter
from mysc.image_utils import load_scaled_image
log = getLogger(__name__) log = getLogger(__name__)
@@ -229,11 +229,11 @@ class ShowUserProfile(wx.Dialog):
"""Draws images on the bitmap ui""" """Draws images on the bitmap ui"""
# log.debug("Drawing images...") # log.debug("Drawing images...")
# Header # Header
headerImage = wx.Image(BytesIO(headerImageBytes), wx.BITMAP_TYPE_ANY) headerImage = load_scaled_image(headerImageBytes, 300, 100)
headerImage.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH) if headerImage is not None:
self.headerImage.SetBitmap(headerImage.ConvertToBitmap()) self.headerImage.SetBitmap(headerImage.ConvertToBitmap())
# Avatar # Avatar
avatarImage = wx.Image(BytesIO(avatarImageBytes), wx.BITMAP_TYPE_ANY) avatarImage = load_scaled_image(avatarImageBytes, 150, 150)
avatarImage.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if avatarImage is not None:
self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap()) self.avatarImage.SetBitmap(avatarImage.ConvertToBitmap())
+48 -28
View File
@@ -1,15 +1,43 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
import requests from logging import getLogger
from io import BytesIO from typing import Optional
import requests
import wx import wx
from mysc.image_utils import load_scaled_image
from wxUI import commonMessageDialogs
log = getLogger("wxUI.dialogs.mastodon.updateProfile")
#: Formats accepted when the user picks a new header or avatar.
IMAGE_WILDCARD = "Images (*.png;*.jpg;*.jpeg;*.gif;*.webp)|*.png;*.jpg;*.jpeg;*.gif;*.webp"
def return_true(): def return_true():
return True return True
def download_scaled_image(url: str, width: int, height: int) -> Optional[wx.Image]:
""" Downloads an image and decodes it, scaled to the given size.
:param url: URL of the image to download.
:param width: Width, in pixels, of the resulting image.
:param height: Height, in pixels, of the resulting image.
:returns: The decoded wx.Image, or None if it could not be downloaded or decoded.
"""
if not url:
return None
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
except requests.exceptions.RequestException:
log.exception("Unable to download image from %s", url)
return None
return load_scaled_image(response.content, width, height)
class UpdateProfileDialog(wx.Dialog): class UpdateProfileDialog(wx.Dialog):
""" """
A dialog for user to update his / her profile details. A dialog for user to update his / her profile details.
@@ -56,16 +84,12 @@ class UpdateProfileDialog(wx.Dialog):
# header # header
header_label = wx.StaticText(panel, label=_("Header")) header_label = wx.StaticText(panel, label=_("Header"))
try: header_bitmap = download_scaled_image(self.header, 300, 100)
response = requests.get(self.header) if header_bitmap is None:
except requests.exceptions.RequestException: # The image is unavailable or in a format we cannot decode, so show an empty placeholder.
# Create empty image self.header_image = wx.StaticBitmap(panel)
self.header_image = wx.StaticBitmap()
else: else:
image_bytes = BytesIO(response.content) self.header_image = wx.StaticBitmap(panel, bitmap=header_bitmap.ConvertToBitmap())
image = wx.Image(image_bytes, wx.BITMAP_TYPE_ANY)
image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.header_image = wx.StaticBitmap(panel, bitmap=image.ConvertToBitmap())
self.header_image.AcceptsFocusFromKeyboard = return_true self.header_image.AcceptsFocusFromKeyboard = return_true
self.change_header = wx.Button(panel, label=_("Change &header")) self.change_header = wx.Button(panel, label=_("Change &header"))
@@ -77,16 +101,12 @@ class UpdateProfileDialog(wx.Dialog):
# avatar # avatar
avatar_label = wx.StaticText(panel, label=_("Avatar")) avatar_label = wx.StaticText(panel, label=_("Avatar"))
try: avatar_bitmap = download_scaled_image(self.avatar, 150, 150)
response = requests.get(self.avatar) if avatar_bitmap is None:
except requests.exceptions.RequestException: # The image is unavailable or in a format we cannot decode, so show an empty placeholder.
# Create empty image self.avatar_image = wx.StaticBitmap(panel)
self.avatar_image = wx.StaticBitmap()
else: else:
image_bytes = BytesIO(response.content) self.avatar_image = wx.StaticBitmap(panel, bitmap=avatar_bitmap.ConvertToBitmap())
image = wx.Image(image_bytes, wx.BITMAP_TYPE_ANY)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatar_image = wx.StaticBitmap(panel, bitmap=image.ConvertToBitmap())
self.avatar_image.AcceptsFocusFromKeyboard = return_true self.avatar_image.AcceptsFocusFromKeyboard = return_true
self.change_avatar = wx.Button(panel, label=_("Change &avatar")) self.change_avatar = wx.Button(panel, label=_("Change &avatar"))
@@ -163,8 +183,7 @@ class UpdateProfileDialog(wx.Dialog):
def on_change_header(self, *args): def on_change_header(self, *args):
"""Display a dialog for the user to choose a picture and update the """Display a dialog for the user to choose a picture and update the
appropriate attribute""" appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif" dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=IMAGE_WILDCARD)
dlg = wx.FileDialog(self, _("Select header image - max 2MB"), wildcard=wildcard)
if dlg.ShowModal() == wx.CLOSE: if dlg.ShowModal() == wx.CLOSE:
return return
if os.path.getsize(dlg.GetPath()) > 2097152: if os.path.getsize(dlg.GetPath()) > 2097152:
@@ -178,15 +197,15 @@ class UpdateProfileDialog(wx.Dialog):
return self.on_change_header() if result == wx.YES else None return self.on_change_header() if result == wx.YES else None
self.header = dlg.GetPath() self.header = dlg.GetPath()
image = wx.Image(self.header, wx.BITMAP_TYPE_ANY) image = load_scaled_image(self.header, 150, 150)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if image is None:
return commonMessageDialogs.unsupported_image()
self.header_image.SetBitmap(image.ConvertToBitmap()) self.header_image.SetBitmap(image.ConvertToBitmap())
def on_change_avatar(self, *args): def on_change_avatar(self, *args):
"""Display a dialog for the user to choose a picture and update the """Display a dialog for the user to choose a picture and update the
appropriate attribute""" appropriate attribute"""
wildcard = "Images (*.png;*.jpg;*.gif)|*.png;*.jpg;*.gif" dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=IMAGE_WILDCARD)
dlg = wx.FileDialog(self, _("Select avatar image - max 2MB"), wildcard=wildcard)
if dlg.ShowModal() == wx.CLOSE: if dlg.ShowModal() == wx.CLOSE:
return return
if os.path.getsize(dlg.GetPath()) > 2097152: if os.path.getsize(dlg.GetPath()) > 2097152:
@@ -200,6 +219,7 @@ class UpdateProfileDialog(wx.Dialog):
return self.on_change_avatar() if result == wx.YES else None return self.on_change_avatar() if result == wx.YES else None
self.avatar = dlg.GetPath() self.avatar = dlg.GetPath()
image = wx.Image(self.avatar, wx.BITMAP_TYPE_ANY) image = load_scaled_image(self.avatar, 150, 150)
image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH) if image is None:
return commonMessageDialogs.unsupported_image()
self.avatar_image.SetBitmap(image.ConvertToBitmap()) self.avatar_image.SetBitmap(image.ConvertToBitmap())