Merge pull request #2263 from freakboy3742/raw-images

Allow creating images from raw image sources
This commit is contained in:
Malcolm Smith 2023-12-13 22:46:00 +00:00 committed by GitHub
commit 9a4d50a287
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 185 additions and 85 deletions

View file

@ -5,17 +5,21 @@ from java.io import ByteArrayOutputStream, FileOutputStream
class Image:
def __init__(self, interface, path=None, data=None):
RAW_TYPE = Bitmap
def __init__(self, interface, path=None, data=None, raw=None):
self.interface = interface
if path:
self.native = BitmapFactory.decodeFile(str(path))
if self.native is None:
raise ValueError(f"Unable to load image from {path}")
else:
elif data:
self.native = BitmapFactory.decodeByteArray(data, 0, len(data))
if self.native is None:
raise ValueError("Unable to load image from data")
else:
self.native = raw
def get_width(self):
return self.native.getWidth()

1
changes/2263.feature.rst Normal file
View file

@ -0,0 +1 @@
Images can now be created from the native platform representation of an image, without needing to be transformed to bytes.

View file

@ -19,7 +19,9 @@ def nsdata_to_bytes(data: NSData) -> bytes:
class Image:
def __init__(self, interface, path=None, data=None):
RAW_TYPE = NSImage
def __init__(self, interface, path=None, data=None, raw=None):
self.interface = interface
try:
@ -36,11 +38,13 @@ class Image:
self.native = image.initWithContentsOfFile(str(path))
if self.native is None:
raise ValueError(f"Unable to load image from {path}")
else:
elif data:
nsdata = NSData.dataWithBytes(data, length=len(data))
self.native = image.initWithData(nsdata)
if self.native is None:
raise ValueError("Unable to load image from data")
else:
self.native = raw
finally:
image.release()

View file

@ -1,9 +1,10 @@
from __future__ import annotations
import sys
import warnings
from io import BytesIO
from pathlib import Path
from typing import TypeVar
from typing import TYPE_CHECKING, Any
from warnings import warn
try:
@ -19,32 +20,34 @@ from toga.platform import get_platform_factory
# Make sure deprecation warnings are shown by default
warnings.filterwarnings("default", category=DeprecationWarning)
ImageT = TypeVar("ImageT")
if TYPE_CHECKING:
if sys.version_info < (3, 10):
from typing_extensions import TypeAlias, TypeVar
else:
from typing import TypeAlias, TypeVar
# Define a type variable for generics where an Image type is required.
ImageT = TypeVar("ImageT")
# Define the types that can be used as Image content
PathLike: TypeAlias = str | Path
BytesLike: TypeAlias = bytes | bytearray | memoryview
ImageLike: TypeAlias = Any
ImageContent: TypeAlias = PathLike | BytesLike | ImageLike
# Note: remove PIL type annotation when plugin system is implemented for image format
# registration; replace with ImageT?
class Image:
def __init__(
self,
src: str
| Path
| bytes
| bytearray
| memoryview
| Image
| PIL.Image.Image
| None = None,
src: ImageContent | None = None,
*,
path=None, # DEPRECATED
data=None, # DEPRECATED
):
"""Create a new image.
:param src: The source from which to load the image. Can be a file path
(relative or absolute, as a string or :any:`pathlib.Path`), raw
binary data in any supported image format, or another Toga image. Can also
accept a :any:`PIL.Image.Image` if Pillow is installed.
:param src: The source from which to load the image. Can be any valid
:any:`image content <ImageContent>` type.
:param path: **DEPRECATED** - Use ``src``.
:param data: **DEPRECATED** - Use ``src``.
:raises FileNotFoundError: If a path is provided, but that path does not exist.
@ -99,6 +102,9 @@ class Image:
src.save(buffer, format="png", compress_level=0)
self._impl = self.factory.Image(interface=self, data=buffer.getvalue())
elif isinstance(src, self.factory.Image.RAW_TYPE):
self._impl = self.factory.Image(interface=self, raw=src)
else:
raise TypeError("Unsupported source type for Image")

View file

@ -9,11 +9,7 @@ from toga.style.pack import NONE
from toga.widgets.base import Widget
if TYPE_CHECKING:
from pathlib import Path
import PIL.Image
from toga.images import ImageT
from toga.images import ImageContent, ImageT
def rehint_imageview(image, style, scale=1):
@ -24,9 +20,9 @@ def rehint_imageview(image, style, scale=1):
:param image: The image being displayed.
:param style: The style object for the imageview.
:param scale: The scale factor (if any) to apply to native pixel sizes.
:returns: A triple containing the intrinsic width hint, intrinsic height
hint, and the aspect ratio to preserve (or None if the aspect ratio
should not be preserved).
:returns: A triple containing the intrinsic width hint, intrinsic height hint, and
the aspect ratio to preserve (or None if the aspect ratio should not be
preserved).
"""
if image:
if style.width != NONE and style.height != NONE:
@ -72,23 +68,15 @@ def rehint_imageview(image, style, scale=1):
class ImageView(Widget):
def __init__(
self,
image: str
| Path
| bytes
| bytearray
| memoryview
| PIL.Image.Image
| None = None,
image: ImageContent | None = None,
id=None,
style=None,
):
"""
Create a new image view.
:param image: The image to display. This can take all the same formats as the
`src` parameter to :class:`toga.Image` -- namely, a file path (as string
or :any:`pathlib.Path`), bytes data in a supported image format,
or :any:`PIL.Image.Image`.
:param image: The image to display. Can be any valid :any:`image content
<ImageContent>` type; or :any:`None` to display no image.
:param id: The ID for the widget.
:param style: A style object. If no style is provided, a default style will be
applied to the widget.
@ -120,14 +108,8 @@ class ImageView(Widget):
def image(self) -> toga.Image | None:
"""The image to display.
When setting an image, you can provide:
* An :class:`~toga.images.Image` instance; or
* Any value that would be a valid path specifier when creating a new
:class:`~toga.images.Image` instance; or
* :any:`None` to clear the image view.
When setting an image, you can provide any valid :any:`image content
<ImageContent>` type; or :any:`None` to clear the image view.
"""
return self._image

View file

@ -22,6 +22,10 @@ def clear_sys_modules(monkeypatch):
pass
class TestApp(toga.App):
pass
@pytest.fixture
def app(event_loop):
return toga.App(formal_name="Test App", app_id="org.beeware.toga.test-app")
return TestApp(formal_name="Test App", app_id="org.beeware.toga.test-app")

View file

@ -6,8 +6,8 @@ import pytest
import toga
from toga_dummy.utils import assert_action_performed_with
RELATIVE_FILE_PATH = Path("resources/toga.png")
ABSOLUTE_FILE_PATH = Path(toga.__file__).parent / "resources/toga.png"
RELATIVE_FILE_PATH = Path("resources/sample.png")
ABSOLUTE_FILE_PATH = Path(__file__).parent / "resources/sample.png"
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
@ -108,6 +108,20 @@ def test_create_from_bytes(args, kwargs):
assert_action_performed_with(image, "load image data", data=BYTES)
def test_create_from_raw():
"""An image can be created from a raw data source"""
orig = toga.Image(BYTES)
copy = toga.Image(orig._impl.native)
# Image is bound
assert copy._impl is not None
# impl/interface round trips
assert copy._impl.interface == copy
# Image was constructed from raw data
assert_action_performed_with(copy, "load image from raw")
def test_not_enough_arguments():
with pytest.raises(
TypeError,
@ -132,7 +146,7 @@ def test_create_from_pil(app):
toga_image = toga.Image(pil_image)
assert isinstance(toga_image, toga.Image)
assert toga_image.size == (32, 32)
assert toga_image.size == (144, 72)
def test_create_from_toga_image(app):
@ -141,7 +155,7 @@ def test_create_from_toga_image(app):
toga_image_2 = toga.Image(toga_image)
assert isinstance(toga_image_2, toga.Image)
assert toga_image_2.size == (32, 32)
assert toga_image_2.size == (144, 72)
@pytest.mark.parametrize("kwargs", [{"data": BYTES}, {"path": ABSOLUTE_FILE_PATH}])
@ -176,14 +190,23 @@ def test_dimensions(app):
"""The dimensions of the image can be retrieved"""
image = toga.Image(RELATIVE_FILE_PATH)
assert image.size == (32, 32)
assert image.width == image.height == 32
assert image.size == (144, 72)
assert image.width == 144
assert image.height == 72
def test_data(app):
"""The raw data of the image can be retrieved."""
image = toga.Image(ABSOLUTE_FILE_PATH)
assert image.data == ABSOLUTE_FILE_PATH.read_bytes()
# We can't guarantee the round-trip of image data,
# but the data starts with a PNG header
assert image.data.startswith(b"\x89PNG\r\n\x1a\n")
# If we build a new image from the data, it has the same properties.
from_data = toga.Image(image.data)
assert from_data.width == image.width
assert from_data.height == image.height
def test_image_save(tmp_path):
@ -214,7 +237,7 @@ def test_as_format_toga(app, Class_1, Class_2):
image_2 = image_1.as_format(Class_2)
assert isinstance(image_2, Class_2)
assert image_2.size == (32, 32)
assert image_2.size == (144, 72)
def test_as_format_pil(app):
@ -222,7 +245,7 @@ def test_as_format_pil(app):
toga_image = toga.Image(ABSOLUTE_FILE_PATH)
pil_image = toga_image.as_format(PIL.Image.Image)
assert isinstance(pil_image, PIL.Image.Image)
assert pil_image.size == (32, 32)
assert pil_image.size == (144, 72)
# None is same as supplying nothing; also test a random unrecognized class

View file

@ -4,7 +4,6 @@ from unittest.mock import Mock
import pytest
import toga
import toga_dummy
from toga_dummy.utils import (
assert_action_not_performed,
assert_action_performed,
@ -351,8 +350,8 @@ def test_as_image(window):
"""A window can be captured as an image"""
image = window.as_image()
assert_action_performed(window, "get image data")
path = Path(toga_dummy.__file__).parent / "resources/screenshot.png"
assert image.data == path.read_bytes()
# Don't need to check the raw data; just check it's the right size.
assert image.size == (318, 346)
def test_info_dialog(window, app):

View file

@ -1,6 +1,8 @@
Image
=====
Graphical content of arbitrary size.
.. rst-class:: widget-support
.. csv-filter:: Availability (:ref:`Key <api-status-key>`)
:header-rows: 1
@ -8,12 +10,11 @@ Image
:included_cols: 4,5,6,7,8,9,10
:exclude: {0: '(?!(Image|Component)$)'}
An image is graphical content of arbitrary size.
Usage
-----
An image can be constructed from a :any:`wide range of sources <ImageContent>`:
.. code-block:: python
from pathlib import Path
@ -37,14 +38,44 @@ Usage
Notes
-----
.. _known-image-formats:
* PNG and JPEG formats are guaranteed to be supported.
Other formats are available on some platforms:
- macOS: GIF, BMP, TIFF
- GTK: BMP
- macOS: GIF, BMP, TIFF
- Windows: GIF, BMP, TIFF
.. _native-image-rep:
* The native platform representations for images are:
- Android: ``android.graphics.Bitmap``
- GTK: ``GdkPixbuf.Pixbuf``
- iOS: ``UIImage``
- macOS: ``NSImage``
- Windows: ``System.Drawing.Image``
Reference
---------
.. c:type:: ImageContent
When specifying content for an :any:`Image`, you can provide:
* a string specifying an absolute or relative path to a file in a :ref:`known image
format <known-image-formats>`;
* an absolute or relative :any:`pathlib.Path` object describing a file in a
:ref:`known image format <known-image-formats>`;
* a "blob of bytes" data type (:any:`bytes`, :any:`bytearray`, or :any:`memoryview`)
containing raw image data in a :ref:`known image format <known-image-formats>`;
* an instance of :any:`toga.Image`; or
* if `Pillow <https://pillow.readthedocs.io/>`__ is installed, an instance of
:any:`PIL.Image.Image`; or
* an instance of the :ref:`native platform image representation <native-image-rep>`.
If a relative path is provided, it will be anchored relative to the module that
defines your Toga application class.
.. autoclass:: toga.Image

View file

@ -32,4 +32,4 @@ App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform
Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,,
Command,Resource,:class:`~toga.Command`,Command,|y|,|y|,|y|,,|y|,,
Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|,,|b|
Image,Resource,:class:`~toga.Image`,An image,|y|,|y|,|y|,|y|,|y|,,
Image,Resource,:class:`~toga.Image`,Graphical content of arbitrary size.,|y|,|y|,|y|,|y|,|y|,,

1 Component Type Component Description macOS GTK Windows iOS Android Web Terminal
32 Font Resource :class:`~toga.Font` A text font |y| |y| |y| |y| |y|
33 Command Resource :class:`~toga.Command` Command |y| |y| |y| |y|
34 Icon Resource :class:`~toga.Icon` A small, square image, used to provide easily identifiable visual context to a widget. |y| |y| |y| |y| |y| |b|
35 Image Resource :class:`~toga.Image` An image Graphical content of arbitrary size. |y| |y| |y| |y| |y|

View file

@ -12,34 +12,57 @@ if TYPE_CHECKING:
import toga
# We need a dummy "internal image format" for the dummy backend It's a wrapper
# around a PIL image. We can't just use a PIL image because that will be
# interpreted *as* a PIL image.
class DummyImage:
def __init__(self, image=None):
self.raw = image
if image:
buffer = BytesIO()
self.raw.save(buffer, format="png", compress_level=0)
self.data = buffer.getvalue()
else:
self.data = b"pretend this is PNG image data"
class Image(LoggedObject):
def __init__(self, interface: toga.Image, path: Path = None, data: bytes = None):
RAW_TYPE = DummyImage
def __init__(
self,
interface: toga.Image,
path: Path = None,
data: bytes = None,
raw: BytesIO = None,
):
super().__init__()
self.interface = interface
if path:
self._action("load image file", path=path)
if path.is_file():
self._data = path.read_bytes()
with PIL.Image.open(path) as image:
self._width, self._height = image.size
self.native = DummyImage(PIL.Image.open(path))
else:
self._data = b"pretend this is PNG image data"
self._width, self._height = 60, 40
else:
self.native = DummyImage()
elif data:
self._action("load image data", data=data)
self._data = data
buffer = BytesIO(data)
with PIL.Image.open(buffer) as image:
self._width, self._height = image.size
self.native = DummyImage(PIL.Image.open(BytesIO(data)))
else:
self._action("load image from raw")
self.native = raw
def get_width(self):
return self._width
if self.native.raw is None:
return 60
return self.native.raw.size[0]
def get_height(self):
return self._height
if self.native.raw is None:
return 40
return self.native.raw.size[1]
def get_data(self):
return self._data
return self.native.data
def save(self, path):
self._action("save", path=path)

View file

@ -4,7 +4,9 @@ from toga_gtk.libs import GdkPixbuf, Gio, GLib
class Image:
def __init__(self, interface, path=None, data=None):
RAW_TYPE = GdkPixbuf.Pixbuf
def __init__(self, interface, path=None, data=None, raw=None):
self.interface = interface
if path:
@ -12,12 +14,14 @@ class Image:
self.native = GdkPixbuf.Pixbuf.new_from_file(str(path))
except GLib.GError:
raise ValueError(f"Unable to load image from {path}")
else:
elif data:
try:
input_stream = Gio.MemoryInputStream.new_from_data(data, None)
self.native = GdkPixbuf.Pixbuf.new_from_stream(input_stream, None)
except GLib.GError:
raise ValueError("Unable to load image from data")
else:
self.native = raw
def get_width(self):
return self.native.get_width()

View file

@ -18,19 +18,24 @@ def nsdata_to_bytes(data: NSData) -> bytes:
class Image:
def __init__(self, interface, path=None, data=None):
RAW_TYPE = UIImage
def __init__(self, interface, path=None, data=None, raw=None):
self.interface = interface
if path:
self.native = UIImage.imageWithContentsOfFile(str(path))
if self.native is None:
raise ValueError(f"Unable to load image from {path}")
else:
elif data:
self.native = UIImage.imageWithData(
NSData.dataWithBytes(data, length=len(data))
)
if self.native is None:
raise ValueError("Unable to load image from data")
else:
self.native = raw
self.native.retain()
def __del__(self):

View file

@ -22,6 +22,16 @@ async def test_local_image(app):
assert image.height == 72
async def test_raw_image(app):
"An image can be created from the platform's raw representation"
original = toga.Image("resources/sample.png")
image = toga.Image(original._impl.native)
assert image.width == original.width
assert image.height == original.height
async def test_bad_image_file(app):
"If a file isn't a loadable image, an error is raised"
with pytest.raises(

View file

@ -10,7 +10,9 @@ from System.IO import MemoryStream
class Image:
def __init__(self, interface, path=None, data=None):
RAW_TYPE = WinImage
def __init__(self, interface, path=None, data=None, raw=None):
self.interface = interface
if path:
@ -20,12 +22,14 @@ class Image:
# OutOfMemoryException is what Winforms raises when a file
# isn't a valid image file.
raise ValueError(f"Unable to load image from {path}")
else:
elif data:
try:
stream = MemoryStream(data)
self.native = WinImage.FromStream(stream)
except ArgumentException:
raise ValueError("Unable to load image from data")
else:
self.native = raw
def get_width(self):
return self.native.Width