#!/usr/bin/env python3 """ PPT Master - SVG Post-processing Tool (Unified Entry Point) Processes SVG files from svg_output/ and produces the visual preview in svg_final/, embedding supported raster/SVG assets. Native PPTX export continues to read svg_output/ by default; svg_final/ may be opened directly or inserted as an SVG image. EMF/WMF assets retain their external-reference exception. By default, all processing steps are executed. You can also specify individual steps via arguments. Architecture note: this module's outputs feed svg_final/ on disk AND its sub-modules (svg_finalize.embed_icons, svg_finalize.flatten_tspan, ...) are memory-reused by svg_to_pptx during native conversion. Deleting any step here may also break native pptx output, not just svg_final/. See scripts/docs/svg-pipeline.md before modifying the shared pipeline. Usage: # Execute all processing steps (recommended) python3 scripts/finalize_svg.py # Execute only specific steps python3 scripts/finalize_svg.py --only embed-icons align-images Examples: python3 scripts/finalize_svg.py projects/my_project python3 scripts/finalize_svg.py examples/ppt169_demo --only embed-icons Processing options: embed-icons - Expand project icons and static same-document align-images - Align (slice/meet) and Base64-embed all in one pass. Replaces the former crop-images + fix-aspect + embed-images trio. The old names remain accepted as aliases for the merged step, so existing --only invocations keep working. flatten-text - Convert to independent (for special renderers) """ import argparse import os import shutil import sys import tempfile from enum import Enum from pathlib import Path from typing import TextIO from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio configure_utf8_stdio() # Import finalize helpers from the internal package. sys.path.insert(0, str(Path(__file__).parent)) from resource_paths import icon_search_dirs_for_project # noqa: E402 from svg_finalize.align_embed_images import ( align_and_embed_images_in_svg, count_office_vector_refs_in_svg, ) from svg_finalize.embed_icons import process_svg_file as embed_icons_in_file from svg_to_pptx.geometry_properties import ( GeometryStyleError, materialize_inline_geometry_in_file, ) from svg_to_pptx.use_expander import ( UseExpansionError, expand_local_use_references_in_file, ) class FlattenTextResult(Enum): """Describe whether flattening changed a file, skipped it, or failed.""" CHANGED = "changed" UNCHANGED = "unchanged" ERROR = "error" def safe_print(text: str, *, file: TextIO | None = None) -> None: """Print text while tolerating Windows terminal encoding limits.""" stream = file or sys.stdout try: print(text, file=stream) except UnicodeEncodeError: replacements = { chr(0x23F3): "[..]", chr(0x2705): "[DONE]", chr(0x274C): "[ERROR]", chr(0x26A0) + chr(0xFE0F): "[WARN]", chr(0x1F4C1): "[DIR]", chr(0x1F4C4): "[FILE]", chr(0x1F4E6): "[OK]", } for source, target in replacements.items(): text = text.replace(source, target) print(text, file=stream) def process_flatten_text( svg_file: Path, verbose: bool = False, ) -> FlattenTextResult: """Flatten text in one SVG and report changed, unchanged, or error.""" try: from svg_finalize.flatten_tspan import flatten_text_with_tspans tree = ET.parse(str(svg_file)) changed = flatten_text_with_tspans(tree) if changed: tree.write(str(svg_file), encoding='unicode', xml_declaration=False) if verbose: safe_print(f" [OK] {svg_file.name}: text flattened") return FlattenTextResult.CHANGED return FlattenTextResult.UNCHANGED except Exception as exc: safe_print( f" [ERROR] {svg_file.name}: text flattening failed: {exc}", file=sys.stderr, ) return FlattenTextResult.ERROR def _path_lexists(path: Path) -> bool: """Return whether a path or dangling symlink occupies the target name.""" return os.path.lexists(path) def _publish_candidate_directory(candidate_dir: Path, output_dir: Path) -> None: """Publish one staged directory and restore the previous output on failure.""" if output_dir.is_symlink() or ( _path_lexists(output_dir) and not output_dir.is_dir() ): raise RuntimeError(f"Output path must be a real directory: {output_dir}") transaction_dir = Path( tempfile.mkdtemp( prefix=f".{output_dir.name}.publish-", dir=output_dir.parent, ) ) backup_dir = transaction_dir / "previous" preserve_backup = False try: if output_dir.is_dir(): try: os.replace(output_dir, backup_dir) os.replace(candidate_dir, output_dir) except BaseException as publish_error: try: if _path_lexists(backup_dir): if _path_lexists(output_dir): failed_output = transaction_dir / "failed-publish" os.replace(output_dir, failed_output) os.replace(backup_dir, output_dir) except BaseException as restore_error: if ( not _path_lexists(backup_dir) and _path_lexists(output_dir) ): raise publish_error preserve_backup = _path_lexists(backup_dir) raise RuntimeError( "Failed to publish svg_final and restore the previous " "directory; recovery directory: " f"{transaction_dir}" ) from restore_error raise else: os.replace(candidate_dir, output_dir) finally: if not preserve_backup: shutil.rmtree(transaction_dir, ignore_errors=True) def _process_candidate_directory( candidate_dir: Path, *, options: dict[str, bool], quiet: bool, compress: bool, max_dimension: int | None, image_scale: float, icons_dir: Path, icons_fallback_dir: Path | None, ) -> bool: """Run every selected finalization pass against one unpublished candidate.""" # Core normalization: downstream image/rect processors read XML geometry. geometry_count = 0 for svg_file in candidate_dir.glob('*.svg'): try: geometry_count += materialize_inline_geometry_in_file(svg_file) except (OSError, ET.ParseError, GeometryStyleError) as exc: safe_print( f"[ERROR] {svg_file.name}: inline geometry materialization failed: {exc}" ) return False # Step 1: Expand project icons, then standard same-document use references. if options.get('embed_icons'): if not quiet: safe_print("[1/3] Expanding icons + local use references...") icons_count = 0 for svg_file in candidate_dir.glob('*.svg'): count = embed_icons_in_file( svg_file, icons_dir, dry_run=False, verbose=False, fallback_dir=icons_fallback_dir, ) icons_count += count for svg_file in candidate_dir.glob('*.svg'): try: geometry_count += materialize_inline_geometry_in_file(svg_file) except (OSError, ET.ParseError, GeometryStyleError) as exc: safe_print( f"[ERROR] {svg_file.name}: expanded icon geometry " f"materialization failed: {exc}" ) return False local_use_count = 0 for svg_file in candidate_dir.glob('*.svg'): try: local_use_count += expand_local_use_references_in_file(svg_file) except (OSError, ET.ParseError, UseExpansionError) as exc: safe_print( f"[ERROR] {svg_file.name}: local expansion failed: {exc}" ) return False if not quiet: if icons_count > 0: safe_print(f" {icons_count} icon(s) embedded") else: safe_print(" No icons") if local_use_count > 0: safe_print(f" {local_use_count} local use reference(s) expanded") else: safe_print(" No local use references") if not quiet and geometry_count: safe_print( f"[PREP] {geometry_count} inline geometry declaration(s) materialized" ) # Step 2: Align (slice/meet) and Base64-embed all in one pass. # Replaces the former crop-images / fix-aspect / embed-images trio: the # spatial transform (slice → crop, meet → fit-box) and the asset embed # are mutually exclusive branches per image, sequenced together so each # SVG is only parsed and serialized once and each bitmap is only read # from disk once. if options.get('align_images'): if not quiet: safe_print("[2/3] Aligning + embedding images...") img_count = 0 img_errors = 0 office_vector_count = 0 for svg_file in candidate_dir.glob('*.svg'): office_vector_count += count_office_vector_refs_in_svg(svg_file) count, errs = align_and_embed_images_in_svg( svg_file, dry_run=False, verbose=False, compress=compress, max_dimension=max_dimension, image_scale=image_scale, ) img_count += count img_errors += errs if img_errors: safe_print( f"[ERROR] Image alignment/embedding failed for " f"{img_errors} image(s); svg_final was not published", file=sys.stderr, ) return False if not quiet: if img_count > 0: msg = f" {img_count} image(s) aligned + embedded" safe_print(msg) if office_vector_count: safe_print( f" {office_vector_count} Office vector(s) left external " "for native PPTX passthrough" ) elif office_vector_count: safe_print( f" {office_vector_count} Office vector(s) left external " "for native PPTX passthrough" ) else: safe_print(" No images") # Step 3: Flatten text. if options.get('flatten_text'): if not quiet: safe_print("[3/3] Flattening text...") flatten_count = 0 flatten_errors = 0 for svg_file in candidate_dir.glob('*.svg'): result = process_flatten_text(svg_file, verbose=False) if result is FlattenTextResult.CHANGED: flatten_count += 1 elif result is FlattenTextResult.ERROR: flatten_errors += 1 if flatten_errors: safe_print( f"[ERROR] Text flattening failed for {flatten_errors} file(s); " "svg_final was not published", file=sys.stderr, ) return False if not quiet: if flatten_count > 0: safe_print(f" {flatten_count} file(s) processed") else: safe_print(" No processing needed") return True def finalize_project( project_dir: Path, options: dict[str, bool], dry_run: bool = False, quiet: bool = False, compress: bool = True, max_dimension: int | None = 2560, image_scale: float = 2.0, ) -> bool: """ Finalize SVG files in the project Args: project_dir: Project directory path options: Processing options dictionary dry_run: Preview only, do not execute quiet: Quiet mode, reduce output compress: Compress images before embedding max_dimension: Downscale images exceeding this dimension image_scale: Target image pixels per SVG display pixel """ svg_output = project_dir / 'svg_output' svg_final = project_dir / 'svg_final' icons_dir, icons_fallback_dir = icon_search_dirs_for_project(project_dir) # Check if svg_output exists if not svg_output.exists(): safe_print(f"[ERROR] svg_output directory not found: {svg_output}") return False # Get list of SVG files svg_files = list(svg_output.glob('*.svg')) if not svg_files: safe_print(f"[ERROR] No SVG files in svg_output") return False if not quiet: print() safe_print(f"[DIR] Project: {project_dir.name}") safe_print(f"[FILE] {len(svg_files)} SVG file(s)") if dry_run: safe_print("[PREVIEW] Preview mode, no operations will be performed") return True candidate_dir = Path( tempfile.mkdtemp( prefix=f".{svg_final.name}.candidate-", dir=svg_final.parent, ) ) try: try: shutil.copytree(svg_output, candidate_dir, dirs_exist_ok=True) candidate_ready = _process_candidate_directory( candidate_dir, options=options, quiet=quiet, compress=compress, max_dimension=max_dimension, image_scale=image_scale, icons_dir=icons_dir, icons_fallback_dir=icons_fallback_dir, ) except Exception as exc: safe_print( f"[ERROR] SVG finalization failed before publish: {exc}", file=sys.stderr, ) return False if not candidate_ready: return False try: _publish_candidate_directory(candidate_dir, svg_final) except (OSError, RuntimeError) as exc: safe_print( f"[ERROR] svg_final publish failed: {exc}", file=sys.stderr, ) return False finally: shutil.rmtree(candidate_dir, ignore_errors=True) # Done if not quiet: print() safe_print("[OK] Done!") print() print("Next steps:") print(f" python scripts/svg_to_pptx.py \"{project_dir}\"") return True def main() -> None: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description='PPT Master - SVG Post-processing Tool', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: %(prog)s projects/my_project # Execute all processing (default) %(prog)s projects/my_project --only embed-icons align-images %(prog)s projects/my_project -q # Quiet mode Processing options (for --only): embed-icons Expand project icons and static same-document align-images Align (slice/meet) + Base64-embed all (single pass) flatten-text Flatten text Aliases (still accepted): crop-images, fix-aspect, embed-images → all map to align-images ''' ) parser.add_argument('project_dir', type=Path, help='Project directory path') parser.add_argument( '--only', nargs='+', metavar='OPTION', choices=[ 'embed-icons', 'align-images', # Backwards-compatible aliases — all three map to align-images now. 'crop-images', 'fix-aspect', 'embed-images', 'flatten-text', ], help=('Execute only specified processing steps (default: all). ' 'crop-images / fix-aspect / embed-images are accepted as ' 'aliases for the merged align-images step.'), ) parser.add_argument('--dry-run', '-n', action='store_true', help='Preview only, do not execute') parser.add_argument('--quiet', '-q', action='store_true', help='Quiet mode, reduce output') parser.add_argument('--compress', dest='compress', action='store_true', default=True, help='Compress images before embedding (default)') parser.add_argument('--no-compress', dest='compress', action='store_false', help='Disable image compression before embedding') parser.add_argument('--max-dimension', type=int, default=2560, help='Downscale images exceeding this dimension on either axis (default: 2560)') parser.add_argument('--image-scale', type=float, default=2.0, help='Target image pixels per SVG display pixel (default: 2.0)') args = parser.parse_args() if not args.project_dir.exists(): safe_print(f"[ERROR] Project directory does not exist: {args.project_dir}") sys.exit(1) # Aliases: any of crop-images / fix-aspect / embed-images implies the # merged align-images step. Older invocations stay valid. _ALIGN_ALIASES = {'align-images', 'crop-images', 'fix-aspect', 'embed-images'} # Determine processing options if args.only: only = set(args.only) options = { 'embed_icons': 'embed-icons' in only, 'align_images': bool(only & _ALIGN_ALIASES), 'flatten_text': 'flatten-text' in only, } else: # Execute all by default options = { 'embed_icons': True, 'align_images': True, 'flatten_text': True, } if args.max_dimension < 1: safe_print("[ERROR] --max-dimension must be >= 1") sys.exit(1) if args.image_scale < 1: safe_print("[ERROR] --image-scale must be >= 1") sys.exit(1) success = finalize_project(args.project_dir, options, args.dry_run, args.quiet, compress=args.compress, max_dimension=args.max_dimension, image_scale=args.image_scale) sys.exit(0 if success else 1) if __name__ == '__main__': main()