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