返回 ppt-master
svg_rect_to_path.py
根目录 / skills / ppt-master / scripts / svg_finalize / svg_rect_to_path.py
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
347 lines PYTHON