Merge pull request #2263 from freakboy3742/raw-images
Allow creating images from raw image sources
This commit is contained in:
commit
9a4d50a287
15 changed files with 185 additions and 85 deletions
|
|
@ -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
1
changes/2263.feature.rst
Normal file
|
|
@ -0,0 +1 @@
|
|||
Images can now be created from the native platform representation of an image, without needing to be transformed to bytes.
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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|,,
|
||||
|
|
|
|||
|
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue