Source code for zooui.objects.mediaobjects.svgmediaobject

## ZooUI - Zooming User Interface
## Copyright (C) 2009 David Roberts <d@vidr.cc>
##
## This program is free software; you can redistribute it and/or
## modify it under the terms of the GNU General Public License
## as published by the Free Software Foundation; either version 3
## of the License, or (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, see <https://www.gnu.org/licenses/>.

"""``SVG`` objects to be displayed in the ZUI."""

from pathlib import Path
from typing import Any

from PySide6 import QtCore, QtSvg

from zooui.logger import get_logger

from .mediaobject import LoadError, MediaObject
from .mediaobjectsutils.svg.svgcache.svgcache import get_svg_cache


[docs] class SVGMediaObject(MediaObject): """ Constructor : SVGMediaObject(media_id, scene) Parameters : media_id : str scene : Scene SVGMediaObject(media_id, scene) --> None SVGMediaObject objects are used to represent SVG images that can be rendered in the ZUI. """ def __get_svg_load_path(self, media_id: str) -> str: """ Get the file path to load SVG from, handling cache hashes. Args: media_id: Either a file path or cache hash (starting with ``'svg_'``) Returns: File path to load """ # Check if media_id is a cache hash (starts with 'svg_') if media_id.startswith("svg_"): # It's a cache hash, get path from cache svg_cache = get_svg_cache() cache_path = svg_cache.get_cache_path(media_id) if not cache_path.exists(): # Try to get content from cache (might have been stored elsewhere) content = svg_cache.get_svg_content(media_id) if content is None: raise LoadError(f"SVG cache hash not found: {media_id}") # Re-store in cache (in case cache was cleaned up) svg_cache.store_svg(content) return str(cache_path) else: # It's a file path return media_id def __init__(self, media_id: str, scene: Any) -> None: """ Constructor : SVGMediaObject(media_id, scene) Parameters : media_id : str (can be file path or cache hash starting with ``'svg_'``) scene : Scene SVGMediaObject(media_id, scene) --> None Initialize a new SVGMediaObject from the SVG file identified by media_id, and the parent Scene referenced by scene. Creates a QSvgRenderer and attempts to load the SVG file. Raises LoadError if the SVG file cannot be parsed. Stores the default width and height of the SVG image for rendering calculations. """ # Initialize the parent MediaObject with media_id and scene reference # This sets up self._media_id, self._scene, and PhysicalObject attributes MediaObject.__init__(self, media_id, scene) # Create a QSvgRenderer instance to parse and render SVG content # QSvgRenderer handles SVG parsing, animation, and rendering onto QPainter self.__renderer: QtSvg.QSvgRenderer = QtSvg.QSvgRenderer() # Determine if media_id is a cache hash or file path load_path = self.__get_svg_load_path(media_id) # Attempt to load the SVG file # load() returns True on success, False if the file cannot be parsed if not self.__renderer.load(load_path): raise LoadError(f"unable to parse SVG file: {media_id}") # Get the default (intrinsic) size of the SVG image as a QSize object # defaultSize() returns the size specified in the SVG's width/height attributes size: QtCore.QSize = self.__renderer.defaultSize() # Extract the width in pixels from the QSize object # This is the SVG's native width before any scaling is applied self.__width: int = size.width() # Extract the height in pixels from the QSize object # This is the SVG's native height before any scaling is applied self.__height: int = size.height() # Initialize private variables for caching optimizations # These start as None and store computed values when first accessed # Stores the scale value (2^(scene.zoomlevel + object.zoomlevel)) self.__cached_scale: float | None = None # Stores the calculated (width, height) tuple for this SVG at current scale self.__cached_onscreen_size: tuple[float, float] | None = None # Track modification state and cache SVG content self.__logger = get_logger("SVGMediaObject") # Track if SVG has been modified (picker, clipboard, or svg_*_utils) self.__is_modified: bool = False # Cache SVG content in memory for performance self.__cached_svg_content: str | None = None # Store original file path for file-based SVGs self.__original_file_path: str | None = None if not media_id.startswith("svg_"): # It's a file path, not cache hash self.__original_file_path = media_id else: # Cache hash - mark as modified (from picker/clipboard) self.__is_modified = True # Class variable: indicates this media object supports transparency # SVG images can have transparent backgrounds, so they cannot hide objects behind them transparent: bool = True # Maximum SVG size for embedding warning (1MB) MAX_EMBEDDED_SVG_SIZE_BYTES: int = 1 * 1024 * 1024
[docs] def render(self, painter: Any, mode: int) -> None: """ Method : SVGMediaObject.render(painter, mode) Parameters : painter : QPainter mode : int SVGMediaObject.render(painter, mode) --> None Render the SVG image using the given painter and render mode. Note: Size visibility is checked by the scene via is_size_visible(). """ # Get top-left corner position of the SVG object on screen # self.topleft is a property that returns tuple (x, y) in screen coordinates x: float y: float x, y = self.topleft # Get the on-screen dimensions of the SVG at current scale # onscreen_size returns (width, height) scaled by the current zoom level w: float h: float w, h = self.onscreen_size # Render the SVG into a floating-point rectangle on the painter # QtCore.QRectF(x, y, width, height) defines the target rendering area # QSvgRenderer.render() scales the SVG vector graphics to fit the rectangle self.__renderer.render(painter, QtCore.QRectF(x, y, w, h))
[docs] def is_size_visible(self, mode: int) -> bool: """ SVG-specific size visibility check. Returns False if SVG is too small or too large to be visible, otherwise returns super().is_size_visible(mode). """ # First check base class (handles Invisible mode) if not super().is_size_visible(mode): return False # SVG-specific size checks (same thresholds as current render()) w, h = self.onscreen_size viewport_w, viewport_h = self._scene.viewport_size # Check if too small: min dimension > viewport_min / 55 not_too_small = min(w, h) > int((min(viewport_w, viewport_h)) / 55) # Check if too large: max dimension < viewport_max / 0.5 not_too_large = max(w, h) < int((max(viewport_w, viewport_h)) / 0.5) return not_too_small and not_too_large
@property def onscreen_size(self) -> tuple[float, float]: """ Property : SVGMediaObject.onscreen_size Parameters : None SVGMediaObject.onscreen_size --> Tuple[float, float] Return the on-screen size of the SVG image. Multiplies the SVG's native width and height by the current scale factor. The scale factor is derived from the combined scene and object zoom levels. Uses caching to avoid recalculating size on every access. """ current_scale: float = self.scale # Return cached size if scale hasn't changed if self.__cached_scale == current_scale and self.__cached_onscreen_size is not None: return self.__cached_onscreen_size # Calculate on-screen dimensions by multiplying native pixel size by scale # self.scale returns 2^(scene.zoomlevel + object.zoomlevel) # self.__width and self.__height are the SVG's intrinsic dimensions w: float = self.__width * current_scale h: float = self.__height * current_scale # Update cache self.__cached_scale = current_scale self.__cached_onscreen_size = (w, h) return (w, h) @property def is_modified(self) -> bool: """Check if SVG has been modified.""" return self.__is_modified
[docs] def mark_as_modified(self) -> None: """Mark SVG as modified (e.g., after svg_*_utils modification).""" self.__is_modified = True # Clear cached content since it will change self.__cached_svg_content = None
@property def original_file_path(self) -> str | None: """Get original file path if SVG was loaded from file.""" return self.__original_file_path
[docs] def get_svg_content(self) -> str | None: """ Get current SVG content with caching. Returns None if content cannot be retrieved. """ # Return cached content if available if self.__cached_svg_content is not None: return self.__cached_svg_content # Get content from cache or file if self._media_id.startswith("svg_"): # Cache hash - get from SVG cache svg_cache = get_svg_cache() content = svg_cache.get_svg_content(self._media_id) else: # File path - read from file try: content = Path(self._media_id).read_text(encoding="utf-8") except Exception as e: self.__logger.error(f"Failed to read SVG file {self._media_id}: {e}") content = None # Cache the content if content is not None: self.__cached_svg_content = content return content
[docs] def set_svg_content(self, content: str) -> None: """Set SVG content (for embedded SVGs on load).""" self.__cached_svg_content = content self.__is_modified = True
[docs] def to_dict(self) -> dict[str, Any]: """ Method : SVGMediaObject.to_dict() Parameters : None SVGMediaObject.to_dict() --> Dict[str, Any] Serialize SVGMediaObject with SVG-specific state. """ data = super().to_dict() data.update( { "width": self.__width, "height": self.__height, "transparent": self.transparent, "is_modified": self.__is_modified, "original_file_path": self.__original_file_path if self.__original_file_path else "", } ) return data
[docs] @classmethod def from_dict(cls, data: dict[str, Any], scene: Any) -> "SVGMediaObject": """ Method : SVGMediaObject.from_dict(data, scene) Parameters : data : Dict[str, Any] scene : Any SVGMediaObject.from_dict(data, scene) --> SVGMediaObject Create SVGMediaObject from serialized data. """ obj = cls(data["media_id"], scene) obj._x, obj._y, obj._z = data["position"] obj.vx, obj.vy, obj.vz = data["velocity"] # Restore modification state if present if "is_modified" in data: obj.__is_modified = data["is_modified"] # Restore original file path if present if data.get("original_file_path"): obj.__original_file_path = data["original_file_path"] return obj