返回 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 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
494 lines PYTHON