Skip to content

Commit

Permalink
Merge pull request #2231 from HalfWhitt/pil-image-support
Browse files Browse the repository at this point in the history
PIL Image support
  • Loading branch information
freakboy3742 authored Nov 27, 2023
2 parents e168ed5 + fd1651b commit dd7f53f
Show file tree
Hide file tree
Showing 22 changed files with 430 additions and 135 deletions.
1 change: 1 addition & 0 deletions changes/2142.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga images can now be created from (and converted to) PIL images. Image constructor also now takes a single argument, src; path and data are deprecated.
2 changes: 2 additions & 0 deletions core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies = [
# ensure environment consistency.
dev = [
"coverage[toml] == 7.3.2",
"Pillow == 10.1.0",
"pre-commit == 3.5.0",
"pytest == 7.4.3",
"pytest-asyncio == 0.21.1",
Expand All @@ -74,6 +75,7 @@ dev = [
]
docs = [
"furo == 2023.9.10",
"Pillow == 10.1.0",
"pyenchant == 3.2.2",
# Sphinx 7.2 deprecated support for Python 3.8
"sphinx == 7.1.2 ; python_version < '3.9'",
Expand Down
143 changes: 110 additions & 33 deletions core/src/toga/images.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,110 @@
from __future__ import annotations

import warnings
from io import BytesIO
from pathlib import Path
from typing import TypeVar
from warnings import warn

try:
import PIL.Image

PIL_imported = True
except ImportError: # pragma: no cover
PIL_imported = False

import toga
from toga.platform import get_platform_factory

# Make sure deprecation warnings are shown by default
warnings.filterwarnings("default", category=DeprecationWarning)

ImageT = TypeVar("ImageT")


# Note: remove PIL type annotation when plugin system is implemented for image format
# registration; replace with ImageT?
class Image:
def __init__(
self,
path: str | None | Path = None,
src: str
| Path
| bytes
| bytearray
| memoryview
| Image
| PIL.Image.Image
| None = None,
*,
data: bytes | None = None,
path=None, # DEPRECATED
data=None, # DEPRECATED
):
"""Create a new image.
An image must be provided either a ``path`` or ``data``, but not both.
:param path: Path to the image to load. This can be specified as a string, or as
a :any:`pathlib.Path` object. The path can be an absolute file system path,
or a path relative to the module that defines your Toga application class.
:param data: A bytes object with the contents of an image in a supported format.
: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 path: **DEPRECATED** - Use ``src``.
:param data: **DEPRECATED** - Use ``src``.
:raises FileNotFoundError: If a path is provided, but that path does not exist.
:raises ValueError: If the path or data cannot be loaded as an image.
:raises ValueError: If the source cannot be loaded as an image.
"""
if path is None and data is None:
raise ValueError("Either path or data must be set.")
if path is not None and data is not None:
raise ValueError("Only either path or data can be set.")

######################################################################
# 2023-11: Backwards compatibility
######################################################################
num_provided = sum(arg is not None for arg in (src, path, data))
if num_provided > 1:
raise ValueError("Received multiple arguments to constructor.")
if num_provided == 0:
raise TypeError(
"Image.__init__() missing 1 required positional argument: 'src'"
)
if path is not None:
if isinstance(path, Path):
self.path = path
else:
self.path = Path(path)
else:
self.path = None
src = path
warn(
"Path argument is deprecated, use src instead.",
DeprecationWarning,
stacklevel=2,
)
elif data is not None:
src = data
warn(
"Data argument is deprecated, use src instead.",
DeprecationWarning,
stacklevel=2,
)
######################################################################
# End backwards compatibility
######################################################################

self.factory = get_platform_factory()
if data is not None:
self._impl = self.factory.Image(interface=self, data=data)
self._path = None

# Any "lump of bytes" should be valid here.
if isinstance(src, (bytes, bytearray, memoryview)):
self._impl = self.factory.Image(interface=self, data=src)

elif isinstance(src, (str, Path)):
self._path = toga.App.app.paths.app / src
if not self._path.is_file():
raise FileNotFoundError(f"Image file {self._path} does not exist")
self._impl = self.factory.Image(interface=self, path=self._path)

elif isinstance(src, Image):
self._impl = self.factory.Image(interface=self, data=src.data)

elif PIL_imported and isinstance(src, PIL.Image.Image):
buffer = BytesIO()
src.save(buffer, format="png", compress_level=0)
self._impl = self.factory.Image(interface=self, data=buffer.getvalue())

else:
self.path = toga.App.app.paths.app / self.path
if not self.path.is_file():
raise FileNotFoundError(f"Image file {self.path} does not exist")
self._impl = self.factory.Image(interface=self, path=self.path)
raise TypeError("Unsupported source type for Image")

@property
def size(self) -> (int, int):
"""The size of the image, as a tuple"""
"""The size of the image, as a (width, height) tuple."""
return (self._impl.get_width(), self._impl.get_height())

@property
Expand All @@ -63,18 +119,39 @@ def height(self) -> int:

@property
def data(self) -> bytes:
"""The raw data for the image, in PNG format.
:returns: The raw image data in PNG format.
"""
"""The raw data for the image, in PNG format."""
return self._impl.get_data()

def save(self, path: str | Path):
@property
def path(self) -> Path | None:
"""The path from which the image was opened, if any (or None)."""
return self._path

def save(self, path: str | Path) -> None:
"""Save image to given path.
The file format of the saved image will be determined by the extension of
the filename provided (e.g ``path/to/mypicture.png`` will save a PNG file).
:param path: Path where to save the image.
:param path: Path to save the image to.
"""
self._impl.save(path)

def as_format(self, format: type[ImageT]) -> ImageT:
"""Return the image, converted to the image format specified.
:param format: The image class to return. Currently supports only :any:`Image`,
and :any:`PIL.Image.Image` if Pillow is installed.
:returns: The image in the requested format
:raises TypeError: If the format supplied is not recognized.
"""
if isinstance(format, type) and issubclass(format, Image):
return format(self.data)

if PIL_imported and format is PIL.Image.Image:
buffer = BytesIO(self.data)
with PIL.Image.open(buffer) as pil_image:
pil_image.load()
return pil_image

raise TypeError(f"Unknown conversion format for Image: {format}")
17 changes: 11 additions & 6 deletions core/src/toga/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from abc import ABC, abstractmethod
from contextlib import contextmanager
from math import cos, pi, sin, tan
from typing import Protocol
from typing import TYPE_CHECKING, Protocol

from travertino.colors import Color

Expand All @@ -14,9 +14,11 @@
from toga.fonts import SYSTEM, SYSTEM_DEFAULT_FONT_SIZE, Font
from toga.handlers import wrapped_handler

from .. import Image
from .base import Widget

if TYPE_CHECKING:
from toga.images import ImageT

#######################################################################################
# Simple drawing objects
#######################################################################################
Expand Down Expand Up @@ -1448,11 +1450,14 @@ def measure_text(
# As image
###########################################################################

def as_image(self) -> toga.Image:
"""Render the canvas as an Image.
def as_image(self, format: type[ImageT] = toga.Image) -> ImageT:
"""Render the canvas as an image.
:returns: A :class:`toga.Image` containing the canvas content."""
return Image(data=self._impl.get_image_data())
:param format: Format to provide. Defaults to :class:`~toga.images.Image`; also
supports :class:`PIL.Image.Image` if Pillow is installed
:returns: The canvas as an image of the specified type.
"""
return toga.Image(self._impl.get_image_data()).as_format(format)

###########################################################################
# 2023-07 Backwards compatibility
Expand Down
41 changes: 34 additions & 7 deletions core/src/toga/widgets/imageview.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

from travertino.size import at_least

from toga.images import Image
import toga
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


def rehint_imageview(image, style, scale=1):
"""Compute the size hints for an ImageView based on the image.
Expand Down Expand Up @@ -60,17 +67,28 @@ def rehint_imageview(image, style, scale=1):
return width, height, aspect_ratio


# Note: remove PIL type annotation when plugin system is implemented for image format
# registration; replace with ImageT?
class ImageView(Widget):
def __init__(
self,
image: Image | Path | str | None = None,
image: str
| Path
| bytes
| bytearray
| memoryview
| PIL.Image.Image
| None = None,
id=None,
style=None,
):
"""
Create a new image view.
:param image: The image to display.
: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 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.
Expand Down Expand Up @@ -99,7 +117,7 @@ def focus(self):
pass

@property
def image(self) -> Image | None:
def image(self) -> toga.Image | None:
"""The image to display.
When setting an image, you can provide:
Expand All @@ -115,12 +133,21 @@ def image(self) -> Image | None:

@image.setter
def image(self, image):
if isinstance(image, Image):
if isinstance(image, toga.Image):
self._image = image
elif image is None:
self._image = None
else:
self._image = Image(image)
self._image = toga.Image(image)

self._impl.set_image(self._image)
self.refresh()

def as_image(self, format: type[ImageT] = toga.Image) -> ImageT:
"""Return the image in the specified format.
:param format: Format to provide. Defaults to :class:`~toga.images.Image`; also
supports :any:`PIL.Image.Image` if Pillow is installed.
:returns: The image in the specified format.
"""
return self.image.as_format(format)
10 changes: 7 additions & 3 deletions core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

if TYPE_CHECKING:
from toga.app import App
from toga.images import ImageT
from toga.widgets.base import Widget


Expand Down Expand Up @@ -329,11 +330,14 @@ def close(self) -> None:
self._impl.close()
self._closed = True

def as_image(self) -> Image:
def as_image(self, format: type[ImageT] = Image) -> ImageT:
"""Render the current contents of the window as an image.
:returns: A :class:`toga.Image` containing the window content."""
return Image(data=self._impl.get_image_data())
:param format: Format to provide. Defaults to :class:`~toga.images.Image`; also
supports :any:`PIL.Image.Image` if Pillow is installed
:returns: An image containing the window content, in the format requested.
"""
return Image(self._impl.get_image_data()).as_format(format)

############################################################
# Dialogs
Expand Down
Binary file added core/tests/resources/sample.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit dd7f53f

Please sign in to comment.