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

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

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("SVGSquareUtils")
[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_square_svg(svg_path: str) -> bool: """ Check if SVG contains a single rectangle element. Returns: True if SVG contains exactly one <rect> element, False otherwise """ try: tree = _load_svg_tree(svg_path) root = tree.getroot() # Find rectangle elements rects = root.findall(".//svg:rect", SVG_NS) if len(rects) != 1: _logger.debug(f"Not a single rectangle: found {len(rects)} rect elements") return False rect = rects[0] # Check required attributes required_attrs = ["x", "y", "width", "height"] for attr in required_attrs: if rect.get(attr) is None: _logger.debug(f"Rectangle missing required attribute: {attr}") return False # Validate coordinates are numeric try: x = float(rect.get("x", 0)) y = float(rect.get("y", 0)) width = float(rect.get("width", 0)) height = float(rect.get("height", 0)) # Check for valid dimensions if width <= 0 or height <= 0: _logger.debug(f"Invalid rectangle dimensions: width={width}, height={height}") return False except (ValueError, TypeError): _logger.debug("Invalid rectangle coordinate values") return False _logger.debug(f"Detected rectangle at {svg_path}: x={x}, y={y}, width={width}, height={height}") return True 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_rectangle_bounds(svg_path: str) -> tuple[float, float, float, float] | None: """ Get rectangle bounds: (x, y, width, height). Returns: Tuple of (x, y, width, height) or None if not a rectangle """ try: if not is_square_svg(svg_path): return None tree = _load_svg_tree(svg_path) root = tree.getroot() rect = root.find(".//svg:rect", SVG_NS) if rect is None: return None x = float(rect.get("x", 0)) y = float(rect.get("y", 0)) width = float(rect.get("width", 0)) height = float(rect.get("height", 0)) return (x, y, width, height) except Exception as e: _logger.error(f"Error getting rectangle bounds from {svg_path}: {e}") return None
[docs] def elongate_square(svg_path: str, scale_x: float, scale_y: float) -> str: """ Elongate rectangle by scaling width and height 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 width (minimum 0.2) scale_y: Multiplier for height (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 rectangle element rect = root.find(".//svg:rect", SVG_NS) if rect is None: raise ValueError(f"No rectangle element found in {svg_path}") # Get current rectangle attributes try: x = float(rect.get("x", 0)) y = float(rect.get("y", 0)) width = float(rect.get("width", 0)) height = float(rect.get("height", 0)) except (ValueError, TypeError) as e: raise ValueError(f"Invalid rectangle coordinates in {svg_path}: {e}") from e # Calculate center point center_x = x + width / 2.0 center_y = y + height / 2.0 # Calculate center point center_x = x + width / 2.0 center_y = y + height / 2.0 # Calculate new dimensions new_width = width * scale_x new_height = height * scale_y # Calculate new position to keep center fixed new_x = center_x - new_width / 2.0 new_y = center_y - new_height / 2.0 # Update rectangle attributes rect.set("x", str(new_x)) rect.set("y", str(new_y)) rect.set("width", str(new_width)) rect.set("height", str(new_height)) _logger.debug( f"Updated rectangle: x={new_x:.2f}, y={new_y:.2f}, width={new_width:.2f}, height={new_height:.2f}" ) # Get current SVG dimensions current_width = root.get("width") current_height = root.get("height") # Calculate bounds of rectangle (after update) rect_points = [ (new_x, new_y), (new_x + new_width, new_y), (new_x, new_y + new_height), (new_x + new_width, new_y + new_height), ] # Calculate bounds all_x_coords = [x for x, _ in rect_points] all_y_coords = [y for _, y in rect_points] min_x = min(all_x_coords) max_x = max(all_x_coords) min_y = min(all_y_coords) max_y = max(all_y_coords) # 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 rectangle 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 rectangle {svg_path}: {e}") raise