返回 ppt-master
1 """CLI entry point for svg_to_pptx."""
2
3 from __future__ import annotations
4
5 import argparse
6 import hashlib
7 import json
8 import math
9 import re
10 import shutil
11 import sys
12 import zipfile
13 from dataclasses import dataclass
14 from datetime import datetime
15 from pathlib import Path
16 from xml.etree import ElementTree as ET
17
18 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
19 if str(_SCRIPTS_DIR) not in sys.path:
20 sys.path.insert(0, str(_SCRIPTS_DIR))
21
22 from attribution_guard import require_skill_integrity # noqa: E402
23 from console_encoding import configure_utf8_stdio # noqa: E402
24 from language_tags import ( # noqa: E402
25 LanguageTagError,
26 normalize_language_tag,
27 )
28 from native_payloads import PAYLOAD_STORE_RELATIVE_PATH # noqa: E402
29 from pptx_animations import ( # noqa: E402
30 ANIMATIONS,
31 animation_seconds_to_milliseconds,
32 normalize_animation_effect,
33 normalize_animation_trigger,
34 )
35 from pptx_transitions import ( # noqa: E402
36 LEGACY_TRANSITION_KEYS,
37 NATIVE_TRANSITION_KEYS,
38 normalize_transition_effect_request,
39 validate_seconds,
40 )
41
42 configure_utf8_stdio()
43
44 if __package__ in {None, ''}:
45 import types
46
47 package = types.ModuleType('svg_to_pptx')
48 package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
49 sys.modules.setdefault('svg_to_pptx', package)
50 __package__ = 'svg_to_pptx'
51
52 from .dimensions import CANVAS_FORMATS, get_project_info
53 from .discovery import NotesFileReadError, find_notes_files, find_svg_files
54 from .builder import create_pptx_with_native_svg
55 from ..native_objects import (
56 native_fallback_kind,
57 native_replacement_kind,
58 native_replacement_status,
59 )
60 from ..native_objects.marker_status import native_marker_release_block_reason
61 from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
62 from ..drawingml.context import (
63 TEXT_FLOW_PRESERVE,
64 TEXT_FLOW_REFLOW,
65 TEXT_FLOW_SPLIT,
66 )
67 from ..drawingml.theme_fonts import (
68 ThemeFontError,
69 load_master_text_style_spec,
70 load_theme_font_spec,
71 )
72 from ..drawingml.utils import unsafe_exported_font_faces
73 from .narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
74 from .template_structure import (
75 TemplateStructureError,
76 load_pptx_structure_lock,
77 parse_template_slides,
78 structured_layout_definition_files,
79 template_lock_errors,
80 template_prototype_errors,
81 )
82 from ..animation_config import (
83 animation_group_effect_entries,
84 load_animation_config,
85 validate_animation_config,
86 validate_animation_config_errors,
87 validate_transition_config,
88 )
89
90
91 def _as_dict(value: object) -> dict:
92 return value if isinstance(value, dict) else {}
93
94
95 _PPTX_STRUCTURE_SECTION_RE = re.compile(
96 r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)"
97 )
98 _PPTX_STRUCTURE_MODE_RE = re.compile(
99 r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^#\r\n]*?)[ \t]*(?:#.*)?$"
100 )
101 _LEGACY_PPTX_STRUCTURE_MODES = frozenset({
102 'baseline',
103 'generated',
104 'preserve',
105 'template',
106 })
107 _RELEASE_PPTX_STRUCTURE_MODES = frozenset({'flat', 'structured'})
108 _CSS_GENERIC_FONT_FAMILIES = frozenset({
109 'cursive',
110 'emoji',
111 'fangsong',
112 'fantasy',
113 'math',
114 'monospace',
115 'sans-serif',
116 'serif',
117 'system-ui',
118 'ui-monospace',
119 'ui-rounded',
120 'ui-sans-serif',
121 'ui-serif',
122 })
123
124
125 class PptxPostflightValidationError(RuntimeError):
126 """Reject a generated PPTX that fails package postflight validation."""
127
128
129 @dataclass
130 class _PostflightReceipt:
131 """Carry the compact export result printed after the audit is written."""
132
133 output_path: Path
134 report_path: Path
135 status: str
136 quality_gate: str
137 slide_count: int
138 warnings: tuple[str, ...]
139
140
141 def _font_stack_is_generic_only(stack: str) -> bool:
142 """Return whether a CSS font stack contains no concrete family name."""
143 families = [
144 family.strip().strip('"\'').strip().lower()
145 for family in stack.split(',')
146 if family.strip().strip('"\'').strip()
147 ]
148 return bool(families) and all(
149 family in _CSS_GENERIC_FONT_FAMILIES
150 for family in families
151 )
152
153
154 def _package_part_counts(pptx_path: Path) -> dict[str, object]:
155 """Count public and structural OOXML parts in a completed PPTX package."""
156 with zipfile.ZipFile(pptx_path) as archive:
157 bad_member = archive.testzip()
158 names = archive.namelist()
159
160 def count(pattern: str) -> int:
161 matcher = re.compile(pattern)
162 return sum(bool(matcher.fullmatch(name)) for name in names)
163
164 return {
165 'zip_integrity': 'passed' if bad_member is None else 'failed',
166 'corrupt_member': bad_member,
167 'slides': count(r'ppt/slides/slide\d+\.xml'),
168 'notes': count(r'ppt/notesSlides/notesSlide\d+\.xml'),
169 'masters': count(r'ppt/slideMasters/slideMaster\d+\.xml'),
170 'layouts': count(r'ppt/slideLayouts/slideLayout\d+\.xml'),
171 }
172
173
174 def _source_resource_audit(svg_files: list[Path]) -> dict[str, object]:
175 """Collect unresolved tokens and portability-oriented source inventories."""
176 placeholder_re = re.compile(r'\{\{[^{}]+\}\}')
177 placeholders: list[dict[str, str]] = []
178 font_stacks: set[str] = set()
179 image_counts = {
180 'data_uri': 0,
181 'local': 0,
182 'external': 0,
183 }
184 external_images: list[dict[str, str]] = []
185 for svg_path in svg_files:
186 try:
187 content = svg_path.read_text(encoding='utf-8')
188 root = ET.fromstring(content)
189 except (OSError, ET.ParseError):
190 continue
191 for token in sorted(set(placeholder_re.findall(content))):
192 placeholders.append({'file': svg_path.name, 'token': token})
193 for element in root.iter():
194 font_family = element.get('font-family')
195 if font_family:
196 font_stacks.add(font_family.strip())
197 style = element.get('style') or ''
198 for declaration in style.split(';'):
199 if ':' not in declaration:
200 continue
201 name, value = declaration.split(':', 1)
202 if name.strip().lower() == 'font-family' and value.strip():
203 font_stacks.add(value.strip())
204 if element.tag.rsplit('}', 1)[-1] != 'image':
205 continue
206 href = (
207 element.get('href')
208 or element.get('{http://www.w3.org/1999/xlink}href')
209 or ''
210 ).strip()
211 if href.startswith('data:'):
212 image_counts['data_uri'] += 1
213 elif re.match(r'^[a-z][a-z0-9+.-]*://', href, re.IGNORECASE):
214 image_counts['external'] += 1
215 external_images.append({
216 'file': svg_path.name,
217 'href': href,
218 })
219 elif href:
220 image_counts['local'] += 1
221 generic_only_font_stacks = sorted({
222 stack
223 for stack in font_stacks
224 if _font_stack_is_generic_only(stack)
225 })
226 unsafe_font_faces = [
227 {
228 'stack': stack,
229 'role': role,
230 'typeface': typeface,
231 }
232 for stack in sorted(font_stacks)
233 for role, typeface in unsafe_exported_font_faces(stack).items()
234 ]
235 return {
236 'unresolved_template_tokens': placeholders,
237 'fonts': {
238 'stacks': sorted(font_stacks),
239 'generic_only_stacks': generic_only_font_stacks,
240 'unsafe_exported_faces': unsafe_font_faces,
241 },
242 'images': {
243 **image_counts,
244 'external_references': external_images,
245 },
246 }
247
248
249 def _svg_source_fingerprint(svg_files: list[Path]) -> dict[str, object]:
250 """Return one deterministic digest for the exact SVG export inputs."""
251 files: list[dict[str, object]] = []
252 aggregate = hashlib.sha256()
253 for path in sorted(svg_files, key=lambda item: item.name):
254 file_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
255 files.append({'file': path.name, 'sha256': file_sha256})
256 aggregate.update(path.name.encode('utf-8'))
257 aggregate.update(b'\0')
258 aggregate.update(file_sha256.encode('ascii'))
259 aggregate.update(b'\n')
260 return {
261 'algorithm': 'sha256',
262 'digest': aggregate.hexdigest(),
263 'file_count': len(files),
264 'files': files,
265 }
266
267
268 def _quality_report_context(
269 project_path: Path,
270 source_fingerprint: dict[str, object],
271 ) -> dict[str, object]:
272 """Load the final SVG quality report when the preceding gate wrote one."""
273 quality_path = project_path / 'validation' / 'svg_quality_report.json'
274 try:
275 quality = json.loads(quality_path.read_text(encoding='utf-8'))
276 except FileNotFoundError:
277 return {'status': 'not-provided', 'path': str(quality_path)}
278 except (OSError, json.JSONDecodeError) as exc:
279 return {
280 'status': 'unreadable',
281 'path': str(quality_path),
282 'error': str(exc),
283 }
284 schema = quality.get('schema')
285 if schema != 'ppt-master.svg-quality-report.v1':
286 return {
287 'status': 'unsupported-schema',
288 'path': str(quality_path),
289 'schema': schema,
290 }
291 categories = quality.get('categories')
292 quality_fingerprint = quality.get('source_fingerprint')
293 if not isinstance(quality_fingerprint, dict):
294 source_match = 'unavailable'
295 elif (
296 quality_fingerprint.get('algorithm') == 'sha256'
297 and quality_fingerprint.get('digest') == source_fingerprint.get('digest')
298 and quality_fingerprint.get('file_count')
299 == source_fingerprint.get('file_count')
300 ):
301 source_match = 'passed'
302 else:
303 source_match = 'mismatch'
304 return {
305 'status': 'loaded',
306 'path': str(quality_path),
307 'schema': schema,
308 'stage': quality.get('stage'),
309 'source_match': source_match,
310 'source_fingerprint': quality_fingerprint,
311 'summary': quality.get('summary'),
312 'categories': categories if isinstance(categories, dict) else {},
313 }
314
315
316 def _quality_gate_status(
317 quality: dict[str, object],
318 ) -> tuple[str, int]:
319 """Return final-gate status and introduced-warning count."""
320 categories = quality.get('categories')
321 blocking_count = None
322 introduced_warning_count = 0
323 if isinstance(categories, dict):
324 blocking = categories.get('blocking')
325 if isinstance(blocking, dict):
326 blocking_count = blocking.get('count')
327 introduced = categories.get('introduced')
328 if (
329 isinstance(introduced, dict)
330 and isinstance(introduced.get('count'), int)
331 ):
332 introduced_warning_count = int(introduced['count'])
333 if quality.get('status') != 'loaded':
334 return str(quality.get('status') or 'not-provided'), introduced_warning_count
335 if quality.get('stage') != 'final':
336 return 'non-final', introduced_warning_count
337 if not isinstance(blocking_count, int):
338 return 'unverified', introduced_warning_count
339 if blocking_count > 0:
340 return 'failed', introduced_warning_count
341 if quality.get('source_match') == 'mismatch':
342 return 'stale', introduced_warning_count
343 if quality.get('source_match') != 'passed':
344 return 'unverified', introduced_warning_count
345 return 'passed', introduced_warning_count
346
347
348 def _postflight_warning_summaries(
349 *,
350 quality_gate: str,
351 introduced_warning_count: int,
352 unresolved_token_count: int,
353 external_image_count: int,
354 generic_font_stack_count: int,
355 unsafe_font_face_count: int,
356 ) -> tuple[str, ...]:
357 """Return stable warning summaries for the terminal receipt."""
358 warnings: list[str] = []
359 if quality_gate != 'passed':
360 warnings.append(f'quality_gate={quality_gate}')
361 if introduced_warning_count:
362 warnings.append(f'quality_introduced_warnings={introduced_warning_count}')
363 if unresolved_token_count:
364 warnings.append(f'unresolved_template_tokens={unresolved_token_count}')
365 if external_image_count:
366 warnings.append(f'external_images={external_image_count}')
367 if generic_font_stack_count:
368 warnings.append(f'generic_only_font_stacks={generic_font_stack_count}')
369 if unsafe_font_face_count:
370 warnings.append(f'unsafe_exported_font_faces={unsafe_font_face_count}')
371 return tuple(warnings)
372
373
374 def _write_postflight_report(
375 *,
376 output_path: Path,
377 project_path: Path,
378 svg_files: list[Path],
379 layout_definition_files: list[Path],
380 pptx_structure: str,
381 backup_path: Path | None,
382 conversion_trace_path: Path | None,
383 deck_motion: dict[str, object],
384 ) -> _PostflightReceipt:
385 """Write the unified package/resource audit for a successful PPTX."""
386 try:
387 package = _package_part_counts(output_path)
388 except (OSError, zipfile.BadZipFile) as exc:
389 raise PptxPostflightValidationError(
390 f"generated PPTX is not a readable ZIP package: {exc}"
391 ) from exc
392 if package['zip_integrity'] != 'passed':
393 raise PptxPostflightValidationError(
394 f"PPTX ZIP integrity failed at {package['corrupt_member']}"
395 )
396 if package['slides'] != len(svg_files):
397 raise PptxPostflightValidationError(
398 "Published Slide count does not match authored SVG count: "
399 f"{package['slides']} != {len(svg_files)}"
400 )
401 source_audit = _source_resource_audit(svg_files)
402 source_fingerprint = _svg_source_fingerprint(svg_files)
403 quality = _quality_report_context(project_path, source_fingerprint)
404 quality_gate, introduced_warning_count = _quality_gate_status(quality)
405 unresolved_tokens = source_audit['unresolved_template_tokens']
406 external_image_count = source_audit['images']['external']
407 generic_only_font_stacks = source_audit['fonts']['generic_only_stacks']
408 unsafe_font_faces = source_audit['fonts']['unsafe_exported_faces']
409 if quality_gate == 'failed':
410 report_status = 'failed'
411 elif (
412 not unresolved_tokens
413 and not external_image_count
414 and not generic_only_font_stacks
415 and not unsafe_font_faces
416 and not introduced_warning_count
417 and quality_gate == 'passed'
418 ):
419 report_status = 'passed'
420 else:
421 report_status = 'passed-with-warnings'
422 report_path = (
423 project_path / 'validation' / f'{output_path.stem}.report.json'
424 )
425 report = {
426 'schema': 'ppt-master.pptx-postflight-report.v1',
427 'status': report_status,
428 'output': {
429 'path': str(output_path.resolve()),
430 'bytes': output_path.stat().st_size,
431 },
432 'source': {
433 'svg_slide_count': len(svg_files),
434 'layout_definition_count': len(layout_definition_files),
435 'fingerprint': source_fingerprint,
436 },
437 'package': package,
438 'checks': {
439 'zip_integrity': 'passed',
440 'slide_count': 'passed',
441 'internal_relationships': 'enforced-at-build',
442 'structured_package': (
443 'enforced-at-build'
444 if pptx_structure == 'structured'
445 else 'not-applicable'
446 ),
447 'transitions': 'enforced-at-build',
448 'animations': 'enforced-at-build',
449 'quality_gate': quality_gate,
450 'quality_warnings': (
451 'passed' if not introduced_warning_count else 'warning'
452 ),
453 'template_tokens': (
454 'passed' if not unresolved_tokens else 'warning'
455 ),
456 'external_images': (
457 'passed' if not external_image_count else 'warning'
458 ),
459 'font_portability': (
460 'passed'
461 if not generic_only_font_stacks and not unsafe_font_faces
462 else 'warning'
463 ),
464 },
465 'quality': quality,
466 'resources': source_audit,
467 'deck_motion': deck_motion,
468 'backup_path': str(backup_path.resolve()) if backup_path else None,
469 'conversion_trace_path': (
470 str(conversion_trace_path.resolve())
471 if conversion_trace_path and conversion_trace_path.is_file()
472 else None
473 ),
474 }
475 report_path.parent.mkdir(parents=True, exist_ok=True)
476 report_path.write_text(
477 json.dumps(report, ensure_ascii=False, indent=2) + '\n',
478 encoding='utf-8',
479 )
480 warnings = _postflight_warning_summaries(
481 quality_gate=quality_gate,
482 introduced_warning_count=introduced_warning_count,
483 unresolved_token_count=len(unresolved_tokens),
484 external_image_count=external_image_count,
485 generic_font_stack_count=len(generic_only_font_stacks),
486 unsafe_font_face_count=len(unsafe_font_faces),
487 )
488 return _PostflightReceipt(
489 output_path=output_path,
490 report_path=report_path,
491 status=report_status,
492 quality_gate=quality_gate,
493 slide_count=int(package['slides']),
494 warnings=warnings,
495 )
496
497
498 def _load_deck_motion_handoff(
499 project_path: Path,
500 report_arg: str,
501 svg_files: list[Path],
502 ) -> dict[str, object]:
503 """Load source-bound deck motion from a successful base export report."""
504 report_path = Path(report_arg).expanduser()
505 if not report_path.is_absolute() and not report_path.is_file():
506 report_path = project_path / report_path
507 try:
508 report = json.loads(report_path.read_text(encoding='utf-8'))
509 except FileNotFoundError as exc:
510 raise ValueError(
511 f'deck-motion handoff report does not exist: {report_path}'
512 ) from exc
513 except (OSError, json.JSONDecodeError) as exc:
514 raise ValueError(
515 f'deck-motion handoff report is unreadable: {report_path}: {exc}'
516 ) from exc
517 if not isinstance(report, dict):
518 raise ValueError('deck-motion handoff report must be a JSON object')
519 if report.get('schema') != 'ppt-master.pptx-postflight-report.v1':
520 raise ValueError(
521 'deck-motion handoff requires a ppt-master postflight report'
522 )
523 if report.get('status') not in {'passed', 'passed-with-warnings'}:
524 raise ValueError('deck-motion handoff report is not a successful export')
525 source = _as_dict(report.get('source'))
526 if source.get('fingerprint') != _svg_source_fingerprint(svg_files):
527 raise ValueError(
528 'deck-motion handoff does not match the current svg_output; '
529 'run the base export again'
530 )
531 motion = report.get('deck_motion')
532 if not isinstance(motion, dict):
533 raise ValueError(
534 'deck-motion handoff is missing from the base export report; '
535 'run the base export again'
536 )
537 if motion.get('narration_timings') is True:
538 raise ValueError(
539 'deck-motion handoff must reference a base non-narrated export report'
540 )
541 if not isinstance(motion.get('transition'), dict):
542 raise ValueError('deck-motion handoff transition must be an object')
543 if not isinstance(motion.get('animation'), dict):
544 raise ValueError('deck-motion handoff animation must be an object')
545 if not isinstance(motion.get('cli_overrides'), dict):
546 raise ValueError('deck-motion handoff cli_overrides must be an object')
547 return motion
548
549
550 def _print_postflight_receipt(receipt: _PostflightReceipt) -> None:
551 """Print the compact completion evidence; keep the full JSON on disk."""
552 print(
553 ' [POSTFLIGHT] '
554 f'status={receipt.status} '
555 f'quality_gate={receipt.quality_gate} '
556 f'slides={receipt.slide_count} '
557 f'warning_categories={len(receipt.warnings)}'
558 )
559 for warning in receipt.warnings:
560 print(f' [POSTFLIGHT][WARNING] {warning}')
561 print(f' [PPTX] {receipt.output_path}')
562 print(f' [REPORT] {receipt.report_path}')
563
564
565 def _declared_pptx_structure_mode(project_path: Path) -> str | None:
566 """Return the explicitly locked SVG export mode, if the lock declares one."""
567 lock_path = project_path / 'spec_lock.md'
568 try:
569 content = lock_path.read_text(encoding='utf-8')
570 except OSError:
571 return None
572 section_match = _PPTX_STRUCTURE_SECTION_RE.search(content)
573 if section_match is None:
574 return None
575 mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1))
576 return mode_match.group(1).strip().lower() if mode_match else None
577
578
579 def _declared_canvas_viewbox(project_path: Path) -> str | None:
580 """Return the project-lock root canvas without inferring from its name."""
581 lock_path = project_path / 'spec_lock.md'
582 try:
583 from update_spec import parse_lock
584
585 lock = parse_lock(lock_path)
586 except (OSError, ValueError):
587 return None
588 canvas = lock.get('canvas', {})
589 value = canvas.get('viewBox')
590 return value.strip() if isinstance(value, str) and value.strip() else None
591
592
593 def _declared_primary_language(project_path: Path) -> str | None:
594 """Return the canonical content language declared by the execution lock."""
595 lock_path = project_path / 'spec_lock.md'
596 try:
597 from update_spec import parse_lock
598
599 lock = parse_lock(lock_path)
600 except (OSError, ValueError):
601 return None
602 communication = lock.get('communication', {})
603 value = communication.get('primary_language')
604 if not isinstance(value, str) or not value.strip():
605 return None
606 try:
607 return normalize_language_tag(value)
608 except LanguageTagError as exc:
609 raise LanguageTagError(
610 'spec_lock.md communication.primary_language '
611 f'is invalid: {exc}'
612 ) from exc
613
614
615 def _print_structure_contract_error(
616 mode: str | None,
617 *,
618 requested_mode: str | None = None,
619 ) -> None:
620 """Explain an unsupported mode or a structured-export lock mismatch."""
621 label = repr(mode) if mode is not None else 'missing'
622 if requested_mode == 'structured':
623 print(
624 "Error: --pptx-structure structured requires an explicit "
625 "spec_lock.md pptx_structure.mode: structured contract; found "
626 + label + ".",
627 file=sys.stderr,
628 )
629 print(
630 " A legacy lock without pptx_structure.mode defaults only to flat. "
631 "Mirror/layout reuse must first create a current template workspace "
632 "through skills/ppt-master/workflows/create-template.md, then generate "
633 "new structured SVG pages.",
634 file=sys.stderr,
635 )
636 return
637 print(
638 "Error: unsupported spec_lock.md pptx_structure.mode " + label + ". "
639 "Current release modes are flat (style reference / free design / "
640 "brand-only) and structured (mirror/layout reuse).",
641 file=sys.stderr,
642 )
643 print(
644 " A legacy lock with no pptx_structure.mode defaults to flat. "
645 "Explicit legacy or unknown values are not inferred. Mirror/layout reuse "
646 "must first create a current template workspace "
647 "through skills/ppt-master/workflows/create-template.md, then generate "
648 "new structured SVG pages.",
649 file=sys.stderr,
650 )
651
652
653 def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]]:
654 """Return fallback-only chart/table replacement statuses from SVG inputs."""
655 fallbacks: list[tuple[str, str, str]] = []
656 for svg_path in svg_files:
657 try:
658 root = ET.parse(svg_path).getroot()
659 except (OSError, ET.ParseError):
660 continue
661 for elem in root.iter():
662 status = native_replacement_status(elem)
663 if not status or elem.tag.rsplit('}', 1)[-1] == 'metadata':
664 continue
665 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
666 fallbacks.append((svg_path.name, marker_id, status))
667 return fallbacks
668
669
670 def _release_blocked_graphics(
671 svg_files: list[Path],
672 ) -> list[tuple[str, str, str]]:
673 """Return graphics whose status metadata is invalid."""
674 blocked: list[tuple[str, str, str]] = []
675 for svg_path in svg_files:
676 try:
677 root = ET.parse(svg_path).getroot()
678 except (OSError, ET.ParseError):
679 continue
680 for elem in root.iter():
681 if elem.tag.rsplit('}', 1)[-1] == 'metadata':
682 continue
683 reason = native_marker_release_block_reason(elem)
684 if reason is None:
685 continue
686 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
687 blocked.append((svg_path.name, marker_id, reason))
688 return blocked
689
690
691 def _reconstruction_only_graphics(
692 svg_files: list[Path],
693 ) -> list[tuple[str, str, bool]]:
694 """Return valid placeholder routes for non-blocking diagnostics."""
695 diagnostics: list[tuple[str, str, bool]] = []
696 for svg_path in svg_files:
697 try:
698 root = ET.parse(svg_path).getroot()
699 except (OSError, ET.ParseError):
700 continue
701 for elem in root.iter():
702 if elem.tag.rsplit('}', 1)[-1] == 'metadata':
703 continue
704 if native_fallback_kind(elem) != 'placeholder':
705 continue
706 if native_marker_release_block_reason(elem) is not None:
707 continue
708 marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
709 active_native = bool(native_replacement_kind(elem))
710 diagnostics.append((svg_path.name, marker_id, active_native))
711 return diagnostics
712
713
714 def _recorded_narration_on_click_slides(
715 ref_files: list[Path],
716 animation_config: dict | None,
717 animation: str | None,
718 animation_trigger: str,
719 animation_cli_overrides: dict[str, bool],
720 ) -> list[str]:
721 """Return slides whose effective recorded-video animation trigger is on-click."""
722 if animation_cli_overrides.get('animation') and animation is None:
723 return []
724 slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
725 blocked: list[str] = []
726 for svg_path in ref_files:
727 slide_cfg = _as_dict(slides_cfg.get(svg_path.stem))
728 anim_cfg = _as_dict(slide_cfg.get('animation'))
729
730 slide_animation = animation
731 if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
732 slide_animation = normalize_animation_effect(anim_cfg.get('effect'))
733 slide_trigger = animation_trigger
734 if (
735 not animation_cli_overrides.get('animation_trigger')
736 and anim_cfg.get('trigger')
737 ):
738 slide_trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
739
740 groups_cfg = _as_dict(slide_cfg.get('groups'))
741 has_interactive_animation = False
742 for group_id, group_cfg in groups_cfg.items():
743 if not isinstance(group_cfg, dict):
744 continue
745 group_path = (
746 f'slides[{json.dumps(svg_path.stem, ensure_ascii=False)}]'
747 f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
748 )
749 for _effect_path, effect_cfg in animation_group_effect_entries(
750 group_cfg,
751 path=group_path,
752 ):
753 row_effect = (
754 normalize_animation_effect(effect_cfg.get('effect'))
755 if 'effect' in effect_cfg
756 else slide_animation
757 )
758 if row_effect is None:
759 continue
760 row_trigger = (
761 normalize_animation_trigger(effect_cfg.get('trigger'))
762 if effect_cfg.get('trigger')
763 else slide_trigger
764 )
765 if effect_cfg.get('trigger_shape') is not None:
766 has_interactive_animation = True
767 break
768 if row_trigger == 'on-click':
769 has_interactive_animation = True
770 break
771 if has_interactive_animation:
772 break
773
774 if has_interactive_animation or (
775 slide_animation is not None and slide_trigger == 'on-click'
776 ):
777 blocked.append(svg_path.stem)
778 return blocked
779
780
781 def _resolve_animation_config_source(
782 project_path: Path,
783 requested_config: str | None,
784 *,
785 recorded_narration: bool,
786 no_animations: bool,
787 ) -> str | None:
788 """Resolve the animation sidecar selected for this export."""
789 if requested_config is not None or not recorded_narration or no_animations:
790 return requested_config
791
792 canonical_exists = (project_path / 'animations.json').is_file()
793 narration_exists = (project_path / 'narration_animations.json').is_file()
794 if canonical_exists or narration_exists:
795 return 'narration_animations.json'
796 return None
797
798
799 def main(argv: list[str] | None = None) -> int:
800 """CLI entry point for the SVG to PPTX conversion tool."""
801 require_skill_integrity()
802 transition_choices = [
803 'none',
804 *NATIVE_TRANSITION_KEYS,
805 *LEGACY_TRANSITION_KEYS,
806 ]
807
808 animation_choices = ['none', *ANIMATIONS, 'auto', 'mixed', 'random']
809
810 parser = argparse.ArgumentParser(
811 description='PPT Master - SVG to native DrawingML PPTX Tool',
812 formatter_class=argparse.RawDescriptionHelpFormatter,
813 epilog=f'''
814 Examples:
815 %(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
816 %(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
817 %(prog)s projects/quick_generate_demo --quick-generate # Lockless flat export with normal postflight
818
819 # Disable transition / change transition effect
820 %(prog)s examples/ppt169_demo -t none
821 %(prog)s examples/ppt169_demo -t push --transition-duration 1.0
822
823 SVG source directory (-s):
824 output - svg_output (hand-authored source; native default)
825 final - svg_final (post-processed preview; diagnostic native input only)
826 <any> - Specify a subdirectory name directly
827 Omit -s to use the default: native export reads svg_output.
828
829 Transition effects (-t/--transition):
830 New selections use the 48 PowerPoint-native gallery keys. The 8 old
831 names remain accepted only as compatibility inputs. Run
832 scripts/pptx_animations.py --list for the categorized registry and
833 --describe-transition <effect> for its Effect Options.
834
835 Per-element object animation (-a/--animation, native shapes mode):
836 Use PowerPoint-native entrance_*, emphasis_*, path_*, and exit_* keys for
837 new animation choices. The 29 old short names remain accepted only as
838 compatibility inputs. Run scripts/pptx_animations.py --list for the
839 complete categorized 232-key input registry.
840 Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
841 "none" (no auto element builds; page transitions still apply). Use
842 "-a auto" to map effects from group id: chart→entrance_wipe,
843 card-/step-/pillar-→entrance_fly,
844 title/takeaway→entrance_fade; image-like ids
845 hero/figure-/image/img-/kpi cycle canonical entrance presets;
846 unmatched ids cycle entrance_fade/entrance_wipe/entrance_fly/
847 entrance_zoom. Start mode set by --animation-trigger, matching
848 PowerPoint's Start dropdown:
849 on-click one presenter click per group
850 with-previous all groups start together on slide entry
851 after-previous (default) cascade on slide entry;
852 gap = --animation-stagger seconds
853 mixed (compatible mode name) cycles a larger 16-preset canonical
854 PowerPoint entrance pool by group order; random samples from the
855 same entrance pool. Use explicit canonical keys for emphasis,
856 motion-path, or exit duties. Use "-a none" to disable element
857 builds explicitly.
858
859 Speaker notes:
860 - Automatically reads Markdown notes files from the notes/ directory
861 - Supports two naming conventions:
862 1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
863 2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
864 - Enabled by default outside Quick Generate; use --no-notes to disable
865 - Disabled by default in Quick Generate; use --with-notes to enable
866
867 Recorded narration:
868 %(prog)s examples/ppt169_demo --recorded-narration audio \\
869 --inherit-motion-from validation/<base>.report.json
870 - Keeps speaker notes when enabled
871 - Prepares PowerPoint recorded timings and narrations
872 - Requires one m4a/mp3/wav file per slide
873 - Uses narration_animations.json when animation sidecars exist
874 - Inherits source-bound deck motion from the base postflight report
875 - Use --animation-config animations.json for the canonical animation
876 - Use --no-animations for narration and timings without animation motion
877 - Embeds per-slide audio matched by SVG filename / slide number
878 - Sets slide auto-advance from audio duration so video export can use
879 "recorded timings and narrations"
880 - Rejects on-click object animations; use after-previous or with-previous
881 %(prog)s examples/ppt169_demo --narration-audio-dir audio
882 - Lower-level audio embedding: embeds matched files but allows partial matches
883 - Use only when you do not need a complete recorded-timings export
884 ''',
885 )
886
887 parser.add_argument('project_path', type=str, help='Project directory path')
888 parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
889 parser.add_argument('-s', '--source', type=str, default=None,
890 help='Native SVG source directory. Default: svg_output/. '
891 'Pass output/final/<name> only for diagnostics.')
892 parser.add_argument('-f', '--format', type=str,
893 choices=list(CANVAS_FORMATS.keys()), default=None,
894 help='Require SVG canvases to match this registered format')
895 parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
896 parser.add_argument(
897 '--quick-generate',
898 action='store_true',
899 help=(
900 'Export a Quick Generate SVG roster from svg_output/ without '
901 'spec_lock.md. Require a matching final quality report, infer one '
902 'consistent canvas, use a flat package with converter defaults, '
903 'and support normal export capabilities.'
904 ),
905 )
906
907 text_flow_group = parser.add_mutually_exclusive_group()
908 text_flow_group.add_argument(
909 '--reflow-text',
910 action='store_const',
911 const=TEXT_FLOW_REFLOW,
912 dest='text_flow',
913 help=(
914 'Let PowerPoint automatically reflow conservative dy-stacked text '
915 'inside one editable text frame.'
916 ),
917 )
918 text_flow_group.add_argument(
919 '--merge-paragraphs',
920 action='store_const',
921 const=TEXT_FLOW_REFLOW,
922 dest='text_flow',
923 help='Compatibility alias for --reflow-text.',
924 )
925 text_flow_group.add_argument(
926 '--no-merge',
927 action='store_const',
928 const=TEXT_FLOW_SPLIT,
929 dest='text_flow',
930 help=(
931 'Emit every positioned visual line as its own text frame for '
932 'strict per-line SVG positioning.'
933 ),
934 )
935 parser.set_defaults(text_flow=TEXT_FLOW_PRESERVE)
936 parser.add_argument(
937 '--conversion-trace',
938 nargs='?',
939 const='',
940 default=None,
941 metavar='PATH',
942 help='Write per-slide SVG conversion diagnostics. Without PATH, write '
943 '<project>/validation/<output_stem>.trace.json; relative PATHs '
944 'are resolved from the project root.',
945 )
946 parser.add_argument(
947 '--native-charts-and-tables',
948 dest='native_objects',
949 action='store_true',
950 default=False,
951 help=(
952 'Replace explicit data-pptx-replace-with chart/table groups with '
953 'PowerPoint native Chart/Table objects. This data-object route may '
954 'normalize styling or omit fallback-only visuals. Default off: groups '
955 'export as editable SVG-derived DrawingML shapes. The default-flow '
956 'output uses <project>_<ts>_native_charts_tables.pptx.'
957 ),
958 )
959 parser.add_argument(
960 '--native-objects',
961 dest='native_objects',
962 action='store_true',
963 help=argparse.SUPPRESS,
964 )
965 parser.add_argument(
966 '--pptx-structure',
967 choices=[
968 'structured',
969 'flat',
970 'baseline',
971 'template',
972 'preserve',
973 'generated',
974 ],
975 default=None,
976 help=(
977 'PPTX structure strategy for native export. Omitting this flag reads '
978 'spec_lock.md; a legacy lock without pptx_structure.mode defaults to '
979 'flat. Flat is the style-reference/free-design/brand-only release mode and '
980 'builds one clean project-owned Master plus Blank Layout while keeping '
981 'all SVG objects slide-local; structured is the mirror/layout reuse '
982 'mode and requires complete explicit metadata. baseline, template, '
983 'preserve, and generated are accepted only to report a migration error.'
984 ),
985 )
986 parser.add_argument('--no-image-optimize', action='store_true',
987 help='Disable native PPTX raster image optimization and always embed '
988 'the original image bytes.')
989 parser.add_argument('--image-max-dimension', type=int, default=2560,
990 help='Preferred raster cap in pixels. Cap mode re-encodes only images '
991 'that require resizing or EXIF geometry normalization, and may '
992 'retain more pixels for cropped/stretched visible resolution '
993 '(default: 2560).')
994 parser.add_argument('--image-sizing', choices=['cap', 'display'], default='cap',
995 help='Raster sizing mode: cap preserves original bytes unless resizing '
996 'or EXIF geometry normalization is required; display targets the '
997 'SVG rendered box for explicit compaction (default: cap).')
998 parser.add_argument('--image-scale', type=float, default=2.0,
999 help='Target optimized image pixels per SVG display pixel '
1000 'when --image-sizing=display (default: 2.0).')
1001 parser.add_argument('--image-quality', type=int, default=85,
1002 help='JPEG quality for raster images re-encoded during optimization, '
1003 '1-100 (default: 85).')
1004
1005 def non_negative_float(value: str) -> float:
1006 try:
1007 number = float(value)
1008 except ValueError as exc:
1009 raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
1010 if not math.isfinite(number):
1011 raise argparse.ArgumentTypeError("must be finite")
1012 if number < 0:
1013 raise argparse.ArgumentTypeError("must be non-negative")
1014 return number
1015
1016 def positive_float(value: str) -> float:
1017 number = non_negative_float(value)
1018 if number <= 0:
1019 raise argparse.ArgumentTypeError("must be greater than zero")
1020 return number
1021
1022 parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
1023 help='Page transition effect (default: fade; "none" removes visual motion)')
1024 parser.add_argument('--transition-duration', type=non_negative_float, default=None,
1025 help='Transition duration in seconds (default: 0.4)')
1026 parser.add_argument('--auto-advance', type=non_negative_float, default=None,
1027 help='Auto-advance interval in seconds (default: manual advance)')
1028
1029 parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
1030 default=None,
1031 help='Per-element object animation (native shapes mode '
1032 'only). Default "none" (no auto element builds; page '
1033 'transitions still apply). Pick a native entrance_*/'
1034 'emphasis_*/path_*/exit_* key or "auto" '
1035 '(map effect from group id — image-like ids cycle a '
1036 'richer canonical pool for visual variation, fallback '
1037 'cycles entrance_fade/entrance_wipe/entrance_fly/'
1038 'entrance_zoom), "mixed" (canonical 16-preset entrance '
1039 'pool), or "random" (the same entrance pool). Use '
1040 'explicit keys for emphasis/path/exit. Legacy short '
1041 'names remain accepted only for compatibility.')
1042 parser.add_argument('--animation-duration', type=positive_float, default=None,
1043 help='Per-element object-animation duration in seconds '
1044 '(default: 0.4; instantaneous native presets keep their '
1045 'PowerPoint-authored duration)')
1046 parser.add_argument('--animation-trigger', type=str,
1047 choices=['on-click', 'with-previous', 'after-previous'],
1048 default=None,
1049 help='Per-element Start mode (matches PowerPoint Start dropdown): '
1050 '"on-click" (one click per element), '
1051 '"with-previous" (all start together on slide entry), '
1052 '"after-previous" (default, cascade after the previous element).')
1053 parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
1054 help='Delay between elements in --animation-trigger=after-previous '
1055 '(seconds, default 0.5). Ignored in other modes.')
1056 animation_source = parser.add_mutually_exclusive_group()
1057 animation_source.add_argument(
1058 '--animation-config',
1059 type=str,
1060 default=None,
1061 help=(
1062 'Per-slide/per-object animation config. Recorded narration uses '
1063 '<project>/narration_animations.json when an animation sidecar exists, '
1064 'or may inherit base postflight motion with --inherit-motion-from. '
1065 'Other exports default to <project>/animations.json when present.'
1066 ),
1067 )
1068 animation_source.add_argument(
1069 '--no-animations',
1070 action='store_true',
1071 help=(
1072 'Export without object animations or page-transition motion. '
1073 'Narration audio and slide advance timings are preserved.'
1074 ),
1075 )
1076
1077 notes_mode = parser.add_mutually_exclusive_group()
1078 notes_mode.add_argument(
1079 '--with-notes',
1080 action='store_true',
1081 help='Embed speaker notes. Required to opt in during Quick Generate.',
1082 )
1083 notes_mode.add_argument(
1084 '--no-notes',
1085 action='store_true',
1086 help='Disable speaker notes embedding (enabled by default outside Quick Generate)',
1087 )
1088 parser.add_argument('--narration-audio-dir', type=str, default=None,
1089 help='Low-level audio embedding from this directory; allows partial matches. '
1090 'Default-flow exports get the _narrated name suffix.')
1091 parser.add_argument('--use-narration-timings', action='store_true',
1092 help='Set slide auto-advance timings from narration audio durations')
1093 parser.add_argument('--recorded-narration', type=str, default=None,
1094 help='Prepare PowerPoint recorded timings and narrations from a complete audio '
1095 'directory. Default-flow exports get the _narrated name suffix '
1096 '(<project>_<ts>_narrated.pptx) to tell them apart from silent exports.')
1097 parser.add_argument('--narration-padding', type=non_negative_float, default=0.5,
1098 help='Seconds to add after each narration before auto-advance (default: 0.5)')
1099 parser.add_argument(
1100 '--inherit-motion-from',
1101 type=str,
1102 default=None,
1103 metavar='BASE_POSTFLIGHT_REPORT',
1104 help=(
1105 'For recorded narration, inherit source-bound deck-wide transition, '
1106 'animation, and advance settings from a successful base export report'
1107 ),
1108 )
1109
1110 raw_argv = list(argv) if argv is not None else sys.argv[1:]
1111 legacy_native_objects = '--native-objects' in raw_argv
1112 args = parser.parse_args(raw_argv)
1113 if legacy_native_objects:
1114 print(
1115 'Warning: --native-objects is deprecated; use '
1116 '--native-charts-and-tables.',
1117 file=sys.stderr,
1118 )
1119 if args.animation_config is not None and not args.animation_config.strip():
1120 print(
1121 'Error: --animation-config must be a non-empty file path',
1122 file=sys.stderr,
1123 )
1124 return 1
1125 if args.inherit_motion_from and not args.recorded_narration:
1126 print(
1127 'Error: --inherit-motion-from requires --recorded-narration',
1128 file=sys.stderr,
1129 )
1130 return 1
1131 if args.inherit_motion_from and args.no_animations:
1132 print(
1133 'Error: --inherit-motion-from cannot be combined with --no-animations',
1134 file=sys.stderr,
1135 )
1136 return 1
1137
1138 if args.quick_generate:
1139 conflicts: list[str] = []
1140 if args.source not in {None, 'output'}:
1141 conflicts.append('--source must be omitted or output')
1142 if args.pptx_structure not in {None, 'flat'}:
1143 conflicts.append('--pptx-structure must be omitted or flat')
1144 if conflicts:
1145 print(
1146 "Error: --quick-generate cannot be combined with: "
1147 + ", ".join(conflicts),
1148 file=sys.stderr,
1149 )
1150 return 1
1151 if not args.with_notes:
1152 args.no_notes = True
1153 args.pptx_structure = 'flat'
1154
1155 project_path = Path(args.project_path)
1156 if not project_path.exists():
1157 print(f"Error: Path does not exist: {project_path}")
1158 return 1
1159
1160 structure_lock = None
1161 native_structure_contract = None
1162 pptx_structure = args.pptx_structure
1163 lock_path = project_path / 'spec_lock.md'
1164 if not args.quick_generate and not lock_path.is_file():
1165 print(
1166 "Error: spec_lock.md is required for release SVG export",
1167 file=sys.stderr,
1168 )
1169 return 1
1170 declared_structure_mode = (
1171 None
1172 if args.quick_generate
1173 else _declared_pptx_structure_mode(project_path)
1174 )
1175 primary_language = None
1176 if not args.quick_generate:
1177 try:
1178 primary_language = _declared_primary_language(project_path)
1179 except LanguageTagError as exc:
1180 print(f"Error: {exc}", file=sys.stderr)
1181 return 1
1182 if primary_language is None:
1183 print(
1184 "Warning: spec_lock.md has no "
1185 "communication.primary_language; using legacy per-run "
1186 "language detection.",
1187 file=sys.stderr,
1188 )
1189 if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
1190 _print_structure_contract_error(pptx_structure)
1191 return 1
1192 if (
1193 declared_structure_mode is not None
1194 and declared_structure_mode not in _RELEASE_PPTX_STRUCTURE_MODES
1195 ):
1196 _print_structure_contract_error(declared_structure_mode)
1197 return 1
1198 if pptx_structure is None:
1199 if declared_structure_mode is None:
1200 pptx_structure = 'flat'
1201 print(
1202 "Warning: spec_lock.md has no pptx_structure.mode; using flat "
1203 "compatibility mode.",
1204 file=sys.stderr,
1205 )
1206 else:
1207 pptx_structure = declared_structure_mode
1208 elif pptx_structure == 'structured' and declared_structure_mode != 'structured':
1209 _print_structure_contract_error(
1210 declared_structure_mode,
1211 requested_mode='structured',
1212 )
1213 return 1
1214
1215 if (
1216 pptx_structure in _RELEASE_PPTX_STRUCTURE_MODES
1217 and declared_structure_mode == pptx_structure
1218 ):
1219 try:
1220 structure_lock = load_pptx_structure_lock(project_path)
1221 except TemplateStructureError as exc:
1222 print(f"Error: {exc}", file=sys.stderr)
1223 return 1
1224 if structure_lock is None or structure_lock.mode != pptx_structure:
1225 print(
1226 "Error: spec_lock.md must contain one complete "
1227 f"pptx_structure.mode: {pptx_structure} contract",
1228 file=sys.stderr,
1229 )
1230 return 1
1231
1232 theme_font_spec = None
1233 master_text_style_spec = None
1234 theme_color_spec = None
1235 if pptx_structure in {'flat', 'structured'} and not args.quick_generate:
1236 try:
1237 theme_font_spec = load_theme_font_spec(project_path)
1238 master_text_style_spec = load_master_text_style_spec(project_path)
1239 theme_color_spec = load_theme_color_spec(project_path)
1240 except (ThemeFontError, ThemeColorError) as exc:
1241 print(f"Error: {exc}", file=sys.stderr)
1242 return 1
1243 missing_theme_fields = []
1244 if theme_font_spec is None:
1245 missing_theme_fields.append(
1246 'typography font_family/title_family/body_family'
1247 )
1248 if theme_color_spec is None:
1249 missing_theme_fields.append('colors')
1250 if missing_theme_fields:
1251 print(
1252 f"Error: {pptx_structure} export requires a current-project "
1253 "theme contract in spec_lock.md; missing: "
1254 + ", ".join(missing_theme_fields),
1255 file=sys.stderr,
1256 )
1257 return 1
1258 if args.image_max_dimension < 1:
1259 print("Error: --image-max-dimension must be >= 1", file=sys.stderr)
1260 return 1
1261 if args.image_scale < 1:
1262 print("Error: --image-scale must be >= 1", file=sys.stderr)
1263 return 1
1264 if not 1 <= args.image_quality <= 100:
1265 print("Error: --image-quality must be between 1 and 100", file=sys.stderr)
1266 return 1
1267
1268 try:
1269 project_info = get_project_info(str(project_path))
1270 project_name = project_info.get('name', project_path.name)
1271 except Exception:
1272 project_name = project_path.name
1273
1274 canvas_format = args.format
1275 expected_viewbox = (
1276 None
1277 if args.quick_generate
1278 else _declared_canvas_viewbox(project_path)
1279 )
1280 if expected_viewbox is None and not args.quick_generate:
1281 print(
1282 "Error: spec_lock.md must contain canvas.viewBox for release export",
1283 file=sys.stderr,
1284 )
1285 return 1
1286
1287 # Native DrawingML is the only PPTX product. A non-output ``-s`` remains a
1288 # diagnostic source override; default and explicit output use release rules.
1289 native_source = args.source or 'output'
1290 native_files, native_source_dir = find_svg_files(
1291 project_path,
1292 native_source,
1293 allow_fallback=args.source is None and not args.quick_generate,
1294 )
1295 ref_files = native_files
1296 if not native_files:
1297 if args.quick_generate:
1298 print(
1299 "Error: No SVG files found for --quick-generate in: "
1300 f"{project_path / 'svg_output'}",
1301 file=sys.stderr,
1302 )
1303 elif args.source is not None:
1304 requested_dir = project_path / native_source_dir
1305 print(
1306 "Error: No SVG files found in explicitly requested source: "
1307 f"{requested_dir}",
1308 file=sys.stderr,
1309 )
1310 else:
1311 print("Error: No SVG files found", file=sys.stderr)
1312 return 1
1313
1314 release_quality_gate = args.quick_generate or args.source in {None, 'output'}
1315 if release_quality_gate:
1316 source_fingerprint = _svg_source_fingerprint(native_files)
1317 quality = _quality_report_context(project_path, source_fingerprint)
1318 quality_gate, _ = _quality_gate_status(quality)
1319 if quality_gate != 'passed':
1320 export_mode = (
1321 '--quick-generate'
1322 if args.quick_generate
1323 else 'default release export'
1324 )
1325 print(
1326 f"Error: {export_mode} requires a passing final SVG quality "
1327 f"report for the current {native_source_dir}/; found "
1328 f"{quality_gate}.",
1329 file=sys.stderr,
1330 )
1331 quick_flag = ' --quick-generate' if args.quick_generate else ''
1332 print(
1333 "Run: python3 skills/ppt-master/scripts/svg_quality_checker.py "
1334 f'"{project_path}"{quick_flag} --stage final --json',
1335 file=sys.stderr,
1336 )
1337 return 1
1338
1339 # Compatibility kwargs remain until the builder's old baseline-specific
1340 # parameters are removed. Structured export never activates either path.
1341 structured_baseline = False
1342 baseline_layout_specs = None
1343 layout_definition_files: list[Path] = []
1344 if pptx_structure == 'structured' and structure_lock is not None:
1345 try:
1346 template_specs = parse_template_slides(native_files)
1347 except TemplateStructureError as exc:
1348 print(f"Error: {exc}", file=sys.stderr)
1349 return 1
1350 lock_errors = template_lock_errors(template_specs, structure_lock)
1351 if lock_errors:
1352 print("Error: PPTX structure does not match spec_lock.md:", file=sys.stderr)
1353 for message in lock_errors:
1354 print(f" {message}", file=sys.stderr)
1355 return 1
1356 try:
1357 layout_definition_files = structured_layout_definition_files(
1358 template_specs,
1359 structure_lock,
1360 )
1361 except TemplateStructureError as exc:
1362 print(f"Error: {exc}", file=sys.stderr)
1363 return 1
1364 prototype_errors = template_prototype_errors(
1365 template_specs,
1366 structure_lock,
1367 )
1368 if prototype_errors:
1369 print(
1370 "Error: structured template output does not match page_layouts "
1371 "prototypes:",
1372 file=sys.stderr,
1373 )
1374 for message in prototype_errors:
1375 print(f" {message}", file=sys.stderr)
1376 return 1
1377
1378 release_blocked = _release_blocked_graphics(native_files)
1379 if release_blocked:
1380 print(
1381 "Error: invalid PPTX graphic status metadata cannot enter an export. "
1382 "Correct the reported replacement/fallback/import-source attributes first.",
1383 file=sys.stderr,
1384 )
1385 for filename, marker_id, status in release_blocked[:20]:
1386 print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
1387 if len(release_blocked) > 20:
1388 print(
1389 f" ... and {len(release_blocked) - 20} more",
1390 file=sys.stderr,
1391 )
1392 return 1
1393
1394 reconstruction_only = _reconstruction_only_graphics(native_files)
1395 if reconstruction_only:
1396 print(
1397 "Warning: reconstruction-only PPTX chart placeholder(s) have no baked "
1398 "preview. Default export keeps the placeholder; "
1399 "--native-charts-and-tables "
1400 "reconstructs entries that carry a valid active replacement marker.",
1401 file=sys.stderr,
1402 )
1403 for filename, marker_id, active_native in reconstruction_only[:20]:
1404 route = (
1405 "active native Chart/Table replacement"
1406 if active_native else "placeholder fallback"
1407 )
1408 print(f" {filename}: {marker_id} ({route})", file=sys.stderr)
1409 if len(reconstruction_only) > 20:
1410 print(
1411 f" ... and {len(reconstruction_only) - 20} more",
1412 file=sys.stderr,
1413 )
1414
1415 if args.native_objects:
1416 print(
1417 "Warning: --native-charts-and-tables replaces shape-based SVG fallbacks "
1418 "with PowerPoint Chart/Table objects. The native objects may normalize "
1419 "styling or omit SVG details that are not represented by marker metadata; "
1420 "use the default shape-based export when exact fallback artwork is required.",
1421 file=sys.stderr,
1422 )
1423 fallbacks = _native_object_fallbacks(native_files)
1424 if fallbacks:
1425 print(
1426 "Warning: --native-charts-and-tables found fallback-only PPTX objects; "
1427 "they will export through their SVG-derived DrawingML shapes instead "
1428 "of native Chart/Table objects.",
1429 file=sys.stderr,
1430 )
1431 for filename, marker_id, status in fallbacks[:20]:
1432 print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
1433 if len(fallbacks) > 20:
1434 print(f" ... and {len(fallbacks) - 20} more", file=sys.stderr)
1435
1436 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1437
1438 backup_dir: Path | None = None
1439 if args.output:
1440 native_path = Path(args.output)
1441 else:
1442 exports_dir = project_path / "exports"
1443 exports_dir.mkdir(parents=True, exist_ok=True)
1444 # --native-charts-and-tables yields a materially different file (PowerPoint
1445 # Chart/Table objects instead of SVG-derived DrawingML shapes), so mark it
1446 # in the default-flow name to distinguish the two editable object models.
1447 # Narration flags likewise mark _narrated (audio embedded per slide +
1448 # auto-advance timings). Flag-driven (not content-sniffed) so the name
1449 # is predictable; an explicit -o keeps the caller's exact name untouched.
1450 native_tag = "_native_charts_tables" if args.native_objects else ""
1451 narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
1452 native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
1453 # Preserve the authored svg_output/ beside every default-path export.
1454 backup_dir = project_path / "backup" / timestamp
1455
1456 native_path.parent.mkdir(parents=True, exist_ok=True)
1457
1458 verbose = not args.quiet
1459
1460 enable_notes = not args.no_notes
1461 notes: dict[str, str] = {}
1462 if enable_notes:
1463 try:
1464 notes = find_notes_files(project_path, ref_files)
1465 except NotesFileReadError as exc:
1466 print(f"Error: {exc}", file=sys.stderr)
1467 return 1
1468
1469 narration_audio: dict[str, Path] = {}
1470 narration_audio_dir_arg = args.recorded_narration or args.narration_audio_dir
1471 use_narration_timings = args.use_narration_timings or bool(args.recorded_narration)
1472 if narration_audio_dir_arg:
1473 narration_audio_dir = Path(narration_audio_dir_arg)
1474 if not narration_audio_dir.is_absolute():
1475 narration_audio_dir = project_path / narration_audio_dir
1476 if args.recorded_narration and not narration_audio_dir.is_dir():
1477 print(
1478 f"Error: Recorded narration directory does not exist: {narration_audio_dir}",
1479 file=sys.stderr,
1480 )
1481 return 1
1482 try:
1483 narration_audio = find_narration_files(
1484 narration_audio_dir,
1485 ref_files,
1486 )
1487 except ValueError as exc:
1488 print(f"Error: {exc}", file=sys.stderr)
1489 return 1
1490 if verbose:
1491 print(f" Narration audio directory: {narration_audio_dir}")
1492 print(f" Narration audio matched: {len(narration_audio)}/{len(ref_files)} slide(s)")
1493 if args.recorded_narration:
1494 missing = [path.stem for path in ref_files if path.stem not in narration_audio]
1495 if missing:
1496 print(
1497 "Error: Recorded narration requires one supported audio file per slide. "
1498 f"Matched {len(narration_audio)}/{len(ref_files)} slide(s). "
1499 f"Supported extensions: {', '.join(NARRATION_EXTENSIONS)}",
1500 file=sys.stderr,
1501 )
1502 for stem in missing[:20]:
1503 print(f" Missing audio for: {stem}", file=sys.stderr)
1504 if len(missing) > 20:
1505 print(f" ... and {len(missing) - 20} more", file=sys.stderr)
1506 return 1
1507 unreadable = [
1508 f"{stem}: {audio_path}"
1509 for stem, audio_path in sorted(narration_audio.items())
1510 if probe_audio_duration(audio_path) is None
1511 ]
1512 if unreadable:
1513 print(
1514 "Error: Recorded narration requires readable audio durations. "
1515 "Install ffprobe/ffmpeg or replace the listed audio files.",
1516 file=sys.stderr,
1517 )
1518 for item in unreadable[:20]:
1519 print(f" {item}", file=sys.stderr)
1520 if len(unreadable) > 20:
1521 print(f" ... and {len(unreadable) - 20} more", file=sys.stderr)
1522 return 1
1523 elif narration_audio_dir_arg and verbose:
1524 missing = [path.stem for path in ref_files if path.stem not in narration_audio]
1525 if missing:
1526 print(
1527 f" [warn] Narration audio matched {len(narration_audio)}/{len(ref_files)} slide(s); "
1528 "unmatched slides will export without audio."
1529 )
1530
1531 if args.no_animations and any(
1532 value is not None
1533 for value in (
1534 args.transition,
1535 args.transition_duration,
1536 args.animation,
1537 args.animation_duration,
1538 args.animation_trigger,
1539 args.animation_stagger,
1540 )
1541 ):
1542 print(
1543 "Error: --no-animations cannot be combined with transition or "
1544 "object-animation overrides.",
1545 file=sys.stderr,
1546 )
1547 return 1
1548
1549 effective_animation_config = _resolve_animation_config_source(
1550 project_path,
1551 args.animation_config,
1552 recorded_narration=bool(args.recorded_narration),
1553 no_animations=args.no_animations,
1554 )
1555
1556 if effective_animation_config:
1557 config_path = Path(effective_animation_config)
1558 if not config_path.is_absolute():
1559 config_path = project_path / config_path
1560 if not config_path.exists():
1561 print(
1562 f"Error: Animation config does not exist: {config_path}",
1563 file=sys.stderr,
1564 )
1565 if (
1566 args.recorded_narration
1567 and args.animation_config is None
1568 and config_path.name == 'narration_animations.json'
1569 ):
1570 print(
1571 "Generate it with narration_sync.py animations, select the "
1572 "canonical config with --animation-config animations.json, "
1573 "or disable animations with --no-animations.",
1574 file=sys.stderr,
1575 )
1576 return 1
1577
1578 try:
1579 animation_config = (
1580 None
1581 if args.no_animations
1582 else load_animation_config(
1583 project_path,
1584 effective_animation_config,
1585 )
1586 )
1587 except Exception as exc:
1588 print(f"Error: Failed to load animation config: {exc}", file=sys.stderr)
1589 return 1
1590 config_errors: list[str] = []
1591 if animation_config:
1592 config_errors.extend(validate_transition_config(animation_config))
1593 config_errors.extend(validate_animation_config_errors(animation_config))
1594 config_errors = list(dict.fromkeys(config_errors))
1595 if config_errors:
1596 for error in config_errors:
1597 print(f"Error: {error}", file=sys.stderr)
1598 return 1
1599
1600 config_warnings: list[str] = []
1601 if animation_config:
1602 reference_messages = validate_animation_config(
1603 project_path,
1604 animation_config,
1605 svg_files=native_files,
1606 )
1607 config_warnings = [
1608 message for message in reference_messages
1609 if ' has no id and cannot be customized in animations.json' in message
1610 ]
1611 reference_errors = [
1612 message for message in reference_messages
1613 if message not in config_warnings
1614 ]
1615 if reference_errors:
1616 for error in reference_errors:
1617 print(f"Error: {error}", file=sys.stderr)
1618 return 1
1619
1620 if animation_config and verbose:
1621 config_label = (
1622 effective_animation_config
1623 or str(project_path / 'animations.json')
1624 )
1625 print(f" Animation config: {config_label}")
1626 for warning in config_warnings:
1627 print(f" [warn] {warning}")
1628 elif args.no_animations and verbose:
1629 print(" Animations: disabled")
1630
1631 inherited_motion: dict[str, object] = {}
1632 if args.inherit_motion_from:
1633 try:
1634 inherited_motion = _load_deck_motion_handoff(
1635 project_path,
1636 args.inherit_motion_from,
1637 native_files,
1638 )
1639 except ValueError as exc:
1640 print(f"Error: {exc}", file=sys.stderr)
1641 return 1
1642 if verbose:
1643 print(f" Deck motion handoff: {args.inherit_motion_from}")
1644
1645 defaults = animation_config.get('defaults', {}) if animation_config else {}
1646 transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
1647 animation_defaults = _as_dict(defaults.get('animation')) if isinstance(defaults, dict) else {}
1648 inherited_transition = _as_dict(inherited_motion.get('transition'))
1649 inherited_animation = _as_dict(inherited_motion.get('animation'))
1650 inherited_overrides = _as_dict(inherited_motion.get('cli_overrides'))
1651
1652 transition_arg = args.transition
1653 transition_effect = (
1654 'none'
1655 if args.no_animations
1656 else (
1657 transition_arg
1658 if transition_arg is not None
1659 else (
1660 inherited_transition['effect']
1661 if 'effect' in inherited_transition
1662 else transition_defaults.get('effect', 'fade')
1663 )
1664 )
1665 )
1666 try:
1667 transition, transition_effect_options = (
1668 normalize_transition_effect_request(
1669 transition_effect,
1670 (
1671 None
1672 if transition_arg is not None or args.no_animations
1673 else (
1674 inherited_transition.get('effect_options')
1675 if 'effect' in inherited_transition
1676 else transition_defaults.get('effect_options')
1677 )
1678 ),
1679 )
1680 )
1681 transition_duration = validate_seconds(
1682 (
1683 args.transition_duration
1684 if args.transition_duration is not None
1685 else (
1686 inherited_transition['duration']
1687 if 'duration' in inherited_transition
1688 else transition_defaults.get('duration', 0.4)
1689 )
1690 ),
1691 "transition duration",
1692 allow_zero=transition is None,
1693 )
1694 auto_advance = (
1695 args.auto_advance
1696 if args.auto_advance is not None
1697 else (
1698 inherited_transition['auto_advance']
1699 if 'auto_advance' in inherited_transition
1700 else transition_defaults.get('auto_advance')
1701 )
1702 )
1703 if auto_advance is not None:
1704 auto_advance = validate_seconds(
1705 auto_advance,
1706 "transition auto_advance",
1707 allow_zero=True,
1708 )
1709 except ValueError as exc:
1710 print(f"Error: {exc}", file=sys.stderr)
1711 return 1
1712
1713 try:
1714 animation_effect = (
1715 'none'
1716 if args.no_animations
1717 else (
1718 args.animation
1719 if args.animation is not None
1720 # Per-element object motion is opt-in by default: unsolicited
1721 # auto-firing builds read as the "AI deck" tell. Page transitions
1722 # stay on; enable objects with -a or animations.json.
1723 else (
1724 inherited_animation['effect_request']
1725 if 'effect_request' in inherited_animation
1726 else animation_defaults.get('effect', 'none')
1727 )
1728 )
1729 )
1730 normalized_animation = normalize_animation_effect(animation_effect)
1731 # Keep the raw request for the builder so legacy directional aliases
1732 # can desugar into canonical effect_options instead of losing their
1733 # direction during early CLI normalization.
1734 animation = (
1735 None
1736 if normalized_animation is None
1737 else str(animation_effect)
1738 )
1739 animation_duration = validate_seconds(
1740 (
1741 args.animation_duration
1742 if args.animation_duration is not None
1743 else (
1744 inherited_animation['duration']
1745 if 'duration' in inherited_animation
1746 else animation_defaults.get('duration', 0.4)
1747 )
1748 ),
1749 "animation duration",
1750 allow_zero=False,
1751 )
1752 animation_seconds_to_milliseconds(
1753 animation_duration,
1754 "animation duration",
1755 allow_zero=False,
1756 )
1757 animation_stagger = validate_seconds(
1758 (
1759 args.animation_stagger
1760 if args.animation_stagger is not None
1761 else (
1762 inherited_animation['stagger']
1763 if 'stagger' in inherited_animation
1764 else animation_defaults.get('stagger', 0.5)
1765 )
1766 ),
1767 "animation stagger",
1768 allow_zero=True,
1769 )
1770 animation_seconds_to_milliseconds(
1771 animation_stagger,
1772 "animation stagger",
1773 allow_zero=True,
1774 )
1775 animation_trigger = normalize_animation_trigger(
1776 args.animation_trigger
1777 if args.animation_trigger is not None
1778 else (
1779 inherited_animation['trigger']
1780 if 'trigger' in inherited_animation
1781 else animation_defaults.get('trigger', 'after-previous')
1782 )
1783 )
1784 except ValueError as exc:
1785 print(f"Error: {exc}", file=sys.stderr)
1786 return 1
1787
1788 animation_cli_overrides = {
1789 'transition': (
1790 args.transition is not None
1791 or inherited_overrides.get('transition') is True
1792 ),
1793 'transition_duration': (
1794 args.transition_duration is not None
1795 or inherited_overrides.get('transition_duration') is True
1796 ),
1797 'auto_advance': (
1798 args.auto_advance is not None
1799 or inherited_overrides.get('auto_advance') is True
1800 ),
1801 'animation': (
1802 args.animation is not None
1803 or inherited_overrides.get('animation') is True
1804 ),
1805 'animation_duration': (
1806 args.animation_duration is not None
1807 or inherited_overrides.get('animation_duration') is True
1808 ),
1809 'animation_stagger': (
1810 args.animation_stagger is not None
1811 or inherited_overrides.get('animation_stagger') is True
1812 ),
1813 'animation_trigger': (
1814 args.animation_trigger is not None
1815 or inherited_overrides.get('animation_trigger') is True
1816 ),
1817 }
1818
1819 deck_motion: dict[str, object] = {
1820 'transition': {
1821 'effect': transition,
1822 'effect_options': transition_effect_options,
1823 'duration': transition_duration,
1824 'auto_advance': auto_advance,
1825 },
1826 'animation': {
1827 'effect': normalized_animation or 'none',
1828 'effect_request': animation,
1829 'duration': animation_duration,
1830 'stagger': animation_stagger,
1831 'trigger': animation_trigger,
1832 },
1833 'cli_overrides': animation_cli_overrides,
1834 'narration_timings': use_narration_timings,
1835 }
1836
1837 if args.recorded_narration:
1838 on_click_slides = _recorded_narration_on_click_slides(
1839 ref_files,
1840 animation_config,
1841 animation,
1842 animation_trigger,
1843 animation_cli_overrides,
1844 )
1845 if on_click_slides:
1846 print(
1847 "Error: --recorded-narration cannot be used with on-click object animations. "
1848 "Use --animation-trigger after-previous or --animation-trigger with-previous.",
1849 file=sys.stderr,
1850 )
1851 for slide in on_click_slides[:20]:
1852 print(f" on-click trigger: {slide}", file=sys.stderr)
1853 if len(on_click_slides) > 20:
1854 print(f" ... and {len(on_click_slides) - 20} more", file=sys.stderr)
1855 return 1
1856
1857 # Optional per-project document properties. Absent file → factual fields
1858 # are still stamped at export; only the authored fields stay blank.
1859 doc_metadata = None
1860 metadata_path = project_path / 'metadata.json'
1861 if metadata_path.is_file():
1862 try:
1863 loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
1864 except (json.JSONDecodeError, OSError) as exc:
1865 print(f" [warn] metadata.json ignored ({exc})", file=sys.stderr)
1866 else:
1867 if isinstance(loaded, dict):
1868 doc_metadata = loaded
1869 if verbose:
1870 print(f" Document properties: metadata.json ({len(loaded)} field(s))")
1871 else:
1872 print(" [warn] metadata.json ignored (top level is not an object)", file=sys.stderr)
1873
1874 structure_name = project_name
1875 if isinstance(doc_metadata, dict):
1876 metadata_title = doc_metadata.get('title')
1877 if isinstance(metadata_title, str) and metadata_title.strip():
1878 structure_name = metadata_title
1879
1880 shared_kwargs = dict(
1881 canvas_format=canvas_format,
1882 expected_viewbox=expected_viewbox,
1883 doc_metadata=doc_metadata,
1884 structure_name=structure_name,
1885 verbose=verbose,
1886 transition=transition,
1887 transition_effect_options=transition_effect_options,
1888 transition_duration=transition_duration,
1889 auto_advance=auto_advance,
1890 notes=notes,
1891 enable_notes=enable_notes,
1892 animation=animation,
1893 animation_duration=animation_duration,
1894 animation_stagger=animation_stagger,
1895 animation_trigger=animation_trigger,
1896 animation_config=animation_config,
1897 animation_resource_root=project_path,
1898 animation_cli_overrides=animation_cli_overrides,
1899 narration_audio=narration_audio,
1900 use_narration_timings=use_narration_timings,
1901 narration_padding=args.narration_padding,
1902 text_flow=args.text_flow,
1903 image_optimize=not args.no_image_optimize,
1904 image_max_dimension=args.image_max_dimension,
1905 image_sizing=args.image_sizing,
1906 image_scale=args.image_scale,
1907 image_quality=args.image_quality,
1908 native_objects=args.native_objects,
1909 pptx_structure=pptx_structure,
1910 structured_baseline=structured_baseline,
1911 baseline_layout_specs=baseline_layout_specs,
1912 layout_definition_files=layout_definition_files,
1913 native_structure_contract=native_structure_contract,
1914 theme_font_spec=theme_font_spec,
1915 master_text_style_spec=master_text_style_spec,
1916 theme_color_spec=theme_color_spec,
1917 primary_language=primary_language,
1918 )
1919
1920 if verbose:
1921 print("PPT Master - SVG to native DrawingML PPTX Tool")
1922 print("=" * 50)
1923 print(f" Project path: {project_path}")
1924 print(f" SVG directory: {native_source_dir}")
1925 print(f" Output file: {native_path}")
1926 print()
1927
1928 conversion_trace_path: Path | None = None
1929 if args.conversion_trace is not None:
1930 if args.conversion_trace:
1931 requested_trace_path = Path(args.conversion_trace).expanduser()
1932 conversion_trace_path = (
1933 requested_trace_path
1934 if requested_trace_path.is_absolute()
1935 else project_path / requested_trace_path
1936 )
1937 else:
1938 conversion_trace_path = (
1939 project_path / 'validation' / f'{native_path.stem}.trace.json'
1940 )
1941 try:
1942 success = create_pptx_with_native_svg(
1943 output_path=native_path,
1944 use_native_shapes=True,
1945 svg_files=native_files,
1946 conversion_trace_path=conversion_trace_path,
1947 **shared_kwargs,
1948 )
1949 except (TemplateStructureError, ValueError) as exc:
1950 print(f"Error: {exc}", file=sys.stderr)
1951 return 1
1952
1953 # Archive svg_output/ once per default-flow export. This preserves the
1954 # authored SVG sources under backup/<ts>/svg_output/ for inspection and
1955 # deterministic re-export.
1956 backup_path: Path | None = None
1957 if success and backup_dir is not None:
1958 svg_output_src = project_path / "svg_output"
1959 if svg_output_src.is_dir():
1960 backup_dir.mkdir(parents=True, exist_ok=True)
1961 svg_output_dst = backup_dir / "svg_output"
1962 try:
1963 shutil.copytree(svg_output_src, svg_output_dst)
1964 except Exception as exc:
1965 if verbose:
1966 print(f" [warn] svg_output backup skipped: {exc}")
1967 else:
1968 backup_path = svg_output_dst
1969 if verbose:
1970 print(f" svg_output backup: {svg_output_dst}")
1971 payload_store_src = project_path / PAYLOAD_STORE_RELATIVE_PATH
1972 if payload_store_src.is_file():
1973 try:
1974 payload_store_dst = backup_dir / PAYLOAD_STORE_RELATIVE_PATH
1975 payload_store_dst.parent.mkdir(parents=True, exist_ok=True)
1976 shutil.copy2(payload_store_src, payload_store_dst)
1977 if verbose:
1978 print(f" native payload backup: {payload_store_dst}")
1979 except Exception as exc:
1980 if verbose:
1981 print(f" [warn] native payload backup skipped: {exc}")
1982 elif verbose:
1983 print(f" [info] svg_output/ not found, backup skipped")
1984
1985 if success:
1986 try:
1987 receipt = _write_postflight_report(
1988 output_path=native_path,
1989 project_path=project_path,
1990 svg_files=native_files,
1991 layout_definition_files=layout_definition_files,
1992 pptx_structure=pptx_structure,
1993 backup_path=backup_path,
1994 conversion_trace_path=conversion_trace_path,
1995 deck_motion=deck_motion,
1996 )
1997 except PptxPostflightValidationError as exc:
1998 print(
1999 "Error: generated PPTX failed postflight validation and must "
2000 f"not be used: {exc}",
2001 file=sys.stderr,
2002 )
2003 print(
2004 f" Invalid output remains at: {native_path}",
2005 file=sys.stderr,
2006 )
2007 return 1
2008 except OSError as exc:
2009 print(
2010 "Error: PPTX generation succeeded, but its postflight report "
2011 f"could not be written: {exc}",
2012 file=sys.stderr,
2013 )
2014 print(f" PPTX output remains at: {native_path}", file=sys.stderr)
2015 return 1
2016 if verbose:
2017 _print_postflight_receipt(receipt)
2018
2019 return 0 if success else 1
2020
2021
2022 if __name__ == '__main__':
2023 raise SystemExit(main())
2024
2024 lines PYTHON