| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Legacy SVG Rounded Rectangle Diagnostic |
| 4 | |
| 5 | Historical diagnostic that converts <rect> elements with rx/ry to equivalent |
| 6 | <path> elements. It is not part of finalize_svg.py or the supported export |
| 7 | workflow; PowerPoint's manual "Convert to Shape" behavior is not supported. |
| 8 | |
| 9 | Usage: |
| 10 | python3 scripts/svg_finalize/svg_rect_to_path.py <SVG file or directory> |
| 11 | python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s output |
| 12 | python3 scripts/svg_finalize/svg_rect_to_path.py <project_path> -s final -o svg_rounded |
| 13 | |
| 14 | Examples: |
| 15 | python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo |
| 16 | python3 scripts/svg_finalize/svg_rect_to_path.py examples/ppt169_demo/svg_output/01_cover.svg |
| 17 | |
| 18 | Output: |
| 19 | - Directory mode: outputs to svg_rounded/ subdirectory |
| 20 | - File mode: outputs to <filename>_rounded.svg |
| 21 | """ |
| 22 | |
| 23 | import sys |
| 24 | import re |
| 25 | import argparse |
| 26 | from pathlib import Path |
| 27 | from typing import Any, Tuple |
| 28 | from xml.etree import ElementTree as ET |
| 29 | |
| 30 | _SCRIPTS_DIR = Path(__file__).resolve().parents[1] |
| 31 | if str(_SCRIPTS_DIR) not in sys.path: |
| 32 | sys.path.insert(0, str(_SCRIPTS_DIR)) |
| 33 | |
| 34 | from console_encoding import configure_utf8_stdio # noqa: E402 |
| 35 | |
| 36 | configure_utf8_stdio() |
| 37 | |
| 38 | |
| 39 | def rect_to_rounded_path( |
| 40 | x: float, |
| 41 | y: float, |
| 42 | width: float, |
| 43 | height: float, |
| 44 | rx: float, |
| 45 | ry: float, |
| 46 | ) -> str: |
| 47 | """ |
| 48 | Convert a rounded rectangle to an SVG path string. |
| 49 | Uses elliptical arc commands to draw rounded corners. |
| 50 | """ |
| 51 | # Limit corner radius to half of width/height |
| 52 | rx = min(rx, width / 2) |
| 53 | ry = min(ry, height / 2) |
| 54 | |
| 55 | # Calculate key points |
| 56 | x1 = x + rx |
| 57 | x2 = x + width - rx |
| 58 | y1 = y + ry |
| 59 | y2 = y + height - ry |
| 60 | |
| 61 | # Build path |
| 62 | path = ( |
| 63 | f"M{x1:.2f},{y:.2f} " |
| 64 | f"H{x2:.2f} " |
| 65 | f"A{rx:.2f},{ry:.2f} 0 0 1 {x + width:.2f},{y1:.2f} " |
| 66 | f"V{y2:.2f} " |
| 67 | f"A{rx:.2f},{ry:.2f} 0 0 1 {x2:.2f},{y + height:.2f} " |
| 68 | f"H{x1:.2f} " |
| 69 | f"A{rx:.2f},{ry:.2f} 0 0 1 {x:.2f},{y2:.2f} " |
| 70 | f"V{y1:.2f} " |
| 71 | f"A{rx:.2f},{ry:.2f} 0 0 1 {x1:.2f},{y:.2f} " |
| 72 | f"Z" |
| 73 | ) |
| 74 | |
| 75 | # Clean up excess decimals |
| 76 | path = re.sub(r'\.00(?=\s|,|[A-Za-z]|$)', '', path) |
| 77 | |
| 78 | return path |
| 79 | |
| 80 | |
| 81 | def parse_float(val: str, default: float = 0.0) -> float: |
| 82 | """Safely parse a float value.""" |
| 83 | if not val: |
| 84 | return default |
| 85 | try: |
| 86 | # Remove units |
| 87 | val = re.sub(r'(px|pt|em|%|rem)$', '', val.strip()) |
| 88 | return float(val) |
| 89 | except ValueError: |
| 90 | return default |
| 91 | |
| 92 | |
| 93 | def process_svg(content: str, verbose: bool = False) -> Tuple[str, int]: |
| 94 | """ |
| 95 | Process SVG content, converting rounded rectangles to paths. |
| 96 | Returns (processed content, conversion count). |
| 97 | """ |
| 98 | converted_count = 0 |
| 99 | |
| 100 | # Save original XML declaration |
| 101 | xml_declaration = '' |
| 102 | if content.strip().startswith('<?xml'): |
| 103 | match = re.match(r'(<\?xml[^?]*\?>)', content) |
| 104 | if match: |
| 105 | xml_declaration = match.group(1) + '\n' |
| 106 | |
| 107 | # Register SVG namespaces |
| 108 | ET.register_namespace('', 'http://www.w3.org/2000/svg') |
| 109 | ET.register_namespace('xlink', 'http://www.w3.org/1999/xlink') |
| 110 | |
| 111 | try: |
| 112 | root = ET.fromstring(content) |
| 113 | except ET.ParseError as e: |
| 114 | if verbose: |
| 115 | print(f" XML parse error: {e}") |
| 116 | return content, 0 |
| 117 | |
| 118 | # Get default namespace |
| 119 | ns = '' |
| 120 | if root.tag.startswith('{'): |
| 121 | ns = root.tag.split('}')[0] + '}' |
| 122 | |
| 123 | def get_tag_name(tag: str) -> str: |
| 124 | """Get tag name without namespace.""" |
| 125 | if tag.startswith('{'): |
| 126 | return tag.split('}')[1] |
| 127 | return tag |
| 128 | |
| 129 | def process_element(elem: ET.Element) -> None: |
| 130 | """Process a single element.""" |
| 131 | nonlocal converted_count |
| 132 | tag_name = get_tag_name(elem.tag) |
| 133 | |
| 134 | # Process rounded rectangles |
| 135 | if tag_name == 'rect': |
| 136 | rx = parse_float(elem.get('rx', '0')) |
| 137 | ry = parse_float(elem.get('ry', '0')) |
| 138 | |
| 139 | # If only one is specified, the other takes the same value |
| 140 | if rx == 0 and ry > 0: |
| 141 | rx = ry |
| 142 | elif ry == 0 and rx > 0: |
| 143 | ry = rx |
| 144 | |
| 145 | if rx > 0 or ry > 0: |
| 146 | x = parse_float(elem.get('x', '0')) |
| 147 | y = parse_float(elem.get('y', '0')) |
| 148 | width = parse_float(elem.get('width', '0')) |
| 149 | height = parse_float(elem.get('height', '0')) |
| 150 | |
| 151 | if width > 0 and height > 0: |
| 152 | # Generate path |
| 153 | path_d = rect_to_rounded_path(x, y, width, height, rx, ry) |
| 154 | |
| 155 | # rect-specific attributes |
| 156 | rect_attrs = {'x', 'y', 'width', 'height', 'rx', 'ry'} |
| 157 | |
| 158 | # Change element to path |
| 159 | elem.tag = ns + 'path' if ns else 'path' |
| 160 | elem.set('d', path_d) |
| 161 | |
| 162 | # Remove rect-specific attributes |
| 163 | for attr in rect_attrs: |
| 164 | if attr in elem.attrib: |
| 165 | del elem.attrib[attr] |
| 166 | |
| 167 | converted_count += 1 |
| 168 | if verbose: |
| 169 | print(f" Converted rounded rect: rx={rx}, ry={ry}") |
| 170 | |
| 171 | # Recursively process child elements |
| 172 | for child in elem: |
| 173 | process_element(child) |
| 174 | |
| 175 | # Process all elements |
| 176 | process_element(root) |
| 177 | |
| 178 | # Convert back to string |
| 179 | result = ET.tostring(root, encoding='unicode') |
| 180 | |
| 181 | # Add XML declaration (if originally present) |
| 182 | if xml_declaration: |
| 183 | result = xml_declaration + result |
| 184 | |
| 185 | return result, converted_count |
| 186 | |
| 187 | |
| 188 | def process_svg_file(input_path: Path, output_path: Path, verbose: bool = False) -> tuple[bool, int]: |
| 189 | """Process a single SVG file.""" |
| 190 | try: |
| 191 | with open(input_path, 'r', encoding='utf-8') as f: |
| 192 | content = f.read() |
| 193 | |
| 194 | processed, count = process_svg(content, verbose) |
| 195 | |
| 196 | # Ensure output directory exists |
| 197 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 198 | |
| 199 | with open(output_path, 'w', encoding='utf-8') as f: |
| 200 | f.write(processed) |
| 201 | |
| 202 | return True, count |
| 203 | |
| 204 | except Exception as e: |
| 205 | if verbose: |
| 206 | print(f" Error: {e}") |
| 207 | return False, 0 |
| 208 | |
| 209 | |
| 210 | def find_svg_files(project_path: Path, source: str = 'output') -> tuple[list[Path], str]: |
| 211 | """Find SVG files in a project.""" |
| 212 | dir_map = { |
| 213 | 'output': 'svg_output', |
| 214 | 'final': 'svg_final', |
| 215 | 'flat': 'svg_output_flattext', |
| 216 | 'final_flat': 'svg_final_flattext', |
| 217 | } |
| 218 | |
| 219 | dir_name = dir_map.get(source, source) |
| 220 | svg_dir = project_path / dir_name |
| 221 | |
| 222 | if not svg_dir.exists(): |
| 223 | if (project_path / 'svg_output').exists(): |
| 224 | dir_name = 'svg_output' |
| 225 | svg_dir = project_path / dir_name |
| 226 | elif project_path.is_dir(): |
| 227 | svg_dir = project_path |
| 228 | dir_name = project_path.name |
| 229 | |
| 230 | if not svg_dir.exists(): |
| 231 | return [], '' |
| 232 | |
| 233 | return sorted(svg_dir.glob('*.svg')), dir_name |
| 234 | |
| 235 | |
| 236 | def main() -> None: |
| 237 | """Run the CLI entry point.""" |
| 238 | parser = argparse.ArgumentParser( |
| 239 | description='PPT Master - Legacy SVG Rounded Rectangle Diagnostic', |
| 240 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 241 | epilog=''' |
| 242 | Examples: |
| 243 | %(prog)s examples/ppt169_demo |
| 244 | %(prog)s examples/ppt169_demo -s final |
| 245 | %(prog)s examples/ppt169_demo/svg_output/01_cover.svg |
| 246 | |
| 247 | What it does: |
| 248 | Converts <rect> elements with rx/ry to equivalent <path> elements. |
| 249 | Legacy diagnostic only; the standard pipeline preserves SVG rounded rectangles. |
| 250 | ''' |
| 251 | ) |
| 252 | |
| 253 | parser.add_argument('path', type=str, help='SVG file or project directory path') |
| 254 | parser.add_argument('-s', '--source', type=str, default='output', |
| 255 | help='SVG source: output/final/flat/final_flat or subdirectory name (default: output)') |
| 256 | parser.add_argument('-o', '--output', type=str, default='svg_rounded', |
| 257 | help='Output directory name (default: svg_rounded)') |
| 258 | parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') |
| 259 | parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode') |
| 260 | |
| 261 | args = parser.parse_args() |
| 262 | |
| 263 | input_path = Path(args.path) |
| 264 | |
| 265 | if not input_path.exists(): |
| 266 | print(f"Error: Path not found: {input_path}") |
| 267 | sys.exit(1) |
| 268 | |
| 269 | verbose = args.verbose and not args.quiet |
| 270 | quiet = args.quiet |
| 271 | |
| 272 | if not quiet: |
| 273 | print("PPT Master - Legacy SVG Rounded Rectangle Diagnostic") |
| 274 | print("=" * 50) |
| 275 | |
| 276 | total_converted = 0 |
| 277 | |
| 278 | if input_path.is_file() and input_path.suffix.lower() == '.svg': |
| 279 | # Single file mode |
| 280 | output_path = input_path.with_stem(input_path.stem + '_rounded') |
| 281 | |
| 282 | if not quiet: |
| 283 | print(f" Input: {input_path}") |
| 284 | print(f" Output: {output_path}") |
| 285 | print() |
| 286 | |
| 287 | success, count = process_svg_file(input_path, output_path, verbose) |
| 288 | total_converted = count |
| 289 | |
| 290 | if success: |
| 291 | if not quiet: |
| 292 | print(f"[DONE] Saved: {output_path}") |
| 293 | else: |
| 294 | print(f"[FAIL] Processing failed") |
| 295 | sys.exit(1) |
| 296 | |
| 297 | else: |
| 298 | # Directory/project mode |
| 299 | svg_files, source_dir = find_svg_files(input_path, args.source) |
| 300 | |
| 301 | if not svg_files: |
| 302 | print("Error: No SVG files found") |
| 303 | sys.exit(1) |
| 304 | |
| 305 | output_dir = input_path / args.output |
| 306 | |
| 307 | if not quiet: |
| 308 | print(f" Project path: {input_path}") |
| 309 | print(f" SVG source: {source_dir}") |
| 310 | print(f" Output directory: {args.output}") |
| 311 | print(f" File count: {len(svg_files)}") |
| 312 | print() |
| 313 | |
| 314 | success_count = 0 |
| 315 | for i, svg_file in enumerate(svg_files, 1): |
| 316 | output_path = output_dir / svg_file.name |
| 317 | |
| 318 | if verbose: |
| 319 | print(f" [{i}/{len(svg_files)}] {svg_file.name}") |
| 320 | |
| 321 | success, count = process_svg_file(svg_file, output_path, verbose) |
| 322 | |
| 323 | if success: |
| 324 | success_count += 1 |
| 325 | total_converted += count |
| 326 | if not verbose and not quiet: |
| 327 | print(f" [{i}/{len(svg_files)}] {svg_file.name} OK") |
| 328 | else: |
| 329 | if not quiet: |
| 330 | print(f" [{i}/{len(svg_files)}] {svg_file.name} FAILED") |
| 331 | |
| 332 | if not quiet: |
| 333 | print() |
| 334 | print(f"[DONE] Succeeded: {success_count}/{len(svg_files)}") |
| 335 | print(f" Output directory: {output_dir}") |
| 336 | |
| 337 | # Show statistics |
| 338 | if not quiet: |
| 339 | print() |
| 340 | print(f"Conversion stats: rounded rect -> path: {total_converted}") |
| 341 | |
| 342 | sys.exit(0) |
| 343 | |
| 344 | |
| 345 | if __name__ == '__main__': |
| 346 | main() |
| 347 |