#!/usr/bin/env python3 """ PPT Master - Template Preview PPTX Exporter Export public SVG prototypes as a structured review deck while retaining definition-only Layout prototypes in the native package. Usage: python3 scripts/template_preview_pptx.py [-o output.pptx] Examples: python3 scripts/template_preview_pptx.py projects/my_template python3 scripts/template_preview_pptx.py templates/decks/my_template -o review.pptx python3 scripts/template_preview_pptx.py templates/decks/legacy --visual-only Dependencies: python-pptx """ from __future__ import annotations import argparse import contextlib import math import re import shutil import statistics import sys import tempfile from collections.abc import Iterator from pathlib import Path from xml.etree import ElementTree as ET from attribution_guard import require_skill_integrity from console_encoding import configure_utf8_stdio configure_utf8_stdio() from pptx import Presentation # noqa: E402 from svg_to_pptx.drawingml.theme_fonts import ( # noqa: E402 MasterTextStyleSpec, ) from svg_to_pptx.drawingml.utils import font_px_to_hpt # noqa: E402 from svg_to_pptx.pptx_package.builder import ( # noqa: E402 create_pptx_with_native_svg, ) _FRONTMATTER_ID_RE = re.compile( r"^(?:template_id|deck_id|layout_id)\s*:\s*(.+?)\s*$", re.MULTILINE, ) _REPLICATION_MODE_RE = re.compile( r"^replication_mode\s*:\s*(standard|fidelity|mirror)\s*$", re.MULTILINE, ) _CANVAS_VIEWBOX_RE = re.compile( r"^canvas_viewbox\s*:\s*[\"']?([^\"'\r\n]+?)[\"']?\s*$", re.MULTILINE, ) _FONT_SIZE_RE = re.compile(r"^([0-9]+(?:\.[0-9]+)?)(?:px)?$") _FILENAME_UNSAFE_RE = re.compile(r"[\\/:*?\"<>|\x00-\x1f]+") _PLACEHOLDER_MARKER_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") _TITLE_PLACEHOLDERS = frozenset({"title", "subtitle"}) _BODY_PLACEHOLDERS = frozenset({ "body", "date", "footer", "slide-number", }) _DEFAULT_TITLE_PX = 40.0 _DEFAULT_BODY_PX = 24.0 def _review_marker_text(match: re.Match[str]) -> str: """Return concise preview-only text for one canonical marker.""" token = match.group(1) if token in {"PAGE_NUM", "SLIDE_NUM"}: return "1" if token.endswith("_NUM"): return "01" if token == "DATE": return "YYYY-MM-DD" return token.replace("_", " ").title() def _write_review_svg(source: Path, target: Path) -> bool: """Copy one SVG, shortening only visible placeholder-carrier prompts.""" tree = ET.parse(source) changed = False for slot in tree.getroot().iter(): if not (slot.get("data-pptx-placeholder") or "").strip(): continue for carrier in slot.iter(): if ( carrier.get("data-pptx-carrier") or "" ).strip().lower() != "true": continue for element in carrier.iter(): if element.text: updated = _PLACEHOLDER_MARKER_RE.sub( _review_marker_text, element.text, ) if updated != element.text: element.text = updated changed = True if changed: tree.write(target, encoding="utf-8", xml_declaration=True) else: shutil.copy2(source, target) return changed @contextlib.contextmanager def _review_svg_sources( workspace: Path, svg_files: list[Path], *, shorten_placeholder_markers: bool, ) -> Iterator[list[Path]]: """Yield ephemeral review SVGs without modifying canonical template files.""" if not shorten_placeholder_markers: yield svg_files return with tempfile.TemporaryDirectory( prefix=".template-preview-", dir=workspace, ) as temporary: review_dir = Path(temporary) review_files: list[Path] = [] shortened = 0 for source in svg_files: target = review_dir / source.name shortened += int(_write_review_svg(source, target)) review_files.append(target) print( " Review prompt text: preview-only samples in " f"{shortened} SVG(s); canonical {{{{...}}}} markers unchanged" ) yield review_files def _partition_svg_prototypes( svg_files: list[Path], *, visual_only: bool, ) -> tuple[list[Path], list[Path]]: """Separate public pages from canonical definition-only Layout SVGs.""" if visual_only: return svg_files, [] public_files: list[Path] = [] definition_files: list[Path] = [] for path in svg_files: target = definition_files if path.stem.startswith("layout_") else public_files target.append(path) return public_files, definition_files def _resolve_workspace(path: Path) -> tuple[Path, Path]: """Resolve one workspace root and its canonical template-source directory.""" candidate = path.expanduser().resolve() nested_spec = candidate / "templates" / "design_spec.md" if nested_spec.is_file(): return candidate, candidate / "templates" direct_spec = candidate / "design_spec.md" if direct_spec.is_file(): if candidate.name == "templates" and (candidate.parent / "exports").is_dir(): return candidate.parent, candidate return candidate, candidate raise ValueError( "template workspace must contain templates/design_spec.md " "(current structure) or design_spec.md (legacy flat package)" ) def _template_id(spec_path: Path, workspace: Path) -> str: """Read a portable template id, falling back to the workspace directory name.""" text = spec_path.read_text(encoding="utf-8") match = _FRONTMATTER_ID_RE.search(text) raw = match.group(1).strip().strip("'\"") if match else workspace.name safe = _FILENAME_UNSAFE_RE.sub("_", raw).strip(" ._") return safe or "template" def _replication_mode(spec_path: Path) -> str: """Read the template replication mode, defaulting legacy packages to standard.""" text = spec_path.read_text(encoding="utf-8") match = _REPLICATION_MODE_RE.search(text) return match.group(1) if match else "standard" def _canvas_viewbox(spec_path: Path) -> str | None: """Read the template's locked root canvas when declared.""" text = spec_path.read_text(encoding="utf-8") if not text.startswith("---\n"): return None end = text.find("\n---\n", 4) if end == -1: return None match = _CANVAS_VIEWBOX_RE.search(text[4:end]) return match.group(1).strip() if match else None def _style_property(style: str, name: str) -> str | None: """Return one inline CSS declaration value.""" for declaration in style.split(";"): key, separator, value = declaration.partition(":") if separator and key.strip().lower() == name: return value.strip() return None def _font_size_px(element: ET.Element) -> float | None: """Read one finite positive SVG font size in px.""" raw = element.get("font-size") if raw is None: raw = _style_property(element.get("style", ""), "font-size") if raw is None: return None match = _FONT_SIZE_RE.fullmatch(raw.strip()) if match is None: return None value = float(match.group(1)) return value if math.isfinite(value) and value > 0 else None def _carrier_sizes(svg_files: list[Path]) -> tuple[list[float], list[float]]: """Collect authored title/body sizes from semantic placeholder carriers.""" title_sizes: list[float] = [] body_sizes: list[float] = [] for svg_path in svg_files: root = ET.parse(svg_path).getroot() for slot in root.iter(): placeholder = slot.get("data-pptx-placeholder") if placeholder not in _TITLE_PLACEHOLDERS | _BODY_PLACEHOLDERS: continue for carrier in slot.iter(): if carrier.get("data-pptx-carrier") != "true": continue size = _font_size_px(carrier) if size is None: continue target = title_sizes if placeholder in _TITLE_PLACEHOLDERS else body_sizes target.append(size) return title_sizes, body_sizes def _master_text_style(svg_files: list[Path]) -> tuple[MasterTextStyleSpec, float, float]: """Build review-only Master text defaults without requiring a project lock.""" title_sizes, body_sizes = _carrier_sizes(svg_files) title_px = float(statistics.median(title_sizes)) if title_sizes else _DEFAULT_TITLE_PX body_px = float(statistics.median(body_sizes)) if body_sizes else _DEFAULT_BODY_PX return ( MasterTextStyleSpec( title_hpt=font_px_to_hpt(title_px), body_hpt=font_px_to_hpt(body_px), ), title_px, body_px, ) def _verify_output( output_path: Path, *, require_full_placeholder_frames: bool, ) -> tuple[int, int, int, int]: """Reopen the review deck and verify counts plus authored placeholder frames.""" presentation = Presentation(str(output_path)) master_count = len(presentation.slide_masters) layout_count = sum(len(master.slide_layouts) for master in presentation.slide_masters) placeholder_count = 0 if require_full_placeholder_frames: for slide_number, slide in enumerate(presentation.slides, 1): layout_placeholders = { shape.placeholder_format.idx: shape for shape in slide.slide_layout.placeholders } slide_placeholders = { shape.placeholder_format.idx: shape for shape in slide.placeholders } if set(slide_placeholders) != set(layout_placeholders): raise ValueError( f"review slide {slide_number} placeholder indexes do not match " f"its Layout: {sorted(slide_placeholders)} != " f"{sorted(layout_placeholders)}" ) for placeholder_idx, slide_shape in slide_placeholders.items(): layout_shape = layout_placeholders[placeholder_idx] if ( slide_shape.placeholder_format.type != layout_shape.placeholder_format.type ): raise ValueError( f"review slide {slide_number} placeholder {placeholder_idx} " "type does not match its Layout" ) slide_frame = ( slide_shape.left, slide_shape.top, slide_shape.width, slide_shape.height, ) layout_frame = ( layout_shape.left, layout_shape.top, layout_shape.width, layout_shape.height, ) if slide_frame != layout_frame: raise ValueError( f"review slide {slide_number} placeholder {placeholder_idx} " f"uses a tight/local frame {slide_frame}; expected full " f"Layout frame {layout_frame}" ) placeholder_count += 1 return len(presentation.slides), master_count, layout_count, placeholder_count def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Export a complete template workspace as a structured PPTX review deck." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "template_workspace", help=( "Workspace containing templates/design_spec.md; legacy flat template " "directories are also accepted." ), ) parser.add_argument( "-o", "--output", help=( "Output PPTX path. Default: " "/exports/_template_preview.pptx" ), ) parser.add_argument( "--force", action="store_true", help="Replace an existing review PPTX after an intentional re-export.", ) parser.add_argument( "--visual-only", action="store_true", help=( "Export a legacy SVG roster as slide-local DrawingML for visual review. " "This does not validate or claim a reusable Master/Layout contract." ), ) return parser def main(argv: list[str] | None = None) -> int: require_skill_integrity() parser = build_parser() args = parser.parse_args(argv) try: workspace, template_dir = _resolve_workspace(Path(args.template_workspace)) all_svg_files = sorted(template_dir.glob("*.svg")) if not all_svg_files: raise ValueError(f"template directory has no SVG prototypes: {template_dir}") svg_files, layout_definition_files = _partition_svg_prototypes( all_svg_files, visual_only=args.visual_only, ) if not svg_files: raise ValueError( "template directory contains Layout definitions but no public " f"SVG prototypes: {template_dir}" ) spec_path = template_dir / "design_spec.md" template_id = _template_id(spec_path, workspace) replication_mode = _replication_mode(spec_path) locked_canvas = _canvas_viewbox(spec_path) if locked_canvas is None and not args.visual_only: raise ValueError( "design_spec.md frontmatter must declare canvas_viewbox" ) use_full_placeholder_frames = ( not args.visual_only and replication_mode != "mirror" ) output_path = ( Path(args.output).expanduser().resolve() if args.output else workspace / "exports" / f"{template_id}_template_preview.pptx" ) if output_path.suffix.lower() != ".pptx": raise ValueError(f"output must use a .pptx extension: {output_path}") if output_path.exists() and not args.force: raise ValueError( f"output already exists: {output_path}; use --force to replace it" ) output_path.parent.mkdir(parents=True, exist_ok=True) text_style: MasterTextStyleSpec | None = None if not args.visual_only: text_style, title_px, body_px = _master_text_style(all_svg_files) print("PPT Master - Template Preview PPTX Exporter") print(f" Workspace: {workspace}") print(f" Template source: {template_dir}") print(f" Public SVG prototypes: {len(svg_files)}") if layout_definition_files: print( " Definition-only Layout prototypes: " f"{len(layout_definition_files)}" ) if args.visual_only: print(" Review mode: visual-only legacy compatibility") elif replication_mode == "mirror": print(" Review placeholder frames: preserved source Slide geometry") else: print(f" Review Master defaults: title {title_px:g}px, body {body_px:g}px") print(" Review placeholder frames: full Layout bounds") print(f" Output: {output_path}") with _review_svg_sources( workspace, all_svg_files, shorten_placeholder_markers=use_full_placeholder_frames, ) as review_all_svg_files: review_svg_files, review_layout_definition_files = ( _partition_svg_prototypes( review_all_svg_files, visual_only=args.visual_only, ) ) success = create_pptx_with_native_svg( svg_files=review_svg_files, output_path=output_path, canvas_format=None, expected_viewbox=locked_canvas, verbose=True, transition=None, enable_notes=False, animation=None, image_optimize=False, native_objects=True, pptx_structure="flat" if args.visual_only else "structured", use_layout_placeholder_frames=use_full_placeholder_frames, master_text_style_spec=text_style, structure_name=template_id, layout_definition_files=review_layout_definition_files, ) if not success or not output_path.is_file(): print("Error: template preview export did not produce a PPTX", file=sys.stderr) return 1 slide_count, master_count, layout_count, placeholder_count = _verify_output( output_path, require_full_placeholder_frames=use_full_placeholder_frames, ) if slide_count != len(svg_files): print( "Error: review PPTX slide count does not match the template SVG roster " f"({slide_count} != {len(svg_files)})", file=sys.stderr, ) return 1 label = "Visual-only template preview" if args.visual_only else "Template preview" placeholder_status = ( f", {placeholder_count} full-frame placeholder(s)" if use_full_placeholder_frames else "" ) print( f"[OK] {label} verified: " f"{slide_count} slides, {master_count} master(s), " f"{layout_count} layout(s){placeholder_status}" ) print(output_path) return 0 except (OSError, ET.ParseError, RuntimeError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())