Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
#!/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 <template_workspace> [-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: "
|
||||
"<template_workspace>/exports/<template_id>_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())
|
||||
Reference in New Issue
Block a user