#!/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 docs/technical-design.md "Post-Processing Pipeline" before modifying. 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 sys import shutil import argparse from pathlib import Path 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, ) def safe_print(text: str) -> None: """Print text while tolerating Windows terminal encoding limits.""" try: print(text) 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) def process_flatten_text(svg_file: Path, verbose: bool = False) -> bool: """Flatten text in a single SVG file (in-place modification)""" try: from svg_finalize.flatten_tspan import flatten_text_with_tspans from xml.etree import ElementTree as ET 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 changed except Exception as e: if verbose: safe_print(f" [ERROR] {svg_file.name}: {e}") return False 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 # Step 1: Copy directory if svg_final.exists(): shutil.rmtree(svg_final) shutil.copytree(svg_output, svg_final) if not quiet: print() # Core normalization: downstream image/rect processors read XML geometry. geometry_count = 0 for svg_file in svg_final.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 2: 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 svg_final.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 svg_final.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 svg_final.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 3: 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 svg_final.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 not quiet: if img_count > 0: msg = f" {img_count} image(s) aligned + embedded" if img_errors: msg += f" ({img_errors} error(s))" 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 4: Flatten text if options.get('flatten_text'): if not quiet: safe_print("[3/3] Flattening text...") flatten_count = 0 for svg_file in svg_final.glob('*.svg'): if process_flatten_text(svg_file, verbose=False): flatten_count += 1 if not quiet: if flatten_count > 0: safe_print(f" {flatten_count} file(s) processed") else: safe_print(" No processing needed") # 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()