返回 ppt-master
template_preview_pptx.py
根目录 / skills / ppt-master / scripts / template_preview_pptx.py
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 _TEMPLATE_SPEC_NAME_RE = re.compile(
164 r"design_spec\.(?P<kind>brand|style|layout|deck)\.[^/\\]+\.md"
165 )
166
167
168 def _roster_spec(directory: Path) -> Path | None:
169 """Return the effective spec that owns this directory's SVG roster.
170
171 A library workspace keeps the exact ``design_spec.md``. A project workspace
172 shares one ``templates/`` across kinds. Layout owns structure when both
173 Layout and Deck are present; otherwise Deck owns it.
174 """
175 if not directory.is_dir():
176 return None
177 exact = directory / "design_spec.md"
178 qualified = []
179 for item in sorted(directory.glob("design_spec.*.md")):
180 match = _TEMPLATE_SPEC_NAME_RE.fullmatch(item.name)
181 if match is not None:
182 qualified.append((item, match.group("kind")))
183 if exact.is_file() and qualified:
184 raise ValueError(
185 "design_spec.md and design_spec.<kind>.<id>.md cannot share "
186 f"{directory}; rename the bare spec to its kind-qualified name"
187 )
188 kinds = [kind for _item, kind in qualified]
189 duplicate_kinds = sorted({
190 kind for kind in kinds if kinds.count(kind) > 1
191 })
192 if duplicate_kinds:
193 raise ValueError(
194 f"{directory} declares the same kind more than once: "
195 + ", ".join(duplicate_kinds)
196 )
197 try:
198 from register_template import (
199 SpecParseError,
200 validate_qualified_spec_identity,
201 )
202 for item, _kind in qualified:
203 validate_qualified_spec_identity(item)
204 except ImportError as exc:
205 raise ValueError(
206 f"Qualified Design Spec validator could not be imported: {exc}"
207 ) from exc
208 except (OSError, SpecParseError) as exc:
209 raise ValueError(str(exc)) from exc
210 if exact.is_file():
211 return exact
212 for preferred_kind in ("layout", "deck"):
213 for item, kind in qualified:
214 if kind == preferred_kind:
215 return item
216 return None
217
218
219 def _resolve_workspace(path: Path) -> tuple[Path, Path]:
220 """Resolve one workspace root and its canonical template-source directory."""
221 candidate = path.expanduser().resolve()
222 if _roster_spec(candidate / "templates") is not None:
223 return candidate, candidate / "templates"
224
225 if _roster_spec(candidate) is not None:
226 if candidate.name == "templates" and (candidate.parent / "exports").is_dir():
227 return candidate.parent, candidate
228 return candidate, candidate
229
230 raise ValueError(
231 "template workspace must contain templates/design_spec.md, "
232 "templates/design_spec.<layout|deck>.<id>.md, or a legacy flat "
233 "design_spec.md"
234 )
235
236
237 def _template_id(spec_path: Path, workspace: Path) -> str:
238 """Read a portable template id, falling back to the workspace directory name."""
239 text = spec_path.read_text(encoding="utf-8")
240 match = _FRONTMATTER_ID_RE.search(text)
241 raw = match.group(1).strip().strip("'\"") if match else workspace.name
242 safe = _FILENAME_UNSAFE_RE.sub("_", raw).strip(" ._")
243 return safe or "template"
244
245
246 def _replication_mode(spec_path: Path) -> str:
247 """Read the template replication mode, defaulting legacy packages to standard."""
248 text = spec_path.read_text(encoding="utf-8")
249 match = _REPLICATION_MODE_RE.search(text)
250 return match.group(1) if match else "standard"
251
252
253 def _canvas_viewbox(spec_path: Path) -> str | None:
254 """Read the template's locked root canvas when declared."""
255 text = spec_path.read_text(encoding="utf-8")
256 if not text.startswith("---\n"):
257 return None
258 end = text.find("\n---\n", 4)
259 if end == -1:
260 return None
261 match = _CANVAS_VIEWBOX_RE.search(text[4:end])
262 return match.group(1).strip() if match else None
263
264
265 def _style_property(style: str, name: str) -> str | None:
266 """Return one inline CSS declaration value."""
267 for declaration in style.split(";"):
268 key, separator, value = declaration.partition(":")
269 if separator and key.strip().lower() == name:
270 return value.strip()
271 return None
272
273
274 def _font_size_px(element: ET.Element) -> float | None:
275 """Read one finite positive SVG font size in px."""
276 raw = element.get("font-size")
277 if raw is None:
278 raw = _style_property(element.get("style", ""), "font-size")
279 if raw is None:
280 return None
281 match = _FONT_SIZE_RE.fullmatch(raw.strip())
282 if match is None:
283 return None
284 value = float(match.group(1))
285 return value if math.isfinite(value) and value > 0 else None
286
287
288 def _carrier_sizes(svg_files: list[Path]) -> tuple[list[float], list[float]]:
289 """Collect authored title/body sizes from semantic placeholder carriers."""
290 title_sizes: list[float] = []
291 body_sizes: list[float] = []
292 for svg_path in svg_files:
293 root = ET.parse(svg_path).getroot()
294 for slot in root.iter():
295 placeholder = slot.get("data-pptx-placeholder")
296 if placeholder not in _TITLE_PLACEHOLDERS | _BODY_PLACEHOLDERS:
297 continue
298 for carrier in slot.iter():
299 if carrier.get("data-pptx-carrier") != "true":
300 continue
301 size = _font_size_px(carrier)
302 if size is None:
303 continue
304 target = title_sizes if placeholder in _TITLE_PLACEHOLDERS else body_sizes
305 target.append(size)
306 return title_sizes, body_sizes
307
308
309 def _master_text_style(svg_files: list[Path]) -> tuple[MasterTextStyleSpec, float, float]:
310 """Build review-only Master text defaults without requiring a project lock."""
311 title_sizes, body_sizes = _carrier_sizes(svg_files)
312 title_px = float(statistics.median(title_sizes)) if title_sizes else _DEFAULT_TITLE_PX
313 body_px = float(statistics.median(body_sizes)) if body_sizes else _DEFAULT_BODY_PX
314 return (
315 MasterTextStyleSpec(
316 title_hpt=font_px_to_hpt(title_px),
317 body_hpt=font_px_to_hpt(body_px),
318 ),
319 title_px,
320 body_px,
321 )
322
323
324 def _verify_output(
325 output_path: Path,
326 *,
327 require_full_placeholder_frames: bool,
328 ) -> tuple[int, int, int, int]:
329 """Reopen the review deck and verify counts plus authored placeholder frames."""
330 presentation = Presentation(str(output_path))
331 master_count = len(presentation.slide_masters)
332 layout_count = sum(len(master.slide_layouts) for master in presentation.slide_masters)
333 placeholder_count = 0
334 if require_full_placeholder_frames:
335 for slide_number, slide in enumerate(presentation.slides, 1):
336 layout_placeholders = {
337 shape.placeholder_format.idx: shape
338 for shape in slide.slide_layout.placeholders
339 }
340 slide_placeholders = {
341 shape.placeholder_format.idx: shape
342 for shape in slide.placeholders
343 }
344 if set(slide_placeholders) != set(layout_placeholders):
345 raise ValueError(
346 f"review slide {slide_number} placeholder indexes do not match "
347 f"its Layout: {sorted(slide_placeholders)} != "
348 f"{sorted(layout_placeholders)}"
349 )
350 for placeholder_idx, slide_shape in slide_placeholders.items():
351 layout_shape = layout_placeholders[placeholder_idx]
352 if (
353 slide_shape.placeholder_format.type
354 != layout_shape.placeholder_format.type
355 ):
356 raise ValueError(
357 f"review slide {slide_number} placeholder {placeholder_idx} "
358 "type does not match its Layout"
359 )
360 slide_frame = (
361 slide_shape.left,
362 slide_shape.top,
363 slide_shape.width,
364 slide_shape.height,
365 )
366 layout_frame = (
367 layout_shape.left,
368 layout_shape.top,
369 layout_shape.width,
370 layout_shape.height,
371 )
372 if slide_frame != layout_frame:
373 raise ValueError(
374 f"review slide {slide_number} placeholder {placeholder_idx} "
375 f"uses a tight/local frame {slide_frame}; expected full "
376 f"Layout frame {layout_frame}"
377 )
378 placeholder_count += 1
379 return len(presentation.slides), master_count, layout_count, placeholder_count
380
381
382 def build_parser() -> argparse.ArgumentParser:
383 parser = argparse.ArgumentParser(
384 description=(
385 "Export a complete template workspace as a structured PPTX review deck."
386 ),
387 formatter_class=argparse.RawDescriptionHelpFormatter,
388 )
389 parser.add_argument(
390 "template_workspace",
391 help=(
392 "Workspace containing templates/design_spec.md; legacy flat template "
393 "directories are also accepted."
394 ),
395 )
396 parser.add_argument(
397 "-o",
398 "--output",
399 help=(
400 "Output PPTX path. Default: "
401 "<template_workspace>/exports/<template_id>_template_preview.pptx"
402 ),
403 )
404 parser.add_argument(
405 "--force",
406 action="store_true",
407 help="Replace an existing review PPTX after an intentional re-export.",
408 )
409 parser.add_argument(
410 "--visual-only",
411 action="store_true",
412 help=(
413 "Export a legacy SVG roster as slide-local DrawingML for visual review. "
414 "This does not validate or claim a reusable Master/Layout contract."
415 ),
416 )
417 return parser
418
419
420 def main(argv: list[str] | None = None) -> int:
421 require_skill_integrity()
422 parser = build_parser()
423 args = parser.parse_args(argv)
424
425 try:
426 workspace, template_dir = _resolve_workspace(Path(args.template_workspace))
427 all_svg_files = sorted(template_dir.glob("*.svg"))
428 if not all_svg_files:
429 raise ValueError(f"template directory has no SVG prototypes: {template_dir}")
430 svg_files, layout_definition_files = _partition_svg_prototypes(
431 all_svg_files,
432 visual_only=args.visual_only,
433 )
434 if not svg_files:
435 raise ValueError(
436 "template directory contains Layout definitions but no public "
437 f"SVG prototypes: {template_dir}"
438 )
439
440 spec_path = _roster_spec(template_dir)
441 template_id = _template_id(spec_path, workspace)
442 replication_mode = _replication_mode(spec_path)
443 locked_canvas = _canvas_viewbox(spec_path)
444 if locked_canvas is None and not args.visual_only:
445 raise ValueError(
446 "design_spec.md frontmatter must declare canvas_viewbox"
447 )
448 use_full_placeholder_frames = (
449 not args.visual_only and replication_mode != "mirror"
450 )
451 output_path = (
452 Path(args.output).expanduser().resolve()
453 if args.output
454 else workspace / "exports" / f"{template_id}_template_preview.pptx"
455 )
456 if output_path.suffix.lower() != ".pptx":
457 raise ValueError(f"output must use a .pptx extension: {output_path}")
458 if output_path.exists() and not args.force:
459 raise ValueError(
460 f"output already exists: {output_path}; use --force to replace it"
461 )
462 output_path.parent.mkdir(parents=True, exist_ok=True)
463 text_style: MasterTextStyleSpec | None = None
464 if not args.visual_only:
465 text_style, title_px, body_px = _master_text_style(all_svg_files)
466
467 print("PPT Master - Template Preview PPTX Exporter")
468 print(f" Workspace: {workspace}")
469 print(f" Template source: {template_dir}")
470 print(f" Public SVG prototypes: {len(svg_files)}")
471 if layout_definition_files:
472 print(
473 " Definition-only Layout prototypes: "
474 f"{len(layout_definition_files)}"
475 )
476 if args.visual_only:
477 print(" Review mode: visual-only legacy compatibility")
478 elif replication_mode == "mirror":
479 print(" Review placeholder frames: preserved source Slide geometry")
480 else:
481 print(f" Review Master defaults: title {title_px:g}px, body {body_px:g}px")
482 print(" Review placeholder frames: full Layout bounds")
483 print(f" Output: {output_path}")
484
485 with _review_svg_sources(
486 workspace,
487 all_svg_files,
488 shorten_placeholder_markers=use_full_placeholder_frames,
489 ) as review_all_svg_files:
490 review_svg_files, review_layout_definition_files = (
491 _partition_svg_prototypes(
492 review_all_svg_files,
493 visual_only=args.visual_only,
494 )
495 )
496 success = create_pptx_with_native_svg(
497 svg_files=review_svg_files,
498 output_path=output_path,
499 canvas_format=None,
500 expected_viewbox=locked_canvas,
501 verbose=True,
502 transition=None,
503 enable_notes=False,
504 animation=None,
505 image_optimize=False,
506 native_objects=True,
507 pptx_structure="flat" if args.visual_only else "structured",
508 use_layout_placeholder_frames=use_full_placeholder_frames,
509 master_text_style_spec=text_style,
510 structure_name=template_id,
511 layout_definition_files=review_layout_definition_files,
512 )
513 if not success or not output_path.is_file():
514 print("Error: template preview export did not produce a PPTX", file=sys.stderr)
515 return 1
516
517 slide_count, master_count, layout_count, placeholder_count = _verify_output(
518 output_path,
519 require_full_placeholder_frames=use_full_placeholder_frames,
520 )
521 if slide_count != len(svg_files):
522 print(
523 "Error: review PPTX slide count does not match the template SVG roster "
524 f"({slide_count} != {len(svg_files)})",
525 file=sys.stderr,
526 )
527 return 1
528
529 label = "Visual-only template preview" if args.visual_only else "Template preview"
530 placeholder_status = (
531 f", {placeholder_count} full-frame placeholder(s)"
532 if use_full_placeholder_frames
533 else ""
534 )
535 print(
536 f"[OK] {label} verified: "
537 f"{slide_count} slides, {master_count} master(s), "
538 f"{layout_count} layout(s){placeholder_status}"
539 )
540 print(output_path)
541 return 0
542 except (OSError, ET.ParseError, RuntimeError, ValueError) as exc:
543 print(f"Error: {exc}", file=sys.stderr)
544 return 1
545
546
547 if __name__ == "__main__":
548 raise SystemExit(main())
549
549 lines PYTHON