| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Template Preview PPTX Exporter |
| 4 | |
| 5 | Export public SVG prototypes as a structured review deck while retaining |
| 6 | definition-only Layout prototypes in the native package. |
| 7 | |
| 8 | Usage: |
| 9 | python3 scripts/template_preview_pptx.py <template_workspace> [-o output.pptx] |
| 10 | |
| 11 | Examples: |
| 12 | python3 scripts/template_preview_pptx.py projects/my_template |
| 13 | python3 scripts/template_preview_pptx.py templates/decks/my_template -o review.pptx |
| 14 | python3 scripts/template_preview_pptx.py templates/decks/legacy --visual-only |
| 15 | |
| 16 | Dependencies: |
| 17 | python-pptx |
| 18 | """ |
| 19 | |
| 20 | from __future__ import annotations |
| 21 | |
| 22 | import argparse |
| 23 | import contextlib |
| 24 | import math |
| 25 | import re |
| 26 | import shutil |
| 27 | import statistics |
| 28 | import sys |
| 29 | import tempfile |
| 30 | from collections.abc import Iterator |
| 31 | from pathlib import Path |
| 32 | from xml.etree import ElementTree as ET |
| 33 | |
| 34 | from attribution_guard import require_skill_integrity |
| 35 | from console_encoding import configure_utf8_stdio |
| 36 | |
| 37 | |
| 38 | configure_utf8_stdio() |
| 39 | |
| 40 | from pptx import Presentation # noqa: E402 |
| 41 | |
| 42 | from svg_to_pptx.drawingml.theme_fonts import ( # noqa: E402 |
| 43 | MasterTextStyleSpec, |
| 44 | ) |
| 45 | from svg_to_pptx.drawingml.utils import font_px_to_hpt # noqa: E402 |
| 46 | from svg_to_pptx.pptx_package.builder import ( # noqa: E402 |
| 47 | create_pptx_with_native_svg, |
| 48 | ) |
| 49 | |
| 50 | |
| 51 | _FRONTMATTER_ID_RE = re.compile( |
| 52 | r"^(?:template_id|deck_id|layout_id)\s*:\s*(.+?)\s*$", |
| 53 | re.MULTILINE, |
| 54 | ) |
| 55 | _REPLICATION_MODE_RE = re.compile( |
| 56 | r"^replication_mode\s*:\s*(standard|fidelity|mirror)\s*$", |
| 57 | re.MULTILINE, |
| 58 | ) |
| 59 | _CANVAS_VIEWBOX_RE = re.compile( |
| 60 | r"^canvas_viewbox\s*:\s*[\"']?([^\"'\r\n]+?)[\"']?\s*$", |
| 61 | re.MULTILINE, |
| 62 | ) |
| 63 | _FONT_SIZE_RE = re.compile(r"^([0-9]+(?:\.[0-9]+)?)(?:px)?$") |
| 64 | _FILENAME_UNSAFE_RE = re.compile(r"[\\/:*?\"<>|\x00-\x1f]+") |
| 65 | _PLACEHOLDER_MARKER_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") |
| 66 | _TITLE_PLACEHOLDERS = frozenset({"title", "subtitle"}) |
| 67 | _BODY_PLACEHOLDERS = frozenset({ |
| 68 | "body", |
| 69 | "date", |
| 70 | "footer", |
| 71 | "slide-number", |
| 72 | }) |
| 73 | _DEFAULT_TITLE_PX = 40.0 |
| 74 | _DEFAULT_BODY_PX = 24.0 |
| 75 | |
| 76 | |
| 77 | def _review_marker_text(match: re.Match[str]) -> str: |
| 78 | """Return concise preview-only text for one canonical marker.""" |
| 79 | token = match.group(1) |
| 80 | if token in {"PAGE_NUM", "SLIDE_NUM"}: |
| 81 | return "1" |
| 82 | if token.endswith("_NUM"): |
| 83 | return "01" |
| 84 | if token == "DATE": |
| 85 | return "YYYY-MM-DD" |
| 86 | return token.replace("_", " ").title() |
| 87 | |
| 88 | |
| 89 | def _write_review_svg(source: Path, target: Path) -> bool: |
| 90 | """Copy one SVG, shortening only visible placeholder-carrier prompts.""" |
| 91 | tree = ET.parse(source) |
| 92 | changed = False |
| 93 | for slot in tree.getroot().iter(): |
| 94 | if not (slot.get("data-pptx-placeholder") or "").strip(): |
| 95 | continue |
| 96 | for carrier in slot.iter(): |
| 97 | if ( |
| 98 | carrier.get("data-pptx-carrier") or "" |
| 99 | ).strip().lower() != "true": |
| 100 | continue |
| 101 | for element in carrier.iter(): |
| 102 | if element.text: |
| 103 | updated = _PLACEHOLDER_MARKER_RE.sub( |
| 104 | _review_marker_text, |
| 105 | element.text, |
| 106 | ) |
| 107 | if updated != element.text: |
| 108 | element.text = updated |
| 109 | changed = True |
| 110 | if changed: |
| 111 | tree.write(target, encoding="utf-8", xml_declaration=True) |
| 112 | else: |
| 113 | shutil.copy2(source, target) |
| 114 | return changed |
| 115 | |
| 116 | |
| 117 | @contextlib.contextmanager |
| 118 | def _review_svg_sources( |
| 119 | workspace: Path, |
| 120 | svg_files: list[Path], |
| 121 | *, |
| 122 | shorten_placeholder_markers: bool, |
| 123 | ) -> Iterator[list[Path]]: |
| 124 | """Yield ephemeral review SVGs without modifying canonical template files.""" |
| 125 | if not shorten_placeholder_markers: |
| 126 | yield svg_files |
| 127 | return |
| 128 | |
| 129 | with tempfile.TemporaryDirectory( |
| 130 | prefix=".template-preview-", |
| 131 | dir=workspace, |
| 132 | ) as temporary: |
| 133 | review_dir = Path(temporary) |
| 134 | review_files: list[Path] = [] |
| 135 | shortened = 0 |
| 136 | for source in svg_files: |
| 137 | target = review_dir / source.name |
| 138 | shortened += int(_write_review_svg(source, target)) |
| 139 | review_files.append(target) |
| 140 | print( |
| 141 | " Review prompt text: preview-only samples in " |
| 142 | f"{shortened} SVG(s); canonical {{{{...}}}} markers unchanged" |
| 143 | ) |
| 144 | yield review_files |
| 145 | |
| 146 | |
| 147 | def _partition_svg_prototypes( |
| 148 | svg_files: list[Path], |
| 149 | *, |
| 150 | visual_only: bool, |
| 151 | ) -> tuple[list[Path], list[Path]]: |
| 152 | """Separate public pages from canonical definition-only Layout SVGs.""" |
| 153 | if visual_only: |
| 154 | return svg_files, [] |
| 155 | public_files: list[Path] = [] |
| 156 | definition_files: list[Path] = [] |
| 157 | for path in svg_files: |
| 158 | target = definition_files if path.stem.startswith("layout_") else public_files |
| 159 | target.append(path) |
| 160 | return public_files, definition_files |
| 161 | |
| 162 | |
| 163 | def _resolve_workspace(path: Path) -> tuple[Path, Path]: |
| 164 | """Resolve one workspace root and its canonical template-source directory.""" |
| 165 | candidate = path.expanduser().resolve() |
| 166 | nested_spec = candidate / "templates" / "design_spec.md" |
| 167 | if nested_spec.is_file(): |
| 168 | return candidate, candidate / "templates" |
| 169 | |
| 170 | direct_spec = candidate / "design_spec.md" |
| 171 | if direct_spec.is_file(): |
| 172 | if candidate.name == "templates" and (candidate.parent / "exports").is_dir(): |
| 173 | return candidate.parent, candidate |
| 174 | return candidate, candidate |
| 175 | |
| 176 | raise ValueError( |
| 177 | "template workspace must contain templates/design_spec.md " |
| 178 | "(current structure) or design_spec.md (legacy flat package)" |
| 179 | ) |
| 180 | |
| 181 | |
| 182 | def _template_id(spec_path: Path, workspace: Path) -> str: |
| 183 | """Read a portable template id, falling back to the workspace directory name.""" |
| 184 | text = spec_path.read_text(encoding="utf-8") |
| 185 | match = _FRONTMATTER_ID_RE.search(text) |
| 186 | raw = match.group(1).strip().strip("'\"") if match else workspace.name |
| 187 | safe = _FILENAME_UNSAFE_RE.sub("_", raw).strip(" ._") |
| 188 | return safe or "template" |
| 189 | |
| 190 | |
| 191 | def _replication_mode(spec_path: Path) -> str: |
| 192 | """Read the template replication mode, defaulting legacy packages to standard.""" |
| 193 | text = spec_path.read_text(encoding="utf-8") |
| 194 | match = _REPLICATION_MODE_RE.search(text) |
| 195 | return match.group(1) if match else "standard" |
| 196 | |
| 197 | |
| 198 | def _canvas_viewbox(spec_path: Path) -> str | None: |
| 199 | """Read the template's locked root canvas when declared.""" |
| 200 | text = spec_path.read_text(encoding="utf-8") |
| 201 | if not text.startswith("---\n"): |
| 202 | return None |
| 203 | end = text.find("\n---\n", 4) |
| 204 | if end == -1: |
| 205 | return None |
| 206 | match = _CANVAS_VIEWBOX_RE.search(text[4:end]) |
| 207 | return match.group(1).strip() if match else None |
| 208 | |
| 209 | |
| 210 | def _style_property(style: str, name: str) -> str | None: |
| 211 | """Return one inline CSS declaration value.""" |
| 212 | for declaration in style.split(";"): |
| 213 | key, separator, value = declaration.partition(":") |
| 214 | if separator and key.strip().lower() == name: |
| 215 | return value.strip() |
| 216 | return None |
| 217 | |
| 218 | |
| 219 | def _font_size_px(element: ET.Element) -> float | None: |
| 220 | """Read one finite positive SVG font size in px.""" |
| 221 | raw = element.get("font-size") |
| 222 | if raw is None: |
| 223 | raw = _style_property(element.get("style", ""), "font-size") |
| 224 | if raw is None: |
| 225 | return None |
| 226 | match = _FONT_SIZE_RE.fullmatch(raw.strip()) |
| 227 | if match is None: |
| 228 | return None |
| 229 | value = float(match.group(1)) |
| 230 | return value if math.isfinite(value) and value > 0 else None |
| 231 | |
| 232 | |
| 233 | def _carrier_sizes(svg_files: list[Path]) -> tuple[list[float], list[float]]: |
| 234 | """Collect authored title/body sizes from semantic placeholder carriers.""" |
| 235 | title_sizes: list[float] = [] |
| 236 | body_sizes: list[float] = [] |
| 237 | for svg_path in svg_files: |
| 238 | root = ET.parse(svg_path).getroot() |
| 239 | for slot in root.iter(): |
| 240 | placeholder = slot.get("data-pptx-placeholder") |
| 241 | if placeholder not in _TITLE_PLACEHOLDERS | _BODY_PLACEHOLDERS: |
| 242 | continue |
| 243 | for carrier in slot.iter(): |
| 244 | if carrier.get("data-pptx-carrier") != "true": |
| 245 | continue |
| 246 | size = _font_size_px(carrier) |
| 247 | if size is None: |
| 248 | continue |
| 249 | target = title_sizes if placeholder in _TITLE_PLACEHOLDERS else body_sizes |
| 250 | target.append(size) |
| 251 | return title_sizes, body_sizes |
| 252 | |
| 253 | |
| 254 | def _master_text_style(svg_files: list[Path]) -> tuple[MasterTextStyleSpec, float, float]: |
| 255 | """Build review-only Master text defaults without requiring a project lock.""" |
| 256 | title_sizes, body_sizes = _carrier_sizes(svg_files) |
| 257 | title_px = float(statistics.median(title_sizes)) if title_sizes else _DEFAULT_TITLE_PX |
| 258 | body_px = float(statistics.median(body_sizes)) if body_sizes else _DEFAULT_BODY_PX |
| 259 | return ( |
| 260 | MasterTextStyleSpec( |
| 261 | title_hpt=font_px_to_hpt(title_px), |
| 262 | body_hpt=font_px_to_hpt(body_px), |
| 263 | ), |
| 264 | title_px, |
| 265 | body_px, |
| 266 | ) |
| 267 | |
| 268 | |
| 269 | def _verify_output( |
| 270 | output_path: Path, |
| 271 | *, |
| 272 | require_full_placeholder_frames: bool, |
| 273 | ) -> tuple[int, int, int, int]: |
| 274 | """Reopen the review deck and verify counts plus authored placeholder frames.""" |
| 275 | presentation = Presentation(str(output_path)) |
| 276 | master_count = len(presentation.slide_masters) |
| 277 | layout_count = sum(len(master.slide_layouts) for master in presentation.slide_masters) |
| 278 | placeholder_count = 0 |
| 279 | if require_full_placeholder_frames: |
| 280 | for slide_number, slide in enumerate(presentation.slides, 1): |
| 281 | layout_placeholders = { |
| 282 | shape.placeholder_format.idx: shape |
| 283 | for shape in slide.slide_layout.placeholders |
| 284 | } |
| 285 | slide_placeholders = { |
| 286 | shape.placeholder_format.idx: shape |
| 287 | for shape in slide.placeholders |
| 288 | } |
| 289 | if set(slide_placeholders) != set(layout_placeholders): |
| 290 | raise ValueError( |
| 291 | f"review slide {slide_number} placeholder indexes do not match " |
| 292 | f"its Layout: {sorted(slide_placeholders)} != " |
| 293 | f"{sorted(layout_placeholders)}" |
| 294 | ) |
| 295 | for placeholder_idx, slide_shape in slide_placeholders.items(): |
| 296 | layout_shape = layout_placeholders[placeholder_idx] |
| 297 | if ( |
| 298 | slide_shape.placeholder_format.type |
| 299 | != layout_shape.placeholder_format.type |
| 300 | ): |
| 301 | raise ValueError( |
| 302 | f"review slide {slide_number} placeholder {placeholder_idx} " |
| 303 | "type does not match its Layout" |
| 304 | ) |
| 305 | slide_frame = ( |
| 306 | slide_shape.left, |
| 307 | slide_shape.top, |
| 308 | slide_shape.width, |
| 309 | slide_shape.height, |
| 310 | ) |
| 311 | layout_frame = ( |
| 312 | layout_shape.left, |
| 313 | layout_shape.top, |
| 314 | layout_shape.width, |
| 315 | layout_shape.height, |
| 316 | ) |
| 317 | if slide_frame != layout_frame: |
| 318 | raise ValueError( |
| 319 | f"review slide {slide_number} placeholder {placeholder_idx} " |
| 320 | f"uses a tight/local frame {slide_frame}; expected full " |
| 321 | f"Layout frame {layout_frame}" |
| 322 | ) |
| 323 | placeholder_count += 1 |
| 324 | return len(presentation.slides), master_count, layout_count, placeholder_count |
| 325 | |
| 326 | |
| 327 | def build_parser() -> argparse.ArgumentParser: |
| 328 | parser = argparse.ArgumentParser( |
| 329 | description=( |
| 330 | "Export a complete template workspace as a structured PPTX review deck." |
| 331 | ), |
| 332 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 333 | ) |
| 334 | parser.add_argument( |
| 335 | "template_workspace", |
| 336 | help=( |
| 337 | "Workspace containing templates/design_spec.md; legacy flat template " |
| 338 | "directories are also accepted." |
| 339 | ), |
| 340 | ) |
| 341 | parser.add_argument( |
| 342 | "-o", |
| 343 | "--output", |
| 344 | help=( |
| 345 | "Output PPTX path. Default: " |
| 346 | "<template_workspace>/exports/<template_id>_template_preview.pptx" |
| 347 | ), |
| 348 | ) |
| 349 | parser.add_argument( |
| 350 | "--force", |
| 351 | action="store_true", |
| 352 | help="Replace an existing review PPTX after an intentional re-export.", |
| 353 | ) |
| 354 | parser.add_argument( |
| 355 | "--visual-only", |
| 356 | action="store_true", |
| 357 | help=( |
| 358 | "Export a legacy SVG roster as slide-local DrawingML for visual review. " |
| 359 | "This does not validate or claim a reusable Master/Layout contract." |
| 360 | ), |
| 361 | ) |
| 362 | return parser |
| 363 | |
| 364 | |
| 365 | def main(argv: list[str] | None = None) -> int: |
| 366 | require_skill_integrity() |
| 367 | parser = build_parser() |
| 368 | args = parser.parse_args(argv) |
| 369 | |
| 370 | try: |
| 371 | workspace, template_dir = _resolve_workspace(Path(args.template_workspace)) |
| 372 | all_svg_files = sorted(template_dir.glob("*.svg")) |
| 373 | if not all_svg_files: |
| 374 | raise ValueError(f"template directory has no SVG prototypes: {template_dir}") |
| 375 | svg_files, layout_definition_files = _partition_svg_prototypes( |
| 376 | all_svg_files, |
| 377 | visual_only=args.visual_only, |
| 378 | ) |
| 379 | if not svg_files: |
| 380 | raise ValueError( |
| 381 | "template directory contains Layout definitions but no public " |
| 382 | f"SVG prototypes: {template_dir}" |
| 383 | ) |
| 384 | |
| 385 | spec_path = template_dir / "design_spec.md" |
| 386 | template_id = _template_id(spec_path, workspace) |
| 387 | replication_mode = _replication_mode(spec_path) |
| 388 | locked_canvas = _canvas_viewbox(spec_path) |
| 389 | if locked_canvas is None and not args.visual_only: |
| 390 | raise ValueError( |
| 391 | "design_spec.md frontmatter must declare canvas_viewbox" |
| 392 | ) |
| 393 | use_full_placeholder_frames = ( |
| 394 | not args.visual_only and replication_mode != "mirror" |
| 395 | ) |
| 396 | output_path = ( |
| 397 | Path(args.output).expanduser().resolve() |
| 398 | if args.output |
| 399 | else workspace / "exports" / f"{template_id}_template_preview.pptx" |
| 400 | ) |
| 401 | if output_path.suffix.lower() != ".pptx": |
| 402 | raise ValueError(f"output must use a .pptx extension: {output_path}") |
| 403 | if output_path.exists() and not args.force: |
| 404 | raise ValueError( |
| 405 | f"output already exists: {output_path}; use --force to replace it" |
| 406 | ) |
| 407 | output_path.parent.mkdir(parents=True, exist_ok=True) |
| 408 | text_style: MasterTextStyleSpec | None = None |
| 409 | if not args.visual_only: |
| 410 | text_style, title_px, body_px = _master_text_style(all_svg_files) |
| 411 | |
| 412 | print("PPT Master - Template Preview PPTX Exporter") |
| 413 | print(f" Workspace: {workspace}") |
| 414 | print(f" Template source: {template_dir}") |
| 415 | print(f" Public SVG prototypes: {len(svg_files)}") |
| 416 | if layout_definition_files: |
| 417 | print( |
| 418 | " Definition-only Layout prototypes: " |
| 419 | f"{len(layout_definition_files)}" |
| 420 | ) |
| 421 | if args.visual_only: |
| 422 | print(" Review mode: visual-only legacy compatibility") |
| 423 | elif replication_mode == "mirror": |
| 424 | print(" Review placeholder frames: preserved source Slide geometry") |
| 425 | else: |
| 426 | print(f" Review Master defaults: title {title_px:g}px, body {body_px:g}px") |
| 427 | print(" Review placeholder frames: full Layout bounds") |
| 428 | print(f" Output: {output_path}") |
| 429 | |
| 430 | with _review_svg_sources( |
| 431 | workspace, |
| 432 | all_svg_files, |
| 433 | shorten_placeholder_markers=use_full_placeholder_frames, |
| 434 | ) as review_all_svg_files: |
| 435 | review_svg_files, review_layout_definition_files = ( |
| 436 | _partition_svg_prototypes( |
| 437 | review_all_svg_files, |
| 438 | visual_only=args.visual_only, |
| 439 | ) |
| 440 | ) |
| 441 | success = create_pptx_with_native_svg( |
| 442 | svg_files=review_svg_files, |
| 443 | output_path=output_path, |
| 444 | canvas_format=None, |
| 445 | expected_viewbox=locked_canvas, |
| 446 | verbose=True, |
| 447 | transition=None, |
| 448 | enable_notes=False, |
| 449 | animation=None, |
| 450 | image_optimize=False, |
| 451 | native_objects=True, |
| 452 | pptx_structure="flat" if args.visual_only else "structured", |
| 453 | use_layout_placeholder_frames=use_full_placeholder_frames, |
| 454 | master_text_style_spec=text_style, |
| 455 | structure_name=template_id, |
| 456 | layout_definition_files=review_layout_definition_files, |
| 457 | ) |
| 458 | if not success or not output_path.is_file(): |
| 459 | print("Error: template preview export did not produce a PPTX", file=sys.stderr) |
| 460 | return 1 |
| 461 | |
| 462 | slide_count, master_count, layout_count, placeholder_count = _verify_output( |
| 463 | output_path, |
| 464 | require_full_placeholder_frames=use_full_placeholder_frames, |
| 465 | ) |
| 466 | if slide_count != len(svg_files): |
| 467 | print( |
| 468 | "Error: review PPTX slide count does not match the template SVG roster " |
| 469 | f"({slide_count} != {len(svg_files)})", |
| 470 | file=sys.stderr, |
| 471 | ) |
| 472 | return 1 |
| 473 | |
| 474 | label = "Visual-only template preview" if args.visual_only else "Template preview" |
| 475 | placeholder_status = ( |
| 476 | f", {placeholder_count} full-frame placeholder(s)" |
| 477 | if use_full_placeholder_frames |
| 478 | else "" |
| 479 | ) |
| 480 | print( |
| 481 | f"[OK] {label} verified: " |
| 482 | f"{slide_count} slides, {master_count} master(s), " |
| 483 | f"{layout_count} layout(s){placeholder_status}" |
| 484 | ) |
| 485 | print(output_path) |
| 486 | return 0 |
| 487 | except (OSError, ET.ParseError, RuntimeError, ValueError) as exc: |
| 488 | print(f"Error: {exc}", file=sys.stderr) |
| 489 | return 1 |
| 490 | |
| 491 | |
| 492 | if __name__ == "__main__": |
| 493 | raise SystemExit(main()) |
| 494 |