import tkinter.ttk as ttk
from .base import Base
from .canvasview import CanvasView
from .modulesmanager import ModulesManager
[docs]
class Image(Base):
"""Widget for displaying a PIL image in a tkinter label."""
def __init__(self, filepath=None, url=None, pil_image=None):
Base.__init__(self)
self.pil_image = pil_image
if self.pil_image is None:
try:
self.pil_image = self.read_pil_image(filepath=filepath, url=url)
except Exception:
self.pil_image = self.PILImage.new("RGB", size=(100, 100))
self._displayed_tkimage = None
self.is_rescalable = False
self.add_observer(self, "is_rescalable")
self.resize_update_delay = 0
self.parent_grid_cell = None
@property
def width(self):
"""The width of the source PIL image in pixels."""
if self.pil_image is not None:
return self.pil_image.width
return None
@property
def height(self):
"""The height of the source PIL image in pixels."""
if self.pil_image is not None:
return self.pil_image.height
return None
@property
def displayed_width(self):
"""The width of the currently displayed image in pixels."""
if self._displayed_tkimage is not None:
return self._displayed_tkimage.width
return None
@property
def displayed_height(self):
"""The height of the currently displayed image in pixels."""
if self._displayed_tkimage is not None:
return self._displayed_tkimage.height
return None
[docs]
def is_environment_valid(self):
"""Check that Pillow and its submodules are installed and importable."""
ModulesManager.install_and_import_modules_if_absent(
{
"Pillow": "PIL",
"ImageTk": "PIL.ImageTk",
"PILImage": "PIL.Image",
"ImageDraw": "PIL.ImageDraw",
}
)
self.PIL = ModulesManager.imported["Pillow"]
self.PILImage = ModulesManager.imported["PILImage"]
self.ImageDraw = ModulesManager.imported["ImageDraw"]
self.ImageTk = ModulesManager.imported["ImageTk"]
return all(
v is not None
for v in [self.ImageTk, self.PIL, self.PILImage, self.ImageDraw]
)
[docs]
def read_pil_image(self, filepath=None, url=None):
"""Load a PIL image from a local file path or a URL."""
if filepath is not None:
pil_image = self.PILImage.open(filepath)
elif url is not None:
from io import BytesIO
import requests
response = requests.get(url)
pil_image = self.PILImage.open(BytesIO(response.content))
pil_image.load()
return pil_image
[docs]
def observed_property_changed(
self, observed_object, observed_property_name, new_value, context
):
"""Handle changes to observed properties such as is_rescalable."""
if observed_property_name == "is_rescalable":
if self.is_rescalable:
self.resize_image_to_fit_widget()
else:
self.update_display()
super().observed_property_changed(
observed_object, observed_property_name, new_value, context
)
[docs]
def event_resized(self, event):
"""Resize the image if is_rescalable, throttling to avoid infinite loops."""
if self.is_rescalable:
if self.resize_update_delay > 0:
if len(self.scheduled_tasks) == 0:
self.after(
self.resize_update_delay,
self.resize_image_to_fit_widget,
)
else:
self.resize_image_to_fit_widget()
else:
self.update_display()
[docs]
def scaled_image(self, width, height):
"""Return the source image rendered to ``(width, height)`` pixels.
For a raster source this resamples the bitmap; subclasses backed by a
resolution-independent source (e.g. :class:`SVGImage`) override this to
re-render at the requested size for crisp output.
"""
return self.pil_image.resize((width, height), self.PILImage.NEAREST)
[docs]
def update_display(self, image_to_display=None):
"""Update the widget to show the given image, or the source image if None."""
if self.widget is None:
return
if image_to_display is None:
image_to_display = self.pil_image
if image_to_display is not None and self.ImageTk is not None:
self._displayed_tkimage = self.ImageTk.PhotoImage(
image=image_to_display
)
else:
self._displayed_tkimage = None
self.widget.configure(image=self._displayed_tkimage)
[docs]
class ImageWithGrid(Image):
"""Image widget with an optional grid overlay drawn on top."""
def __init__(self, filepath=None, url=None, pil_image=None):
super().__init__(filepath=filepath, url=url, pil_image=pil_image)
self.is_grid_showing = True
self.grid_count = 5
self.add_observer(self, "is_grid_showing")
self.add_observer(self, "grid_count")
[docs]
def observed_property_changed(
self, observed_object, observed_property_name, new_value, context
):
"""Handle changes to grid visibility or grid count."""
super().observed_property_changed(
observed_object, observed_property_name, new_value, context
)
if observed_property_name == "is_grid_showing" or observed_property_name == "grid_count":
if self.is_rescalable:
self.resize_image_to_fit_widget()
else:
self.update_display()
[docs]
def update_display(self, image_to_display=None):
"""Update the widget, adding a grid overlay if enabled."""
if self.widget is None:
return
if image_to_display is None:
image_to_display = self.pil_image
if self.is_grid_showing:
image_to_display = self.image_with_grid_overlay(image_to_display)
if image_to_display is not None and self.ImageTk is not None:
self._displayed_tkimage = self.ImageTk.PhotoImage(
image=image_to_display
)
else:
self._displayed_tkimage = None
self.widget.configure(image=self._displayed_tkimage)
[docs]
def image_with_grid_overlay(self, pil_image):
"""Return a copy of the image with grid lines drawn on it."""
if pil_image is not None:
# from
# https://randomgeekery.org/post/2017/11/drawing-grids-with-python-and-pillow/
image = pil_image.copy()
draw = self.ImageDraw.Draw(image)
y_start = 0
y_end = image.height
step_size = int(image.width / self.grid_count)
if step_size > 0:
for x in range(0, image.width, step_size):
line = ((x, y_start), (x, y_end))
draw.line(line, fill=255)
x_start = 0
x_end = image.width
for y in range(0, image.height, step_size):
line = ((x_start, y), (x_end, y))
draw.line(line, fill=255)
return image
else:
return None
[docs]
class SVGImage(Image):
"""Display an SVG document, rasterized to fit the widget.
``SVGImage`` is an :class:`Image` whose source is an SVG document instead
of a bitmap, so it inherits all of ``Image``'s placement, rescaling and
display behaviour unchanged. The SVG is rendered with the bundled ``resvg``
engine (full SVG support, including text and gradients), which ships as a
self-contained, cross-platform binary wheel; the resulting raster flows
through the very same ``ttk.Label`` pipeline as any other image.
Provide the document as a file path or as an SVG string/bytes::
SVGImage(filepath="drawing.svg")
SVGImage(data="<svg ...>...</svg>")
Because the source is vector, resizing re-renders at the new pixel size
(crisp) rather than resampling a fixed bitmap. Use :meth:`load` or
:meth:`load_from_file` to swap the document after creation.
"""
def __init__(self, filepath=None, data=None):
if isinstance(data, str):
data = data.encode("utf-8")
self._svg_filepath = filepath
self._svg_data = data
# Image.__init__ runs is_environment_valid (importing Pillow and the
# resvg engine) before we can render, so initialise the base first and
# then replace its placeholder with the rasterized SVG.
super().__init__()
self.pil_image = self.render_svg_to_pil_image()
[docs]
def is_environment_valid(self):
"""Ensure Pillow (via :class:`Image`) and the ``resvg`` engine are present."""
valid = super().is_environment_valid()
ModulesManager.install_and_import_modules_if_absent(
{"resvg-py": "resvg_py"}
)
self.resvg = ModulesManager.imported.get("resvg-py")
return valid and self.resvg is not None
[docs]
def render_svg_to_pil_image(self, width=None, height=None):
"""Rasterize the SVG source to a PIL image at the requested pixel size.
With no size the document's intrinsic dimensions are used; a ``width``
and/or ``height`` scales it (resvg preserves the aspect ratio). Returns
a blank image when there is no source or the engine is unavailable.
"""
from io import BytesIO
if self.resvg is None:
return self.PILImage.new("RGBA", (1, 1))
kwargs = {}
if self._svg_data is not None:
kwargs["svg_string"] = self._svg_data.decode("utf-8")
elif self._svg_filepath is not None:
kwargs["svg_path"] = self._svg_filepath
else:
return self.PILImage.new("RGBA", (1, 1))
if width is not None and width > 0:
kwargs["width"] = int(width)
if height is not None and height > 0:
kwargs["height"] = int(height)
png_bytes = bytes(self.resvg.svg_to_bytes(**kwargs))
image = self.PILImage.open(BytesIO(png_bytes))
image.load()
return image
[docs]
def scaled_image(self, width, height):
"""Re-render the SVG at ``(width, height)`` for resolution-independent output."""
return self.render_svg_to_pil_image(width, height)
[docs]
def load(self, data):
"""Render an SVG document supplied as a string (or bytes)."""
if isinstance(data, str):
data = data.encode("utf-8")
self._svg_data = data
self._svg_filepath = None
self._reload()
[docs]
def load_from_file(self, filepath):
"""Load and render an ``.svg`` file from disk."""
self._svg_data = None
self._svg_filepath = filepath
self._reload()
def _reload(self):
"""Re-render the current source and refresh the display."""
self.pil_image = self.render_svg_to_pil_image()
if self.widget is None:
return
if self.is_rescalable:
self.resize_image_to_fit_widget()
else:
self.update_display()
[docs]
def load_file_or_warn(self, path):
"""Load an ``.svg`` file, warning in a dialog if it cannot be opened.
Unlike :meth:`load_from_file`, this never raises — it is meant for
dropped or user-picked files, where a missing or malformed file should
be reported in the UI rather than crash the app. Returns True if the
file loaded.
"""
import os
from .dialog import Dialog
try:
self.load_from_file(path)
return True
except FileNotFoundError:
reason = "The file could not be found."
except Exception as err:
reason = str(err) or "It could not be rendered."
Dialog.showwarning(
title="Could not open SVG",
message=f"“{os.path.basename(path)}” could not be opened.\n\n{reason}",
)
return False
[docs]
def accept_dropped_svg_files(self, on_load=None):
"""Accept ``.svg`` files dropped onto the widget from the OS file manager.
Renders the first dropped file whose name ends in ``.svg``
(case-insensitive) via :meth:`load_file_or_warn`; non-SVG files are
ignored. The optional ``on_load(path)`` callback runs after a successful
load. Call this after the widget is placed. Returns True if
drag-and-drop was enabled, or False if it is unavailable (the optional
``tkinterdnd2`` dependency could not be loaded).
"""
def handle_dropped(paths):
path = self._first_svg(paths)
if path is None:
return
if self.load_file_or_warn(path) and on_load is not None:
on_load(path)
return self.accept_dropped_files(handle_dropped)
@staticmethod
def _first_svg(paths):
"""Return the first ``.svg`` path from a dropped-file list, or None."""
for path in paths:
if path.lower().endswith(".svg"):
return path
return None
[docs]
class DynamicImage(CanvasView):
"""Canvas-based image that can be redrawn dynamically."""
def __init__(self, width=200, height=200):
super().__init__(width=width, height=height)
[docs]
def draw_canvas(self):
"""Draw the canvas contents. Override in subclasses."""
pass