zcbot/skills/ppt/scripts/svg_finalize/svg_rect_to_path.py

338 lines
10 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - SVG Rounded Rectangle to Path Tool
Solves the issue of rounded corners being lost when using "Convert to Shape" in PowerPoint:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Usage:
python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory>
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s output
python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final -o svg_rounded
Examples:
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo
python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg
Output:
- Directory mode: outputs to svg_rounded/ subdirectory
- File mode: outputs to <filename>_rounded.svg
"""
import sys
import re
import argparse
from pathlib import Path
from typing import Any, Tuple
from xml.etree import ElementTree as ET
def rect_to_rounded_path(
x: float,
y: float,
width: float,
height: float,
rx: float,
ry: float,
) -> str:
"""
Convert a rounded rectangle to an SVG path string.
Uses elliptical arc commands to draw rounded corners.
"""
# Limit corner radius to half of width/height
rx = min(rx, width / 2)
ry = min(ry, height / 2)
# Calculate key points
x1 = x + rx
x2 = x + width - rx
y1 = y + ry
y2 = y + height - ry
# Build path
path = (
f"M{x1:.2f},{y:.2f} "
f"H{x2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} "
f"V{y2:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} "
f"H{x1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} "
f"V{y1:.2f} "
f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} "
f"Z"
)
# Clean up excess decimals
path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path)
return path
def parse_float(val: str, default: float = 0.0) -> float:
"""Safely parse a float value."""
if not val:
return default
try:
# Remove units
val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip())
return float(val)
except ValueError:
return default
def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]:
"""
Process SVG content, converting rounded rectangles to paths.
Returns (processed content, conversion count).
"""
converted_count = 0
# Save original XML declaration
xml_declaration = ''
if content.strip().startswith('<?xml'):
match = re.match(r'(<\?xml[^?]*\?>)', content)
if match:
xml_declaration = match.group(1) + '\n'
# Register SVG namespaces
ET.register_namespace('', 'http://www.w3.org/2000/svg')
ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink')
try:
root = ET.fromstring(content)
except ET.ParseError as e:
if verbose:
print(f" XML parse error: {e}")
return content, 0
# Get default namespace
ns = ''
if root.tag.startswith('{'):
ns = root.tag.split('}')[0] + '}'
def get_tag_name(tag: str) -> str:
"""Get tag name without namespace."""
if tag.startswith('{'):
return tag.split('}')[1]
return tag
def process_element(elem: ET.Element) -> None:
"""Process a single element."""
nonlocal converted_count
tag_name = get_tag_name(elem.tag)
# Process rounded rectangles
if tag_name == 'rect':
rx = parse_float(elem.get('rx', '0'))
ry = parse_float(elem.get('ry', '0'))
# If only one is specified, the other takes the same value
if rx == 0 and ry > 0:
rx = ry
elif ry == 0 and rx > 0:
ry = rx
if rx > 0 or ry > 0:
x = parse_float(elem.get('x', '0'))
y = parse_float(elem.get('y', '0'))
width = parse_float(elem.get('width', '0'))
height = parse_float(elem.get('height', '0'))
if width > 0 and height > 0:
# Generate path
path_d = rect_to_rounded_path(x, y, width, height, rx, ry)
# rect-specific attributes
rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'}
# Change element to path
elem.tag = ns + 'path' if ns else 'path'
elem.set('d', path_d)
# Remove rect-specific attributes
for attr in rect_attrs:
if attr in elem.attrib:
del elem.attrib[attr]
converted_count += 1
if verbose:
print(f" Converted rounded rect: rx={rx}, ry={ry}")
# Recursively process child elements
for child in elem:
process_element(child)
# Process all elements
process_element(root)
# Convert back to string
result = ET.tostring(root, encoding='unicode')
# Add XML declaration (if originally present)
if xml_declaration:
result = xml_declaration + result
return result, converted_count
def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> tuple[bool, int]:
"""Process a single SVG file."""
try:
with open(input_path, 'r', encoding='utf-8') as f:
content = f.read()
processed, count = process_svg(content, verbose)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(processed)
return True, count
except Exception as e:
if verbose:
print(f" Error: {e}")
return False, 0
def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Path], str]:
"""Find SVG files in a project."""
dir_map = {
'output': 'svg_output',
'final': 'svg_final',
'flat': 'svg_output_flattext',
'final_flat': 'svg_final_flattext',
}
dir_name = dir_map.get(source, source)
svg_dir = project_path / dir_name
if not svg_dir.exists():
if (project_path / 'svg_output').exists():
dir_name = 'svg_output'
svg_dir = project_path / dir_name
elif project_path.is_dir():
svg_dir = project_path
dir_name = project_path.name
if not svg_dir.exists():
return [], ''
return sorted(svg_dir.glob('*.svg')), dir_name
def main() -> None:
"""Run the CLI entry point."""
parser = argparse.ArgumentParser(
description='PPT Master - SVG Rounded Rectangle to Path Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s examples/ppt169_demo
%(prog)s examples/ppt169_demo -s final
%(prog)s examples/ppt169_demo/svg_output/01_cover.svg
What it does:
Converts <rect> elements with rx/ry to equivalent <path> elements.
Processed SVGs preserve rounded corners when using "Convert to Shape" in PowerPoint.
'''
)
parser.add_argument('path', type=str, help='SVG file or project directory path')
parser.add_argument('-s', '--source', type=str, default='output',
help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)')
parser.add_argument('-o', '--output', type=str, default='svg_rounded',
help='Output directory name (default: svg_rounded)')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
args = parser.parse_args()
input_path = Path(args.path)
if not input_path.exists():
print(f"Error: Path not found: {input_path}")
sys.exit(1)
verbose = args.verbose and not args.quiet
quiet = args.quiet
if not quiet:
print("PPT Master - SVG Rounded Rectangle to Path Tool")
print("=" * 50)
total_converted = 0
if input_path.is_file() and input_path.suffix.lower() == '.svg':
# Single file mode
output_path = input_path.with_stem(input_path.stem + '_rounded')
if not quiet:
print(f" Input: {input_path}")
print(f" Output: {output_path}")
print()
success, count = process_svg_file(input_path, output_path, verbose)
total_converted = count
if success:
if not quiet:
print(f"[DONE] Saved: {output_path}")
else:
print(f"[FAIL] Processing failed")
sys.exit(1)
else:
# Directory/project mode
svg_files, source_dir = find_svg_files(input_path, args.source)
if not svg_files:
print("Error: No SVG files found")
sys.exit(1)
output_dir = input_path / args.output
if not quiet:
print(f" Project path: {input_path}")
print(f" SVG source: {source_dir}")
print(f" Output directory: {args.output}")
print(f" File count: {len(svg_files)}")
print()
success_count = 0
for i, svg_file in enumerate(svg_files, 1):
output_path = output_dir / svg_file.name
if verbose:
print(f" [{i}/{len(svg_files)}] {svg_file.name}")
success, count = process_svg_file(svg_file, output_path, verbose)
if success:
success_count += 1
total_converted += count
if not verbose and not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} OK")
else:
if not quiet:
print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED")
if not quiet:
print()
print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}")
print(f" Output directory: {output_dir}")
# Show statistics
if not quiet:
print()
print(f"Conversion stats: rounded rect -> path: {total_converted}")
sys.exit(0)
if __name__ == '__main__':
main()