#!/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 elements with rx/ry to equivalent elements. Usage: python3 scripts/svg_finalize/svg_rect_to_path.py python3 scripts/svg_finalize/svg_rect_to_path.py -s output python3 scripts/svg_finalize/svg_rect_to_path.py -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 _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(')', 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 elements with rx/ry to equivalent 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()