返回 ppt-master
finalize_svg.py
根目录 / skills / ppt-master / scripts / finalize_svg.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - SVG Post-processing Tool (Unified Entry Point)
4
5 Processes SVG files from svg_output/ and produces the visual preview in
6 svg_final/, embedding supported raster/SVG assets. Native PPTX export continues
7 to read svg_output/ by default; svg_final/ may be opened directly or inserted
8 as an SVG image. EMF/WMF assets retain their external-reference exception.
9 By default, all processing steps are executed. You can also specify individual
10 steps via arguments.
11
12 Architecture note: this module's outputs feed svg_final/ on disk AND its
13 sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...)
14 are memory-reused by svg_to_pptx during native conversion. Deleting any
15 step here may also break native pptx output, not just svg_final/.
16 See scripts/docs/svg-pipeline.md before modifying the shared pipeline.
17
18 Usage:
19 # Execute all processing steps (recommended)
20 python3 scripts/finalize_svg.py <project_directory>
21
22 # Execute only specific steps
23 python3 scripts/finalize_svg.py <project_directory> --only embed-icons align-images
24
25 Examples:
26 python3 scripts/finalize_svg.py projects/my_project
27 python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons
28
29 Processing options:
30 embed-icons - Expand project icons and static same-document <use>
31 align-images - Align (slice/meet) and Base64-embed all <image> in one pass.
32 Replaces the former crop-images + fix-aspect + embed-images
33 trio. The old names remain accepted as aliases for the
34 merged step, so existing --only invocations keep working.
35 flatten-text - Convert <tspan> to independent <text> (for special renderers)
36 """
37
38 import argparse
39 import os
40 import shutil
41 import sys
42 import tempfile
43 from enum import Enum
44 from pathlib import Path
45 from typing import TextIO
46 from xml.etree import ElementTree as ET
47
48 from console_encoding import configure_utf8_stdio
49
50 configure_utf8_stdio()
51
52 # Import finalize helpers from the internal package.
53 sys.path.insert(0, str(Path(__file__).parent))
54 from resource_paths import icon_search_dirs_for_project # noqa: E402
55 from svg_finalize.align_embed_images import (
56 align_and_embed_images_in_svg,
57 count_office_vector_refs_in_svg,
58 )
59 from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file
60 from svg_to_pptx.geometry_properties import (
61 GeometryStyleError,
62 materialize_inline_geometry_in_file,
63 )
64 from svg_to_pptx.use_expander import (
65 UseExpansionError,
66 expand_local_use_references_in_file,
67 )
68
69
70 class FlattenTextResult(Enum):
71 """Describe whether flattening changed a file, skipped it, or failed."""
72
73 CHANGED = "changed"
74 UNCHANGED = "unchanged"
75 ERROR = "error"
76
77
78 def safe_print(text: str, *, file: TextIO | None = None) -> None:
79 """Print text while tolerating Windows terminal encoding limits."""
80 stream = file or sys.stdout
81 try:
82 print(text, file=stream)
83 except UnicodeEncodeError:
84 replacements = {
85 chr(0x23F3): "[..]",
86 chr(0x2705): "[DONE]",
87 chr(0x274C): "[ERROR]",
88 chr(0x26A0) + chr(0xFE0F): "[WARN]",
89 chr(0x1F4C1): "[DIR]",
90 chr(0x1F4C4): "[FILE]",
91 chr(0x1F4E6): "[OK]",
92 }
93 for source, target in replacements.items():
94 text = text.replace(source, target)
95 print(text, file=stream)
96
97
98 def process_flatten_text(
99 svg_file: Path,
100 verbose: bool = False,
101 ) -> FlattenTextResult:
102 """Flatten text in one SVG and report changed, unchanged, or error."""
103 try:
104 from svg_finalize.flatten_tspan import flatten_text_with_tspans
105
106 tree = ET.parse(str(svg_file))
107 changed = flatten_text_with_tspans(tree)
108
109 if changed:
110 tree.write(str(svg_file), encoding='unicode', xml_declaration=False)
111 if verbose:
112 safe_print(f" [OK] {svg_file.name}: text flattened")
113 return FlattenTextResult.CHANGED
114 return FlattenTextResult.UNCHANGED
115 except Exception as exc:
116 safe_print(
117 f" [ERROR] {svg_file.name}: text flattening failed: {exc}",
118 file=sys.stderr,
119 )
120 return FlattenTextResult.ERROR
121
122
123 def _path_lexists(path: Path) -> bool:
124 """Return whether a path or dangling symlink occupies the target name."""
125 return os.path.lexists(path)
126
127
128 def _publish_candidate_directory(candidate_dir: Path, output_dir: Path) -> None:
129 """Publish one staged directory and restore the previous output on failure."""
130 if output_dir.is_symlink() or (
131 _path_lexists(output_dir) and not output_dir.is_dir()
132 ):
133 raise RuntimeError(f"Output path must be a real directory: {output_dir}")
134
135 transaction_dir = Path(
136 tempfile.mkdtemp(
137 prefix=f".{output_dir.name}.publish-",
138 dir=output_dir.parent,
139 )
140 )
141 backup_dir = transaction_dir / "previous"
142 preserve_backup = False
143
144 try:
145 if output_dir.is_dir():
146 try:
147 os.replace(output_dir, backup_dir)
148 os.replace(candidate_dir, output_dir)
149 except BaseException as publish_error:
150 try:
151 if _path_lexists(backup_dir):
152 if _path_lexists(output_dir):
153 failed_output = transaction_dir / "failed-publish"
154 os.replace(output_dir, failed_output)
155 os.replace(backup_dir, output_dir)
156 except BaseException as restore_error:
157 if (
158 not _path_lexists(backup_dir)
159 and _path_lexists(output_dir)
160 ):
161 raise publish_error
162 preserve_backup = _path_lexists(backup_dir)
163 raise RuntimeError(
164 "Failed to publish svg_final and restore the previous "
165 "directory; recovery directory: "
166 f"{transaction_dir}"
167 ) from restore_error
168 raise
169 else:
170 os.replace(candidate_dir, output_dir)
171 finally:
172 if not preserve_backup:
173 shutil.rmtree(transaction_dir, ignore_errors=True)
174
175
176 def _process_candidate_directory(
177 candidate_dir: Path,
178 *,
179 options: dict[str, bool],
180 quiet: bool,
181 compress: bool,
182 max_dimension: int | None,
183 image_scale: float,
184 icons_dir: Path,
185 icons_fallback_dir: Path | None,
186 ) -> bool:
187 """Run every selected finalization pass against one unpublished candidate."""
188 # Core normalization: downstream image/rect processors read XML geometry.
189 geometry_count = 0
190 for svg_file in candidate_dir.glob('*.svg'):
191 try:
192 geometry_count += materialize_inline_geometry_in_file(svg_file)
193 except (OSError, ET.ParseError, GeometryStyleError) as exc:
194 safe_print(
195 f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}"
196 )
197 return False
198
199 # Step 1: Expand project icons, then standard same-document use references.
200 if options.get('embed_icons'):
201 if not quiet:
202 safe_print("[1/3] Expanding icons + local use references...")
203 icons_count = 0
204 for svg_file in candidate_dir.glob('*.svg'):
205 count = embed_icons_in_file(
206 svg_file,
207 icons_dir,
208 dry_run=False,
209 verbose=False,
210 fallback_dir=icons_fallback_dir,
211 )
212 icons_count += count
213 for svg_file in candidate_dir.glob('*.svg'):
214 try:
215 geometry_count += materialize_inline_geometry_in_file(svg_file)
216 except (OSError, ET.ParseError, GeometryStyleError) as exc:
217 safe_print(
218 f"[ERROR] {svg_file.name}: expanded icon geometry "
219 f"materialization failed: {exc}"
220 )
221 return False
222 local_use_count = 0
223 for svg_file in candidate_dir.glob('*.svg'):
224 try:
225 local_use_count += expand_local_use_references_in_file(svg_file)
226 except (OSError, ET.ParseError, UseExpansionError) as exc:
227 safe_print(
228 f"[ERROR] {svg_file.name}: local <use> expansion failed: {exc}"
229 )
230 return False
231 if not quiet:
232 if icons_count > 0:
233 safe_print(f" {icons_count} icon(s) embedded")
234 else:
235 safe_print(" No icons")
236 if local_use_count > 0:
237 safe_print(f" {local_use_count} local use reference(s) expanded")
238 else:
239 safe_print(" No local use references")
240
241 if not quiet and geometry_count:
242 safe_print(
243 f"[PREP] {geometry_count} inline geometry declaration(s) materialized"
244 )
245
246 # Step 2: Align (slice/meet) and Base64-embed all <image> in one pass.
247 # Replaces the former crop-images / fix-aspect / embed-images trio: the
248 # spatial transform (slice → crop, meet → fit-box) and the asset embed
249 # are mutually exclusive branches per image, sequenced together so each
250 # SVG is only parsed and serialized once and each bitmap is only read
251 # from disk once.
252 if options.get('align_images'):
253 if not quiet:
254 safe_print("[2/3] Aligning + embedding images...")
255 img_count = 0
256 img_errors = 0
257 office_vector_count = 0
258 for svg_file in candidate_dir.glob('*.svg'):
259 office_vector_count += count_office_vector_refs_in_svg(svg_file)
260 count, errs = align_and_embed_images_in_svg(
261 svg_file,
262 dry_run=False,
263 verbose=False,
264 compress=compress,
265 max_dimension=max_dimension,
266 image_scale=image_scale,
267 )
268 img_count += count
269 img_errors += errs
270 if img_errors:
271 safe_print(
272 f"[ERROR] Image alignment/embedding failed for "
273 f"{img_errors} image(s); svg_final was not published",
274 file=sys.stderr,
275 )
276 return False
277 if not quiet:
278 if img_count > 0:
279 msg = f" {img_count} image(s) aligned + embedded"
280 safe_print(msg)
281 if office_vector_count:
282 safe_print(
283 f" {office_vector_count} Office vector(s) left external "
284 "for native PPTX passthrough"
285 )
286 elif office_vector_count:
287 safe_print(
288 f" {office_vector_count} Office vector(s) left external "
289 "for native PPTX passthrough"
290 )
291 else:
292 safe_print(" No images")
293
294 # Step 3: Flatten text.
295 if options.get('flatten_text'):
296 if not quiet:
297 safe_print("[3/3] Flattening text...")
298 flatten_count = 0
299 flatten_errors = 0
300 for svg_file in candidate_dir.glob('*.svg'):
301 result = process_flatten_text(svg_file, verbose=False)
302 if result is FlattenTextResult.CHANGED:
303 flatten_count += 1
304 elif result is FlattenTextResult.ERROR:
305 flatten_errors += 1
306 if flatten_errors:
307 safe_print(
308 f"[ERROR] Text flattening failed for {flatten_errors} file(s); "
309 "svg_final was not published",
310 file=sys.stderr,
311 )
312 return False
313 if not quiet:
314 if flatten_count > 0:
315 safe_print(f" {flatten_count} file(s) processed")
316 else:
317 safe_print(" No processing needed")
318
319 return True
320
321
322 def finalize_project(
323 project_dir: Path,
324 options: dict[str, bool],
325 dry_run: bool = False,
326 quiet: bool = False,
327 compress: bool = True,
328 max_dimension: int | None = 2560,
329 image_scale: float = 2.0,
330 ) -> bool:
331 """
332 Finalize SVG files in the project
333
334 Args:
335 project_dir: Project directory path
336 options: Processing options dictionary
337 dry_run: Preview only, do not execute
338 quiet: Quiet mode, reduce output
339 compress: Compress images before embedding
340 max_dimension: Downscale images exceeding this dimension
341 image_scale: Target image pixels per SVG display pixel
342 """
343 svg_output = project_dir / 'svg_output'
344 svg_final = project_dir / 'svg_final'
345 icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir)
346
347 # Check if svg_output exists
348 if not svg_output.exists():
349 safe_print(f"[ERROR] svg_output directory not found: {svg_output}")
350 return False
351
352 # Get list of SVG files
353 svg_files = list(svg_output.glob('*.svg'))
354 if not svg_files:
355 safe_print(f"[ERROR] No SVG files in svg_output")
356 return False
357
358 if not quiet:
359 print()
360 safe_print(f"[DIR] Project: {project_dir.name}")
361 safe_print(f"[FILE] {len(svg_files)} SVG file(s)")
362
363 if dry_run:
364 safe_print("[PREVIEW] Preview mode, no operations will be performed")
365 return True
366
367 candidate_dir = Path(
368 tempfile.mkdtemp(
369 prefix=f".{svg_final.name}.candidate-",
370 dir=svg_final.parent,
371 )
372 )
373 try:
374 try:
375 shutil.copytree(svg_output, candidate_dir, dirs_exist_ok=True)
376 candidate_ready = _process_candidate_directory(
377 candidate_dir,
378 options=options,
379 quiet=quiet,
380 compress=compress,
381 max_dimension=max_dimension,
382 image_scale=image_scale,
383 icons_dir=icons_dir,
384 icons_fallback_dir=icons_fallback_dir,
385 )
386 except Exception as exc:
387 safe_print(
388 f"[ERROR] SVG finalization failed before publish: {exc}",
389 file=sys.stderr,
390 )
391 return False
392
393 if not candidate_ready:
394 return False
395
396 try:
397 _publish_candidate_directory(candidate_dir, svg_final)
398 except (OSError, RuntimeError) as exc:
399 safe_print(
400 f"[ERROR] svg_final publish failed: {exc}",
401 file=sys.stderr,
402 )
403 return False
404 finally:
405 shutil.rmtree(candidate_dir, ignore_errors=True)
406
407 # Done
408 if not quiet:
409 print()
410 safe_print("[OK] Done!")
411 print()
412 print("Next steps:")
413 print(f" python scripts/svg_to_pptx.py \"{project_dir}\"")
414
415 return True
416
417
418 def main() -> None:
419 """Run the CLI entry point."""
420 parser = argparse.ArgumentParser(
421 description='PPT Master - SVG Post-processing Tool',
422 formatter_class=argparse.RawDescriptionHelpFormatter,
423 epilog='''
424 Examples:
425 %(prog)s projects/my_project # Execute all processing (default)
426 %(prog)s projects/my_project --only embed-icons align-images
427 %(prog)s projects/my_project -q # Quiet mode
428
429 Processing options (for --only):
430 embed-icons Expand project icons and static same-document <use>
431 align-images Align (slice/meet) + Base64-embed all <image> (single pass)
432 flatten-text Flatten text
433
434 Aliases (still accepted):
435 crop-images, fix-aspect, embed-images → all map to align-images
436 '''
437 )
438
439 parser.add_argument('project_dir', type=Path, help='Project directory path')
440 parser.add_argument(
441 '--only', nargs='+', metavar='OPTION',
442 choices=[
443 'embed-icons',
444 'align-images',
445 # Backwards-compatible aliases — all three map to align-images now.
446 'crop-images', 'fix-aspect', 'embed-images',
447 'flatten-text',
448 ],
449 help=('Execute only specified processing steps (default: all). '
450 'crop-images / fix-aspect / embed-images are accepted as '
451 'aliases for the merged align-images step.'),
452 )
453 parser.add_argument('--dry-run', '-n', action='store_true',
454 help='Preview only, do not execute')
455 parser.add_argument('--quiet', '-q', action='store_true',
456 help='Quiet mode, reduce output')
457 parser.add_argument('--compress', dest='compress', action='store_true', default=True,
458 help='Compress images before embedding (default)')
459 parser.add_argument('--no-compress', dest='compress', action='store_false',
460 help='Disable image compression before embedding')
461 parser.add_argument('--max-dimension', type=int, default=2560,
462 help='Downscale images exceeding this dimension on either axis (default: 2560)')
463 parser.add_argument('--image-scale', type=float, default=2.0,
464 help='Target image pixels per SVG display pixel (default: 2.0)')
465
466 args = parser.parse_args()
467
468 if not args.project_dir.exists():
469 safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}")
470 sys.exit(1)
471
472 # Aliases: any of crop-images / fix-aspect / embed-images implies the
473 # merged align-images step. Older invocations stay valid.
474 _ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'}
475
476 # Determine processing options
477 if args.only:
478 only = set(args.only)
479 options = {
480 'embed_icons': 'embed-icons' in only,
481 'align_images': bool(only & _ALIGN_ALIASES),
482 'flatten_text': 'flatten-text' in only,
483 }
484 else:
485 # Execute all by default
486 options = {
487 'embed_icons': True,
488 'align_images': True,
489 'flatten_text': True,
490 }
491
492 if args.max_dimension < 1:
493 safe_print("[ERROR] --max-dimension must be >= 1")
494 sys.exit(1)
495 if args.image_scale < 1:
496 safe_print("[ERROR] --image-scale must be >= 1")
497 sys.exit(1)
498
499 success = finalize_project(args.project_dir, options, args.dry_run, args.quiet,
500 compress=args.compress,
501 max_dimension=args.max_dimension,
502 image_scale=args.image_scale)
503 sys.exit(0 if success else 1)
504
505
506 if __name__ == '__main__':
507 main()
508
508 lines PYTHON