Source code for zooui.objects.mediaobjects.mediaobjectsutils.svg.utils.svgcircleutils

## 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 circle/ellipse detection and elongation utilities."""

import math
import os
import shutil
import xml.etree.ElementTree as ET

from zooui.logger import get_logger

from ..svgcache.svgcache import get_svg_cache

# SVG namespace for XML parsing
SVG_NS = {"svg": "http://www.w3.org/2000/svg"}


[docs] def _load_svg_tree(svg_input: str) -> ET.ElementTree: """ Load SVG XML tree from either file path or cache hash. Args: svg_input: Either a file path or cache hash (starting with ``'svg_'``) Returns: ElementTree object Raises: FileNotFoundError: If file doesn't exist ValueError: If cache hash not found ET.ParseError: If XML parsing fails """ if svg_input.startswith("svg_"): # It's a cache hash, load from cache cache = get_svg_cache() svg_content = cache.get_svg_content(svg_input) if svg_content is None: raise ValueError(f"Cache hash not found: {svg_input}") root = ET.fromstring(svg_content) assert root is not None, f"Failed to parse SVG content: {svg_content[:100]}" return ET.ElementTree(root) else: # It's a file path if not os.path.exists(svg_input): raise FileNotFoundError(f"SVG file not found: {svg_input}") return ET.parse(svg_input)
# Module logger _logger = get_logger("SVGCircleUtils")
[docs] def _create_backup(svg_path: str) -> str | None: """ Create a backup copy of the SVG file. Returns: Path to backup file or None on failure """ try: backup_path = svg_path + ".backup" shutil.copy2(svg_path, backup_path) _logger.debug(f"Created backup: {backup_path}") return backup_path except Exception as e: _logger.error(f"Failed to create backup for {svg_path}: {e}") return None
[docs] def _restore_from_backup(svg_path: str, backup_path: str) -> bool: """ Restore SVG file from backup. Returns: True if successful, False otherwise """ try: if os.path.exists(backup_path): shutil.copy2(backup_path, svg_path) _logger.debug(f"Restored from backup: {svg_path}") return True else: _logger.error(f"Backup file not found: {backup_path}") return False except Exception as e: _logger.error(f"Failed to restore from backup {backup_path}: {e}") return False
[docs] def _cleanup_backup(backup_path: str) -> None: """Remove backup file if it exists.""" try: if os.path.exists(backup_path): os.remove(backup_path) _logger.debug(f"Cleaned up backup: {backup_path}") except Exception as e: _logger.warning(f"Failed to cleanup backup {backup_path}: {e}")
[docs] def is_circle_svg(svg_path: str) -> bool: """ Check if SVG contains a single circle or ellipse element. Returns: True if SVG contains exactly one <circle> or <ellipse> element, False otherwise """ try: tree = _load_svg_tree(svg_path) root = tree.getroot() # Find circle and ellipse elements circles = root.findall(".//svg:circle", SVG_NS) ellipses = root.findall(".//svg:ellipse", SVG_NS) total_shapes = len(circles) + len(ellipses) if total_shapes != 1: _logger.debug(f"Not a single circle/ellipse: found {len(circles)} circles and {len(ellipses)} ellipses") return False # Check if it's a circle if circles: circle = circles[0] # Check required attributes for circle required_attrs = ["cx", "cy", "r"] for attr in required_attrs: if circle.get(attr) is None: _logger.debug(f"Circle missing required attribute: {attr}") return False # Validate coordinates are numeric try: cx = float(circle.get("cx", 0)) cy = float(circle.get("cy", 0)) r = float(circle.get("r", 0)) # Check for valid radius if r <= 0: _logger.debug(f"Invalid circle radius: r={r}") return False _logger.debug(f"Detected circle at {svg_path}: cx={cx}, cy={cy}, r={r}") return True except (ValueError, TypeError): _logger.debug("Invalid circle coordinate values") return False # Check if it's an ellipse elif ellipses: ellipse = ellipses[0] # Check required attributes for ellipse required_attrs = ["cx", "cy", "rx", "ry"] for attr in required_attrs: if ellipse.get(attr) is None: _logger.debug(f"Ellipse missing required attribute: {attr}") return False # Validate coordinates are numeric try: cx = float(ellipse.get("cx", 0)) cy = float(ellipse.get("cy", 0)) rx = float(ellipse.get("rx", 0)) ry = float(ellipse.get("ry", 0)) # Check for valid radii if rx <= 0 or ry <= 0: _logger.debug(f"Invalid ellipse radii: rx={rx}, ry={ry}") return False _logger.debug(f"Detected ellipse at {svg_path}: cx={cx}, cy={cy}, rx={rx}, ry={ry}") return True except (ValueError, TypeError): _logger.debug("Invalid ellipse coordinate values") return False return False except ET.ParseError as e: _logger.error(f"Failed to parse SVG {svg_path}: {e}") return False except Exception as e: _logger.error(f"Error checking SVG {svg_path}: {e}") return False
[docs] def get_circle_bounds(svg_path: str) -> tuple[float, float, float, float] | None: """ Get circle/ellipse bounds: (cx, cy, rx, ry). For circles, rx = ry = r. Returns: Tuple of (cx, cy, rx, ry) or None if not a circle/ellipse """ try: if not is_circle_svg(svg_path): return None tree = _load_svg_tree(svg_path) root = tree.getroot() # Check for circle first circle = root.find(".//svg:circle", SVG_NS) if circle is not None: cx = float(circle.get("cx", 0)) cy = float(circle.get("cy", 0)) r = float(circle.get("r", 0)) return (cx, cy, r, r) # Check for ellipse ellipse = root.find(".//svg:ellipse", SVG_NS) if ellipse is not None: cx = float(ellipse.get("cx", 0)) cy = float(ellipse.get("cy", 0)) rx = float(ellipse.get("rx", 0)) ry = float(ellipse.get("ry", 0)) return (cx, cy, rx, ry) return None except Exception as e: _logger.error(f"Error getting circle bounds from {svg_path}: {e}") return None
[docs] def elongate_circle(svg_path: str, scale_x: float, scale_y: float) -> str: """ Elongate circle/ellipse by scaling radii from center. Stores modified SVG in cache and returns cache hash. Args: svg_path: Path to SVG file (will be read but not modified) scale_x: Multiplier for rx (minimum 0.2) scale_y: Multiplier for ry (minimum 0.2) Returns: Cache hash of modified SVG (format: svg_{8_char_hex}) Raises: ValueError: If scale_x < 0.2 or scale_y < 0.2 Exception: If SVG parsing or modification fails """ if scale_x < 0.2: raise ValueError(f"Scale factor X must be >= 0.2, got {scale_x}") if scale_y < 0.2: raise ValueError(f"Scale factor Y must be >= 0.2, got {scale_y}") # Get SVG cache svg_cache = get_svg_cache() try: # Parse SVG tree = _load_svg_tree(svg_path) root = tree.getroot() # Get circle or ellipse element circle = root.find(".//svg:circle", SVG_NS) ellipse = root.find(".//svg:ellipse", SVG_NS) if circle is None and ellipse is None: raise ValueError(f"No circle or ellipse element found in {svg_path}") # Initialize variables cx: float = 0.0 cy: float = 0.0 new_rx: float = 0.0 new_ry: float = 0.0 # Get current SVG dimensions current_width = root.get("width") current_height = root.get("height") # Handle circle if circle is not None: # Get current circle attributes try: cx = float(circle.get("cx", 0)) cy = float(circle.get("cy", 0)) r = float(circle.get("r", 0)) except (ValueError, TypeError) as e: raise ValueError(f"Invalid circle coordinates in {svg_path}: {e}") from e new_rx = r * scale_x new_ry = r * scale_y # If scaling is proportional (scale_x == scale_y), keep as circle if math.isclose(scale_x, scale_y, rel_tol=1e-9): # Update circle radius circle.set("r", str(new_rx)) _logger.debug(f"Updated circle: cx={cx}, cy={cy}, r={new_rx:.2f}") else: # Convert circle to ellipse with different radii # Change tag from circle to ellipse circle.tag = "{http://www.w3.org/2000/svg}ellipse" # Update attributes for ellipse circle.set("rx", str(new_rx)) circle.set("ry", str(new_ry)) # Remove 'r' attribute (not used for ellipse) if "r" in circle.attrib: del circle.attrib["r"] _logger.debug(f"Converted circle to ellipse: cx={cx}, cy={cy}, rx={new_rx:.2f}, ry={new_ry:.2f}") # Handle ellipse elif ellipse is not None: # Get current ellipse attributes try: cx = float(ellipse.get("cx", 0)) cy = float(ellipse.get("cy", 0)) rx = float(ellipse.get("rx", 0)) ry = float(ellipse.get("ry", 0)) except (ValueError, TypeError) as e: raise ValueError(f"Invalid ellipse coordinates in {svg_path}: {e}") from e # Calculate new radii new_rx = rx * scale_x new_ry = ry * scale_y # Update ellipse radii ellipse.set("rx", str(new_rx)) ellipse.set("ry", str(new_ry)) _logger.debug(f"Updated ellipse: cx={cx}, cy={cy}, rx={new_rx:.2f}, ry={new_ry:.2f}") # Calculate bounds of circle/ellipse # Use new_rx and new_ry which are already calculated min_x = cx - new_rx max_x = cx + new_rx min_y = cy - new_ry max_y = cy + new_ry # Calculate padding based on current dimensions or default default_padding = 20.0 if current_width and current_height: try: current_width_val = float(current_width) current_height_val = float(current_height) padding_x = max(current_width_val * 0.1, default_padding) padding_y = max(current_height_val * 0.1, default_padding) except (ValueError, TypeError): padding_x = padding_y = default_padding else: padding_x = padding_y = default_padding # Apply padding to bounds (allow negative coordinates in viewBox) padded_min_x = min_x - padding_x * 0.5 padded_max_x = max_x + padding_x * 0.5 padded_min_y = min_y - padding_y * 0.5 padded_max_y = max_y + padding_y * 0.5 # Calculate dimensions from padded bounds viewbox_width = padded_max_x - padded_min_x viewbox_height = padded_max_y - padded_min_y # Set viewBox (allows negative coordinates) root.set("viewBox", f"{padded_min_x} {padded_min_y} {viewbox_width} {viewbox_height}") _logger.debug(f"Updated viewBox: {padded_min_x} {padded_min_y} {viewbox_width} {viewbox_height}") # Update width and height to match viewBox dimensions # This ensures consistent aspect ratio if current_width: root.set("width", str(viewbox_width)) if current_height: root.set("height", str(viewbox_height)) # Log dimension updates if current_width and current_height: _logger.debug(f"Updated dimensions: width={viewbox_width}, height={viewbox_height}") # Convert to string and store in cache svg_content = ET.tostring(root, encoding="utf-8").decode("utf-8") cache_hash = svg_cache.store_svg(svg_content) _logger.info(f"Elongated circle/ellipse by factors X={scale_x:.2f}, Y={scale_y:.2f}, cache hash: {cache_hash}") return cache_hash except Exception as e: _logger.error(f"Error elongating circle/ellipse {svg_path}: {e}") raise