## 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 arrow 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"}
# Module logger
_logger = get_logger("SVGArrowUtils")
[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)
[docs]
def is_straight_arrow_svg(svg_input: str) -> bool:
"""
Check if SVG is a straight arrow (up, down, left, right).
Args:
svg_input: Either a file path or cache hash (starting with ``'svg_'``)
Returns:
True if SVG matches straight arrow pattern, False otherwise
"""
try:
tree = _load_svg_tree(svg_input)
root = tree.getroot()
# Find line and polygon elements
lines = root.findall(".//svg:line", SVG_NS)
polygons = root.findall(".//svg:polygon", SVG_NS)
if len(lines) != 1 or len(polygons) != 1:
_logger.debug(f"Not a straight arrow: wrong element count (lines={len(lines)}, polygons={len(polygons)})")
return False
line = lines[0]
polygon = polygons[0]
# Get coordinates
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError):
_logger.debug("Invalid line coordinates")
return False
# Check if line is straight (horizontal or vertical)
# Allow small floating point errors (0.1 units)
is_horizontal = abs(y1 - y2) < 0.1
is_vertical = abs(x1 - x2) < 0.1
if not (is_horizontal or is_vertical):
_logger.debug(f"Line not straight: ({x1},{y1}) to ({x2},{y2})")
return False
# Check polygon has points
if polygon.get("points") is None:
_logger.debug("Polygon has no points")
return False
_logger.debug(f"Detected straight arrow at {svg_input}")
return True
except ET.ParseError as e:
_logger.error(f"Failed to parse SVG {svg_input}: {e}")
return False
except Exception as e:
_logger.error(f"Error checking SVG {svg_input}: {e}")
return False
[docs]
def get_arrow_direction(svg_path: str) -> str | None:
"""
Determine arrow direction: 'right', 'left', 'up', or 'down'.
Returns:
Direction string or None if not a straight arrow
"""
try:
tree = _load_svg_tree(svg_path)
root = tree.getroot()
line = root.find(".//svg:line", SVG_NS)
if line is None:
return None
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError):
return None
# Determine direction with tolerance
if abs(y1 - y2) < 0.1: # Horizontal
if x1 < x2:
return "right"
else:
return "left"
elif abs(x1 - x2) < 0.1: # Vertical
if y1 < y2:
return "down"
else:
return "up"
else:
return None
except Exception as e:
_logger.error(f"Error getting arrow direction from {svg_path}: {e}")
return None
[docs]
def is_diagonal_arrow_svg(svg_path: str) -> bool:
"""
Check if SVG is a 45° diagonal arrow (upright, upleft, downright, downleft).
Returns:
True if SVG matches 45° diagonal arrow pattern, False otherwise
"""
try:
tree = _load_svg_tree(svg_path)
root = tree.getroot()
# Find line and polygon elements
lines = root.findall(".//svg:line", SVG_NS)
polygons = root.findall(".//svg:polygon", SVG_NS)
if len(lines) != 1 or len(polygons) != 1:
_logger.debug(f"Not a diagonal arrow: wrong element count (lines={len(lines)}, polygons={len(polygons)})")
return False
line = lines[0]
polygon = polygons[0]
# Get coordinates
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError):
_logger.debug("Invalid line coordinates")
return False
# Check if line is at 45° (± tolerance)
dx = abs(x2 - x1)
dy = abs(y2 - y1)
# 10% tolerance or 0.1 units (match existing straight arrow logic)
tolerance = max(dx, dy) * 0.1
is_45_degree = abs(dx - dy) < max(tolerance, 0.1)
if not is_45_degree:
_logger.debug(f"Line not 45°: dx={dx}, dy={dy}, tolerance={max(tolerance, 0.1)}")
return False
# Check polygon has points
if polygon.get("points") is None:
_logger.debug("Polygon has no points")
return False
_logger.debug(f"Detected 45° diagonal arrow at {svg_path}")
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_diagonal_arrow_direction(svg_path: str) -> str | None:
"""
Determine diagonal arrow direction: 'upright', 'upleft', 'downright', or 'downleft'.
Returns:
Direction string or None if not a 45° diagonal arrow
"""
try:
tree = _load_svg_tree(svg_path)
root = tree.getroot()
line = root.find(".//svg:line", SVG_NS)
if line is None:
return None
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError):
return None
# Check if line is at 45° (± tolerance)
dx = abs(x2 - x1)
dy = abs(y2 - y1)
tolerance = max(dx, dy) * 0.1
is_45_degree = abs(dx - dy) < max(tolerance, 0.1)
if not is_45_degree:
return None
# Determine diagonal direction
if x1 < x2: # Rightward
if y1 < y2: # Downward
return "downright"
else: # Upward
return "upright"
else: # Leftward
if y1 < y2: # Downward
return "downleft"
else: # Upward
return "upleft"
except Exception as e:
_logger.error(f"Error getting diagonal arrow direction from {svg_path}: {e}")
return None
[docs]
def is_arrow_svg(svg_path: str) -> bool:
"""
Check if SVG is any arrow (straight or 45° diagonal).
Returns:
True if SVG matches any arrow pattern, False otherwise
"""
return is_straight_arrow_svg(svg_path) or is_diagonal_arrow_svg(svg_path)
[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 elongate_straight_arrow(svg_path: str, scale_factor: float) -> str:
"""
Elongate straight arrow SVG.
Stores modified SVG in cache and returns cache hash.
Args:
svg_path: Path to SVG file (will be read but not modified)
scale_factor: Multiplier for arrow length (minimum 0.2)
Returns:
Cache hash of modified SVG (format: svg_{8_char_hex})
Raises:
ValueError: If scale_factor < 0.2
Exception: If SVG parsing or modification fails
"""
if scale_factor < 0.2:
raise ValueError(f"Scale factor must be >= 0.2, got {scale_factor}")
# Get SVG cache
svg_cache = get_svg_cache()
try:
# Parse SVG
tree = _load_svg_tree(svg_path)
root = tree.getroot()
# Get arrow direction
direction = get_arrow_direction(svg_path)
if direction is None:
raise ValueError(f"Not a straight arrow: {svg_path}")
# Find elements
line = root.find(".//svg:line", SVG_NS)
polygon = root.find(".//svg:polygon", SVG_NS)
if line is None or polygon is None:
raise ValueError(f"Missing line or polygon in {svg_path}")
# Get current coordinates
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid coordinates in {svg_path}: {e}") from e
# Parse polygon points
points_str = polygon.get("points", "")
points = []
for pair in points_str.split():
if "," in pair:
try:
x, y = map(float, pair.split(","))
points.append((x, y))
except (ValueError, TypeError):
_logger.error(f"Invalid polygon point: {pair}")
return False
if len(points) != 3:
_logger.error(f"Expected triangle arrowhead, got {len(points)} points")
return False
# Calculate new line end coordinates
# Initialize shift variables (used for left/up arrows that go negative)
if direction == "right" or direction == "left":
new_x2 = x1 + (x2 - x1) * scale_factor
new_y2 = y1
line_base_x = x2
line_base_y = y2
elif direction == "up" or direction == "down":
new_x2 = x1
new_y2 = y1 + (y2 - y1) * scale_factor
line_base_x = x2
line_base_y = y2
else:
_logger.error(f"Unknown direction: {direction}")
raise ValueError(f"Unknown direction: {direction}")
# Update line coordinates
line.set("x2", str(new_x2))
line.set("y2", str(new_y2))
# Calculate arrowhead offset
dx = new_x2 - line_base_x
dy = new_y2 - line_base_y
# Update polygon points (uniform scaling - maintain arrowhead size)
new_points = []
for px, py in points:
# Move points by the same offset as line end
new_px = px + dx
new_py = py + dy
new_points.append(f"{new_px},{new_py}")
polygon.set("points", " ".join(new_points))
# Update SVG dimensions to accommodate elongated/shortened arrow
# Get current SVG dimensions
current_width = root.get("width")
current_height = root.get("height")
# Calculate all points after modification (before any potential shifting)
all_points = [(x1, y1), (new_x2, new_y2)] + [(px + dx, py + dy) for px, py in points]
# Calculate bounds of all points
all_x_coords = [x for x, _ in all_points]
all_y_coords = [y for _, y in all_points]
# Initialize bounds
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
# Handle each direction separately
# Don't shift coordinates - allow negative values in viewBox
# For all directions, we'll update dimensions based on actual bounds
# For straight arrows, allow negative coordinates in viewBox
# Don't shift coordinates - just calculate proper bounds with padding
# Apply padding to bounds
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 {direction} arrow by factor {scale_factor:.2f}, cache hash: {cache_hash}")
return cache_hash
except Exception as e:
_logger.error(f"Error elongating arrow {svg_path}: {e}")
raise
[docs]
def elongate_diagonal_arrow(svg_path: str, scale_factor: float) -> str:
"""
Elongate 45° diagonal arrow SVG.
Stores modified SVG in cache and returns cache hash.
Args:
svg_path: Path to SVG file (will be read but not modified)
scale_factor: Multiplier for arrow length (minimum 0.2)
Returns:
Cache hash of modified SVG (format: svg_{8_char_hex})
Raises:
ValueError: If scale_factor < 0.2
Exception: If SVG parsing or modification fails
"""
if scale_factor < 0.2:
raise ValueError(f"Scale factor must be >= 0.2, got {scale_factor}")
# Get SVG cache
svg_cache = get_svg_cache()
try:
# Parse SVG
tree = _load_svg_tree(svg_path)
root = tree.getroot()
# Get arrow direction
direction = get_diagonal_arrow_direction(svg_path)
if direction is None:
raise ValueError(f"Not a 45° diagonal arrow: {svg_path}")
# Find elements
line = root.find(".//svg:line", SVG_NS)
polygon = root.find(".//svg:polygon", SVG_NS)
if line is None or polygon is None:
raise ValueError(f"Missing line or polygon in {svg_path}")
# Get current coordinates
try:
x1 = float(line.get("x1", 0))
y1 = float(line.get("y1", 0))
x2 = float(line.get("x2", 0))
y2 = float(line.get("y2", 0))
except (ValueError, TypeError) as e:
raise ValueError(f"Invalid coordinates in {svg_path}: {e}") from e
# Parse polygon points
points_str = polygon.get("points", "")
points = []
for pair in points_str.split():
if "," in pair:
try:
x, y = map(float, pair.split(","))
points.append((x, y))
except (ValueError, TypeError):
_logger.error(f"Invalid polygon point: {pair}")
return False
if len(points) != 3:
_logger.error(f"Expected triangle arrowhead, got {len(points)} points")
return False
# Initialize shift variables (used for arrows that go negative)
# Calculate new line end coordinates (scale both x and y)
if direction == "upright" or direction == "upleft" or direction == "downright" or direction == "downleft":
new_x2 = x1 + (x2 - x1) * scale_factor
new_y2 = y1 + (y2 - y1) * scale_factor
line_base_x = x2
line_base_y = y2
else:
_logger.error(f"Unknown diagonal direction: {direction}")
return False
# Update line coordinates
line.set("x2", str(new_x2))
line.set("y2", str(new_y2))
# Calculate arrowhead offset
dx = new_x2 - line_base_x
dy = new_y2 - line_base_y
# Update polygon points (uniform scaling - maintain arrowhead size)
new_points = []
for px, py in points:
# Move points by the same offset as line end
new_px = px + dx
new_py = py + dy
new_points.append(f"{new_px},{new_py}")
polygon.set("points", " ".join(new_points))
# Update SVG dimensions to accommodate elongated/shortened arrow
# Get current SVG dimensions
current_width = root.get("width")
current_height = root.get("height")
# Calculate all points after modification (before any potential shifting)
all_points = [(x1, y1), (new_x2, new_y2)] + [(px + dx, py + dy) for px, py in points]
# Calculate bounds of all points
all_x_coords = [x for x, _ in all_points]
all_y_coords = [y for _, y in all_points]
# Initialize bounds
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
# For diagonal arrows, allow negative coordinates in viewBox
# Don't shift coordinates - just calculate proper bounds with padding
# Apply padding to bounds
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 {direction} diagonal arrow by factor {scale_factor:.2f}, cache hash: {cache_hash}")
return cache_hash
except Exception as e:
_logger.error(f"Error elongating diagonal arrow {svg_path}: {e}")
raise