| 1 | #!/usr/bin/env python3 |
| 2 | """Unified PPTX preparation entry point for the /create-template workflow. |
| 3 | |
| 4 | Reads OOXML directly via `pptx_to_svg` and writes a reusable reference workspace: |
| 5 | |
| 6 | - `manifest.json` — single source of truth for slide size, theme colors, fonts, |
| 7 | asset inventory, and per-slide / per-layout / per-master metadata |
| 8 | - `native_structure.json` + `source_template.pptx` — source-structure facts and |
| 9 | a byte-identical analysis copy used to rebuild explicit SVG structure |
| 10 | - `assets/` — extracted reusable image assets |
| 11 | - `conversion-report.json` — source-recovery and fidelity diagnostics emitted |
| 12 | with SVG conversion |
| 13 | - `svg/` — canonical layered template view (every master |
| 14 | and layout in the deck rendered once each as `master_*.svg` / |
| 15 | `layout_*.svg`, slides contain only their own shapes, and an |
| 16 | `inheritance.json` describes the reuse graph) |
| 17 | - `svg-flat/` — optional verification view (`--inheritance-mode both`): each |
| 18 | `slide_NN.svg` is self-contained, so opening one slide shows the full page |
| 19 | like PowerPoint would |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import argparse |
| 25 | import json |
| 26 | import shutil |
| 27 | import tempfile |
| 28 | from pathlib import Path |
| 29 | from xml.etree import ElementTree as ET |
| 30 | from zipfile import BadZipFile |
| 31 | |
| 32 | from console_encoding import configure_utf8_stdio |
| 33 | from template_import.manifest import build_manifest |
| 34 | from template_import.native_structure import ( |
| 35 | CONTRACT_NAME, |
| 36 | SOURCE_TEMPLATE_NAME, |
| 37 | write_native_structure_bundle, |
| 38 | ) |
| 39 | |
| 40 | configure_utf8_stdio() |
| 41 | |
| 42 | _MANIFEST_NAME = "manifest.json" |
| 43 | _CONVERSION_REPORT_NAME = "conversion-report.json" |
| 44 | |
| 45 | |
| 46 | def parse_args() -> argparse.Namespace: |
| 47 | """Build the CLI argument parser for the import entry point.""" |
| 48 | parser = argparse.ArgumentParser( |
| 49 | description="Prepare a PPTX reference workspace for /create-template." |
| 50 | ) |
| 51 | parser.add_argument("pptx_file", help="Path to the source .pptx file") |
| 52 | parser.add_argument( |
| 53 | "-o", |
| 54 | "--output", |
| 55 | help="Output directory (default: <pptx_stem>_template_import beside the source file)", |
| 56 | ) |
| 57 | parser.add_argument( |
| 58 | "--skip-manifest", |
| 59 | action="store_true", |
| 60 | help=( |
| 61 | "Skip metadata, asset inventory, native structure contract, and " |
| 62 | "preserved source-package generation" |
| 63 | ), |
| 64 | ) |
| 65 | parser.add_argument( |
| 66 | "--manifest-only", |
| 67 | action="store_true", |
| 68 | help=( |
| 69 | "Only extract manifest.json + reusable assets + the native " |
| 70 | "structure/source pair, without exporting slides to SVG" |
| 71 | ), |
| 72 | ) |
| 73 | parser.add_argument( |
| 74 | "--embed-images", |
| 75 | action="store_true", |
| 76 | help="Inline images as data: URIs instead of writing files to assets/", |
| 77 | ) |
| 78 | parser.add_argument( |
| 79 | "--inheritance-mode", |
| 80 | choices=("both", "layered", "flat"), |
| 81 | default="layered", |
| 82 | help=( |
| 83 | "How to render master/layout shapes for slide SVGs. " |
| 84 | "'layered' (default): emit the canonical svg/ tree with master, " |
| 85 | "layout, and slide-local files plus svg/inheritance.json. " |
| 86 | "'both': also emit svg-flat/ with self-contained per-slide " |
| 87 | "verification files. In this mode svg/ still holds the layered " |
| 88 | "renderings (template designers see master/layout/slide as " |
| 89 | "separate files). 'flat': emit only self-contained slide SVGs " |
| 90 | "in svg/, the round-trip view used by svg_to_pptx." |
| 91 | ), |
| 92 | ) |
| 93 | return parser.parse_args() |
| 94 | |
| 95 | |
| 96 | def _managed_asset_paths(output_dir: Path) -> set[Path]: |
| 97 | """Read the previous manifest's exact exported-asset roster.""" |
| 98 | manifest_path = output_dir / _MANIFEST_NAME |
| 99 | try: |
| 100 | manifest = json.loads(manifest_path.read_text(encoding="utf-8")) |
| 101 | except (OSError, UnicodeError, json.JSONDecodeError): |
| 102 | return set() |
| 103 | if not isinstance(manifest, dict): |
| 104 | return set() |
| 105 | assets = manifest.get("assets") |
| 106 | if not isinstance(assets, dict): |
| 107 | return set() |
| 108 | export_dir = assets.get("exportDir") |
| 109 | asset_names = assets.get("allAssets") |
| 110 | if export_dir != "assets" or not isinstance(asset_names, list): |
| 111 | return set() |
| 112 | if any( |
| 113 | not isinstance(name, str) |
| 114 | or not name |
| 115 | or name in {".", ".."} |
| 116 | or "/" in name |
| 117 | or "\\" in name |
| 118 | for name in asset_names |
| 119 | ): |
| 120 | return set() |
| 121 | return { |
| 122 | Path("assets") / name |
| 123 | for name in asset_names |
| 124 | } |
| 125 | |
| 126 | |
| 127 | def main() -> int: |
| 128 | """CLI entry point: write the PPTX reference workspace to disk.""" |
| 129 | args = parse_args() |
| 130 | pptx_path = Path(args.pptx_file).expanduser().resolve() |
| 131 | if not pptx_path.exists(): |
| 132 | print(f"Error: file does not exist: {pptx_path}") |
| 133 | return 1 |
| 134 | if pptx_path.suffix.lower() != ".pptx": |
| 135 | print(f"Error: expected a .pptx file, got: {pptx_path.name}") |
| 136 | return 1 |
| 137 | |
| 138 | output_dir = ( |
| 139 | Path(args.output).expanduser().resolve() |
| 140 | if args.output |
| 141 | else pptx_path.with_name(f"{pptx_path.stem}_template_import") |
| 142 | ) |
| 143 | |
| 144 | if args.skip_manifest and args.manifest_only: |
| 145 | print("Error: --skip-manifest and --manifest-only cannot be used together") |
| 146 | return 1 |
| 147 | |
| 148 | previous_assets = _managed_asset_paths(output_dir) |
| 149 | output_dir.parent.mkdir(parents=True, exist_ok=True) |
| 150 | staging_root = Path(tempfile.mkdtemp( |
| 151 | prefix=f".{output_dir.name}.import-", |
| 152 | dir=output_dir.parent, |
| 153 | )) |
| 154 | staged_dir = staging_root / "generated" |
| 155 | staged_dir.mkdir() |
| 156 | |
| 157 | try: |
| 158 | manifest = None |
| 159 | native_structure = None |
| 160 | manifest_path = staged_dir / _MANIFEST_NAME |
| 161 | if not args.skip_manifest: |
| 162 | try: |
| 163 | manifest = build_manifest( |
| 164 | pptx_path, |
| 165 | staged_dir, |
| 166 | include_flat_svg=( |
| 167 | not args.manifest_only and args.inheritance_mode == "both" |
| 168 | ), |
| 169 | ) |
| 170 | except (RuntimeError, OSError, ValueError) as exc: |
| 171 | print(f"Error: failed to extract PPTX metadata: {exc}") |
| 172 | return 1 |
| 173 | |
| 174 | manifest_path.write_text( |
| 175 | json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", |
| 176 | encoding="utf-8", |
| 177 | ) |
| 178 | try: |
| 179 | native_structure = write_native_structure_bundle( |
| 180 | pptx_path, |
| 181 | staged_dir, |
| 182 | manifest, |
| 183 | ) |
| 184 | except (OSError, ValueError) as exc: |
| 185 | print(f"Error: failed to write native structure bundle: {exc}") |
| 186 | return 1 |
| 187 | |
| 188 | result = None |
| 189 | total_bytes = 0 |
| 190 | if not args.manifest_only: |
| 191 | from pptx_to_svg import convert_pptx_to_svg |
| 192 | from pptx_to_svg.converter import ConvertOptions |
| 193 | |
| 194 | options = ConvertOptions( |
| 195 | media_subdir="assets", |
| 196 | embed_images=args.embed_images, |
| 197 | keep_hidden=False, |
| 198 | inheritance_mode=args.inheritance_mode, |
| 199 | asset_name_map=( |
| 200 | manifest.get("assets", {}).get("assetMap", {}) |
| 201 | if manifest else {} |
| 202 | ), |
| 203 | ) |
| 204 | try: |
| 205 | result = convert_pptx_to_svg(pptx_path, staged_dir, options) |
| 206 | except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc: |
| 207 | print(f"Error: failed to convert PPTX template source: {exc}") |
| 208 | return 1 |
| 209 | total_bytes = sum( |
| 210 | len(art.svg.encode("utf-8")) |
| 211 | for art in result.slides |
| 212 | ) |
| 213 | |
| 214 | from pptx_to_svg.converter import publish_staged_workspace |
| 215 | |
| 216 | try: |
| 217 | publish_staged_workspace( |
| 218 | output_dir, |
| 219 | staged_dir, |
| 220 | managed_root_files={ |
| 221 | _MANIFEST_NAME, |
| 222 | CONTRACT_NAME, |
| 223 | SOURCE_TEMPLATE_NAME, |
| 224 | _CONVERSION_REPORT_NAME, |
| 225 | }, |
| 226 | managed_relative_paths=previous_assets, |
| 227 | ) |
| 228 | except (OSError, RuntimeError, ValueError) as exc: |
| 229 | print(f"Error: failed to publish PPTX template workspace: {exc}") |
| 230 | return 1 |
| 231 | |
| 232 | if args.manifest_only: |
| 233 | print(f"Imported PPTX template source: {pptx_path.name}") |
| 234 | print(f"Output directory: {output_dir}") |
| 235 | if manifest is not None: |
| 236 | print(f"Manifest: {manifest_path.name}") |
| 237 | print(f"Native structure: {CONTRACT_NAME}") |
| 238 | print(f"Source package analysis copy: {SOURCE_TEMPLATE_NAME}") |
| 239 | print( |
| 240 | "Source structure assessment: " |
| 241 | f"{native_structure['strategy']['recommendedMode']}" |
| 242 | ) |
| 243 | print("Template output mode: explicit SVG structure") |
| 244 | print(f"Assets exported: {len(manifest['assets']['allAssets'])}") |
| 245 | print(f"Common assets: {len(manifest['assets']['commonAssets'])}") |
| 246 | print(f"Slides analyzed: {len(manifest['slides'])}") |
| 247 | print(f"Layouts (unique): {len(manifest.get('layouts', []))}") |
| 248 | print(f"Masters (unique): {len(manifest.get('masters', []))}") |
| 249 | return 0 |
| 250 | |
| 251 | print(f"Inheritance mode: {args.inheritance_mode}") |
| 252 | print(f"Exported SVG slides: {len(result.slides)}") |
| 253 | if args.inheritance_mode in {"layered", "both"}: |
| 254 | print(f"Exported masters: {len(result.masters)}") |
| 255 | print(f"Exported layouts: {len(result.layouts)}") |
| 256 | print("Inheritance graph: svg/inheritance.json") |
| 257 | if result.flat_slides: |
| 258 | print(f"Flat companion slides: {len(result.flat_slides)} (svg-flat/)") |
| 259 | if result.diagnostics: |
| 260 | print( |
| 261 | f"Source recovery warnings: {len(result.diagnostics)} " |
| 262 | f"({_CONVERSION_REPORT_NAME})" |
| 263 | ) |
| 264 | print(f"SVG bytes (primary): {total_bytes}") |
| 265 | print(f"Output directory: {output_dir}") |
| 266 | if native_structure is not None: |
| 267 | print( |
| 268 | "Source structure assessment: " |
| 269 | f"{native_structure['strategy']['recommendedMode']}; " |
| 270 | "create-template rebuilds explicit SVG structure" |
| 271 | ) |
| 272 | return 0 |
| 273 | finally: |
| 274 | shutil.rmtree(staging_root, ignore_errors=True) |
| 275 | |
| 276 | |
| 277 | if __name__ == "__main__": |
| 278 | raise SystemExit(main()) |
| 279 |