2062 lines
80 KiB
Python
2062 lines
80 KiB
Python
"""CLI entry point for svg_to_pptx."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree as ET
|
|
|
|
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
from attribution_guard import require_skill_integrity # noqa: E402
|
|
from console_encoding import configure_utf8_stdio # noqa: E402
|
|
from language_tags import ( # noqa: E402
|
|
LanguageTagError,
|
|
normalize_language_tag,
|
|
)
|
|
from native_payloads import PAYLOAD_STORE_RELATIVE_PATH # noqa: E402
|
|
from pptx_animations import ( # noqa: E402
|
|
ANIMATIONS,
|
|
animation_seconds_to_milliseconds,
|
|
normalize_animation_effect,
|
|
normalize_animation_trigger,
|
|
)
|
|
from pptx_transitions import ( # noqa: E402
|
|
LEGACY_TRANSITION_KEYS,
|
|
NATIVE_TRANSITION_KEYS,
|
|
normalize_transition_effect_request,
|
|
validate_seconds,
|
|
)
|
|
|
|
configure_utf8_stdio()
|
|
|
|
if __package__ in {None, ''}:
|
|
import types
|
|
|
|
package = types.ModuleType('svg_to_pptx')
|
|
package.__path__ = [str(Path(__file__).resolve().parent)] # type: ignore[attr-defined]
|
|
sys.modules.setdefault('svg_to_pptx', package)
|
|
__package__ = 'svg_to_pptx'
|
|
|
|
from .dimensions import CANVAS_FORMATS, get_project_info
|
|
from .discovery import NotesFileReadError, find_notes_files, find_svg_files
|
|
from .builder import create_pptx_with_native_svg
|
|
from ..native_objects import (
|
|
native_fallback_kind,
|
|
native_replacement_kind,
|
|
native_replacement_status,
|
|
)
|
|
from ..native_objects.marker_status import native_marker_release_block_reason
|
|
from ..drawingml.theme_colors import ThemeColorError, load_theme_color_spec
|
|
from ..drawingml.context import (
|
|
TEXT_FLOW_PRESERVE,
|
|
TEXT_FLOW_REFLOW,
|
|
TEXT_FLOW_SPLIT,
|
|
)
|
|
from ..drawingml.theme_fonts import (
|
|
ThemeFontError,
|
|
load_master_text_style_spec,
|
|
load_theme_font_spec,
|
|
)
|
|
from ..drawingml.utils import unsafe_exported_font_faces
|
|
from .narration import (
|
|
DEFAULT_NARRATION_START_FLOOR,
|
|
NARRATION_EXTENSIONS,
|
|
find_narration_files,
|
|
probe_audio_duration,
|
|
)
|
|
from .template_structure import (
|
|
TemplateStructureError,
|
|
load_pptx_structure_lock,
|
|
parse_template_slides,
|
|
structured_layout_definition_files,
|
|
template_lock_errors,
|
|
template_prototype_errors,
|
|
)
|
|
from ..animation_config import (
|
|
animation_group_effect_entries,
|
|
load_animation_config,
|
|
validate_animation_config,
|
|
validate_animation_config_errors,
|
|
validate_transition_config,
|
|
)
|
|
|
|
|
|
def _as_dict(value: object) -> dict:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
_PPTX_STRUCTURE_SECTION_RE = re.compile(
|
|
r"(?ms)^##[ \t]+pptx_structure[ \t]*\r?\n(.*?)(?=^##[ \t]+|\Z)"
|
|
)
|
|
_PPTX_STRUCTURE_MODE_RE = re.compile(
|
|
r"(?m)^-[ \t]+mode[ \t]*:[ \t]*([^#\r\n]*?)[ \t]*(?:#.*)?$"
|
|
)
|
|
_LEGACY_PPTX_STRUCTURE_MODES = frozenset({
|
|
'baseline',
|
|
'generated',
|
|
'preserve',
|
|
'template',
|
|
})
|
|
_RELEASE_PPTX_STRUCTURE_MODES = frozenset({'flat', 'structured'})
|
|
_CSS_GENERIC_FONT_FAMILIES = frozenset({
|
|
'cursive',
|
|
'emoji',
|
|
'fangsong',
|
|
'fantasy',
|
|
'math',
|
|
'monospace',
|
|
'sans-serif',
|
|
'serif',
|
|
'system-ui',
|
|
'ui-monospace',
|
|
'ui-rounded',
|
|
'ui-sans-serif',
|
|
'ui-serif',
|
|
})
|
|
|
|
|
|
class PptxPostflightValidationError(RuntimeError):
|
|
"""Reject a generated PPTX that fails package postflight validation."""
|
|
|
|
|
|
@dataclass
|
|
class _PostflightReceipt:
|
|
"""Carry the compact export result printed after the audit is written."""
|
|
|
|
output_path: Path
|
|
report_path: Path
|
|
status: str
|
|
quality_gate: str
|
|
slide_count: int
|
|
warnings: tuple[str, ...]
|
|
|
|
|
|
def _font_stack_is_generic_only(stack: str) -> bool:
|
|
"""Return whether a CSS font stack contains no concrete family name."""
|
|
families = [
|
|
family.strip().strip('"\'').strip().lower()
|
|
for family in stack.split(',')
|
|
if family.strip().strip('"\'').strip()
|
|
]
|
|
return bool(families) and all(
|
|
family in _CSS_GENERIC_FONT_FAMILIES
|
|
for family in families
|
|
)
|
|
|
|
|
|
def _package_part_counts(pptx_path: Path) -> dict[str, object]:
|
|
"""Count public and structural OOXML parts in a completed PPTX package."""
|
|
with zipfile.ZipFile(pptx_path) as archive:
|
|
bad_member = archive.testzip()
|
|
names = archive.namelist()
|
|
|
|
def count(pattern: str) -> int:
|
|
matcher = re.compile(pattern)
|
|
return sum(bool(matcher.fullmatch(name)) for name in names)
|
|
|
|
return {
|
|
'zip_integrity': 'passed' if bad_member is None else 'failed',
|
|
'corrupt_member': bad_member,
|
|
'slides': count(r'ppt/slides/slide\d+\.xml'),
|
|
'notes': count(r'ppt/notesSlides/notesSlide\d+\.xml'),
|
|
'masters': count(r'ppt/slideMasters/slideMaster\d+\.xml'),
|
|
'layouts': count(r'ppt/slideLayouts/slideLayout\d+\.xml'),
|
|
}
|
|
|
|
|
|
def _source_resource_audit(svg_files: list[Path]) -> dict[str, object]:
|
|
"""Collect unresolved tokens and portability-oriented source inventories."""
|
|
placeholder_re = re.compile(r'\{\{[^{}]+\}\}')
|
|
placeholders: list[dict[str, str]] = []
|
|
font_stacks: set[str] = set()
|
|
image_counts = {
|
|
'data_uri': 0,
|
|
'local': 0,
|
|
'external': 0,
|
|
}
|
|
external_images: list[dict[str, str]] = []
|
|
for svg_path in svg_files:
|
|
try:
|
|
content = svg_path.read_text(encoding='utf-8')
|
|
root = ET.fromstring(content)
|
|
except (OSError, ET.ParseError):
|
|
continue
|
|
for token in sorted(set(placeholder_re.findall(content))):
|
|
placeholders.append({'file': svg_path.name, 'token': token})
|
|
for element in root.iter():
|
|
font_family = element.get('font-family')
|
|
if font_family:
|
|
font_stacks.add(font_family.strip())
|
|
style = element.get('style') or ''
|
|
for declaration in style.split(';'):
|
|
if ':' not in declaration:
|
|
continue
|
|
name, value = declaration.split(':', 1)
|
|
if name.strip().lower() == 'font-family' and value.strip():
|
|
font_stacks.add(value.strip())
|
|
if element.tag.rsplit('}', 1)[-1] != 'image':
|
|
continue
|
|
href = (
|
|
element.get('href')
|
|
or element.get('{http://www.w3.org/1999/xlink}href')
|
|
or ''
|
|
).strip()
|
|
if href.startswith('data:'):
|
|
image_counts['data_uri'] += 1
|
|
elif re.match(r'^[a-z][a-z0-9+.-]*://', href, re.IGNORECASE):
|
|
image_counts['external'] += 1
|
|
external_images.append({
|
|
'file': svg_path.name,
|
|
'href': href,
|
|
})
|
|
elif href:
|
|
image_counts['local'] += 1
|
|
generic_only_font_stacks = sorted({
|
|
stack
|
|
for stack in font_stacks
|
|
if _font_stack_is_generic_only(stack)
|
|
})
|
|
unsafe_font_faces = [
|
|
{
|
|
'stack': stack,
|
|
'role': role,
|
|
'typeface': typeface,
|
|
}
|
|
for stack in sorted(font_stacks)
|
|
for role, typeface in unsafe_exported_font_faces(stack).items()
|
|
]
|
|
return {
|
|
'unresolved_template_tokens': placeholders,
|
|
'fonts': {
|
|
'stacks': sorted(font_stacks),
|
|
'generic_only_stacks': generic_only_font_stacks,
|
|
'unsafe_exported_faces': unsafe_font_faces,
|
|
},
|
|
'images': {
|
|
**image_counts,
|
|
'external_references': external_images,
|
|
},
|
|
}
|
|
|
|
|
|
def _svg_source_fingerprint(svg_files: list[Path]) -> dict[str, object]:
|
|
"""Return one deterministic digest for the exact SVG export inputs."""
|
|
files: list[dict[str, object]] = []
|
|
aggregate = hashlib.sha256()
|
|
for path in sorted(svg_files, key=lambda item: item.name):
|
|
file_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
files.append({'file': path.name, 'sha256': file_sha256})
|
|
aggregate.update(path.name.encode('utf-8'))
|
|
aggregate.update(b'\0')
|
|
aggregate.update(file_sha256.encode('ascii'))
|
|
aggregate.update(b'\n')
|
|
return {
|
|
'algorithm': 'sha256',
|
|
'digest': aggregate.hexdigest(),
|
|
'file_count': len(files),
|
|
'files': files,
|
|
}
|
|
|
|
|
|
def _quality_report_context(
|
|
project_path: Path,
|
|
source_fingerprint: dict[str, object],
|
|
) -> dict[str, object]:
|
|
"""Load the final SVG quality report when the preceding gate wrote one."""
|
|
quality_path = project_path / 'validation' / 'svg_quality_report.json'
|
|
try:
|
|
quality = json.loads(quality_path.read_text(encoding='utf-8'))
|
|
except FileNotFoundError:
|
|
return {'status': 'not-provided', 'path': str(quality_path)}
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return {
|
|
'status': 'unreadable',
|
|
'path': str(quality_path),
|
|
'error': str(exc),
|
|
}
|
|
schema = quality.get('schema')
|
|
if schema != 'ppt-master.svg-quality-report.v1':
|
|
return {
|
|
'status': 'unsupported-schema',
|
|
'path': str(quality_path),
|
|
'schema': schema,
|
|
}
|
|
categories = quality.get('categories')
|
|
quality_fingerprint = quality.get('source_fingerprint')
|
|
if not isinstance(quality_fingerprint, dict):
|
|
source_match = 'unavailable'
|
|
elif (
|
|
quality_fingerprint.get('algorithm') == 'sha256'
|
|
and quality_fingerprint.get('digest') == source_fingerprint.get('digest')
|
|
and quality_fingerprint.get('file_count')
|
|
== source_fingerprint.get('file_count')
|
|
):
|
|
source_match = 'passed'
|
|
else:
|
|
source_match = 'mismatch'
|
|
return {
|
|
'status': 'loaded',
|
|
'path': str(quality_path),
|
|
'schema': schema,
|
|
'stage': quality.get('stage'),
|
|
'source_match': source_match,
|
|
'source_fingerprint': quality_fingerprint,
|
|
'summary': quality.get('summary'),
|
|
'categories': categories if isinstance(categories, dict) else {},
|
|
}
|
|
|
|
|
|
def _quality_gate_status(
|
|
quality: dict[str, object],
|
|
) -> tuple[str, int]:
|
|
"""Return final-gate status and introduced-warning count."""
|
|
categories = quality.get('categories')
|
|
blocking_count = None
|
|
introduced_warning_count = 0
|
|
if isinstance(categories, dict):
|
|
blocking = categories.get('blocking')
|
|
if isinstance(blocking, dict):
|
|
blocking_count = blocking.get('count')
|
|
introduced = categories.get('introduced')
|
|
if (
|
|
isinstance(introduced, dict)
|
|
and isinstance(introduced.get('count'), int)
|
|
):
|
|
introduced_warning_count = int(introduced['count'])
|
|
if quality.get('status') != 'loaded':
|
|
return str(quality.get('status') or 'not-provided'), introduced_warning_count
|
|
if quality.get('stage') != 'final':
|
|
return 'non-final', introduced_warning_count
|
|
if not isinstance(blocking_count, int):
|
|
return 'unverified', introduced_warning_count
|
|
if blocking_count > 0:
|
|
return 'failed', introduced_warning_count
|
|
if quality.get('source_match') == 'mismatch':
|
|
return 'stale', introduced_warning_count
|
|
if quality.get('source_match') != 'passed':
|
|
return 'unverified', introduced_warning_count
|
|
return 'passed', introduced_warning_count
|
|
|
|
|
|
def _postflight_warning_summaries(
|
|
*,
|
|
quality_gate: str,
|
|
introduced_warning_count: int,
|
|
unresolved_token_count: int,
|
|
external_image_count: int,
|
|
generic_font_stack_count: int,
|
|
unsafe_font_face_count: int,
|
|
) -> tuple[str, ...]:
|
|
"""Return stable warning summaries for the terminal receipt."""
|
|
warnings: list[str] = []
|
|
if quality_gate != 'passed':
|
|
warnings.append(f'quality_gate={quality_gate}')
|
|
if introduced_warning_count:
|
|
warnings.append(f'quality_introduced_warnings={introduced_warning_count}')
|
|
if unresolved_token_count:
|
|
warnings.append(f'unresolved_template_tokens={unresolved_token_count}')
|
|
if external_image_count:
|
|
warnings.append(f'external_images={external_image_count}')
|
|
if generic_font_stack_count:
|
|
warnings.append(f'generic_only_font_stacks={generic_font_stack_count}')
|
|
if unsafe_font_face_count:
|
|
warnings.append(f'unsafe_exported_font_faces={unsafe_font_face_count}')
|
|
return tuple(warnings)
|
|
|
|
|
|
def _write_postflight_report(
|
|
*,
|
|
output_path: Path,
|
|
project_path: Path,
|
|
svg_files: list[Path],
|
|
layout_definition_files: list[Path],
|
|
pptx_structure: str,
|
|
backup_path: Path | None,
|
|
conversion_trace_path: Path | None,
|
|
deck_motion: dict[str, object],
|
|
) -> _PostflightReceipt:
|
|
"""Write the unified package/resource audit for a successful PPTX."""
|
|
try:
|
|
package = _package_part_counts(output_path)
|
|
except (OSError, zipfile.BadZipFile) as exc:
|
|
raise PptxPostflightValidationError(
|
|
f"generated PPTX is not a readable ZIP package: {exc}"
|
|
) from exc
|
|
if package['zip_integrity'] != 'passed':
|
|
raise PptxPostflightValidationError(
|
|
f"PPTX ZIP integrity failed at {package['corrupt_member']}"
|
|
)
|
|
if package['slides'] != len(svg_files):
|
|
raise PptxPostflightValidationError(
|
|
"Published Slide count does not match authored SVG count: "
|
|
f"{package['slides']} != {len(svg_files)}"
|
|
)
|
|
source_audit = _source_resource_audit(svg_files)
|
|
source_fingerprint = _svg_source_fingerprint(svg_files)
|
|
quality = _quality_report_context(project_path, source_fingerprint)
|
|
quality_gate, introduced_warning_count = _quality_gate_status(quality)
|
|
unresolved_tokens = source_audit['unresolved_template_tokens']
|
|
external_image_count = source_audit['images']['external']
|
|
generic_only_font_stacks = source_audit['fonts']['generic_only_stacks']
|
|
unsafe_font_faces = source_audit['fonts']['unsafe_exported_faces']
|
|
if quality_gate == 'failed':
|
|
report_status = 'failed'
|
|
elif (
|
|
not unresolved_tokens
|
|
and not external_image_count
|
|
and not generic_only_font_stacks
|
|
and not unsafe_font_faces
|
|
and not introduced_warning_count
|
|
and quality_gate == 'passed'
|
|
):
|
|
report_status = 'passed'
|
|
else:
|
|
report_status = 'passed-with-warnings'
|
|
report_path = (
|
|
project_path / 'validation' / f'{output_path.stem}.report.json'
|
|
)
|
|
report = {
|
|
'schema': 'ppt-master.pptx-postflight-report.v1',
|
|
'status': report_status,
|
|
'output': {
|
|
'path': str(output_path.resolve()),
|
|
'bytes': output_path.stat().st_size,
|
|
},
|
|
'source': {
|
|
'svg_slide_count': len(svg_files),
|
|
'layout_definition_count': len(layout_definition_files),
|
|
'fingerprint': source_fingerprint,
|
|
},
|
|
'package': package,
|
|
'checks': {
|
|
'zip_integrity': 'passed',
|
|
'slide_count': 'passed',
|
|
'internal_relationships': 'enforced-at-build',
|
|
'structured_package': (
|
|
'enforced-at-build'
|
|
if pptx_structure == 'structured'
|
|
else 'not-applicable'
|
|
),
|
|
'transitions': 'enforced-at-build',
|
|
'animations': 'enforced-at-build',
|
|
'quality_gate': quality_gate,
|
|
'quality_warnings': (
|
|
'passed' if not introduced_warning_count else 'warning'
|
|
),
|
|
'template_tokens': (
|
|
'passed' if not unresolved_tokens else 'warning'
|
|
),
|
|
'external_images': (
|
|
'passed' if not external_image_count else 'warning'
|
|
),
|
|
'font_portability': (
|
|
'passed'
|
|
if not generic_only_font_stacks and not unsafe_font_faces
|
|
else 'warning'
|
|
),
|
|
},
|
|
'quality': quality,
|
|
'resources': source_audit,
|
|
'deck_motion': deck_motion,
|
|
'backup_path': str(backup_path.resolve()) if backup_path else None,
|
|
'conversion_trace_path': (
|
|
str(conversion_trace_path.resolve())
|
|
if conversion_trace_path and conversion_trace_path.is_file()
|
|
else None
|
|
),
|
|
}
|
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
report_path.write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + '\n',
|
|
encoding='utf-8',
|
|
)
|
|
warnings = _postflight_warning_summaries(
|
|
quality_gate=quality_gate,
|
|
introduced_warning_count=introduced_warning_count,
|
|
unresolved_token_count=len(unresolved_tokens),
|
|
external_image_count=external_image_count,
|
|
generic_font_stack_count=len(generic_only_font_stacks),
|
|
unsafe_font_face_count=len(unsafe_font_faces),
|
|
)
|
|
return _PostflightReceipt(
|
|
output_path=output_path,
|
|
report_path=report_path,
|
|
status=report_status,
|
|
quality_gate=quality_gate,
|
|
slide_count=int(package['slides']),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def _load_deck_motion_handoff(
|
|
project_path: Path,
|
|
report_arg: str,
|
|
svg_files: list[Path],
|
|
) -> dict[str, object]:
|
|
"""Load source-bound deck motion from a successful base export report."""
|
|
report_path = Path(report_arg).expanduser()
|
|
if not report_path.is_absolute() and not report_path.is_file():
|
|
report_path = project_path / report_path
|
|
try:
|
|
report = json.loads(report_path.read_text(encoding='utf-8'))
|
|
except FileNotFoundError as exc:
|
|
raise ValueError(
|
|
f'deck-motion handoff report does not exist: {report_path}'
|
|
) from exc
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise ValueError(
|
|
f'deck-motion handoff report is unreadable: {report_path}: {exc}'
|
|
) from exc
|
|
if not isinstance(report, dict):
|
|
raise ValueError('deck-motion handoff report must be a JSON object')
|
|
if report.get('schema') != 'ppt-master.pptx-postflight-report.v1':
|
|
raise ValueError(
|
|
'deck-motion handoff requires a ppt-master postflight report'
|
|
)
|
|
if report.get('status') not in {'passed', 'passed-with-warnings'}:
|
|
raise ValueError('deck-motion handoff report is not a successful export')
|
|
source = _as_dict(report.get('source'))
|
|
if source.get('fingerprint') != _svg_source_fingerprint(svg_files):
|
|
raise ValueError(
|
|
'deck-motion handoff does not match the current svg_output; '
|
|
'run the base export again'
|
|
)
|
|
motion = report.get('deck_motion')
|
|
if not isinstance(motion, dict):
|
|
raise ValueError(
|
|
'deck-motion handoff is missing from the base export report; '
|
|
'run the base export again'
|
|
)
|
|
if motion.get('narration_timings') is True:
|
|
raise ValueError(
|
|
'deck-motion handoff must reference a base non-narrated export report'
|
|
)
|
|
if not isinstance(motion.get('transition'), dict):
|
|
raise ValueError('deck-motion handoff transition must be an object')
|
|
if not isinstance(motion.get('animation'), dict):
|
|
raise ValueError('deck-motion handoff animation must be an object')
|
|
if not isinstance(motion.get('cli_overrides'), dict):
|
|
raise ValueError('deck-motion handoff cli_overrides must be an object')
|
|
return motion
|
|
|
|
|
|
def _print_postflight_receipt(receipt: _PostflightReceipt) -> None:
|
|
"""Print the compact completion evidence; keep the full JSON on disk."""
|
|
print(
|
|
' [POSTFLIGHT] '
|
|
f'status={receipt.status} '
|
|
f'quality_gate={receipt.quality_gate} '
|
|
f'slides={receipt.slide_count} '
|
|
f'warning_categories={len(receipt.warnings)}'
|
|
)
|
|
for warning in receipt.warnings:
|
|
print(f' [POSTFLIGHT][WARNING] {warning}')
|
|
print(f' [PPTX] {receipt.output_path}')
|
|
print(f' [REPORT] {receipt.report_path}')
|
|
|
|
|
|
def _declared_pptx_structure_mode(project_path: Path) -> str | None:
|
|
"""Return the explicitly locked SVG export mode, if the lock declares one."""
|
|
lock_path = project_path / 'spec_lock.md'
|
|
try:
|
|
content = lock_path.read_text(encoding='utf-8')
|
|
except OSError:
|
|
return None
|
|
section_match = _PPTX_STRUCTURE_SECTION_RE.search(content)
|
|
if section_match is None:
|
|
return None
|
|
mode_match = _PPTX_STRUCTURE_MODE_RE.search(section_match.group(1))
|
|
return mode_match.group(1).strip().lower() if mode_match else None
|
|
|
|
|
|
def _declared_canvas_viewbox(project_path: Path) -> str | None:
|
|
"""Return the project-lock root canvas without inferring from its name."""
|
|
lock_path = project_path / 'spec_lock.md'
|
|
try:
|
|
from update_spec import parse_lock
|
|
|
|
lock = parse_lock(lock_path)
|
|
except (OSError, ValueError):
|
|
return None
|
|
canvas = lock.get('canvas', {})
|
|
value = canvas.get('viewBox')
|
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
|
|
|
|
def _declared_primary_language(project_path: Path) -> str | None:
|
|
"""Return the canonical content language declared by the execution lock."""
|
|
lock_path = project_path / 'spec_lock.md'
|
|
try:
|
|
from update_spec import parse_lock
|
|
|
|
lock = parse_lock(lock_path)
|
|
except (OSError, ValueError):
|
|
return None
|
|
communication = lock.get('communication', {})
|
|
value = communication.get('primary_language')
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None
|
|
try:
|
|
return normalize_language_tag(value)
|
|
except LanguageTagError as exc:
|
|
raise LanguageTagError(
|
|
'spec_lock.md communication.primary_language '
|
|
f'is invalid: {exc}'
|
|
) from exc
|
|
|
|
|
|
def _print_structure_contract_error(
|
|
mode: str | None,
|
|
*,
|
|
requested_mode: str | None = None,
|
|
) -> None:
|
|
"""Explain an unsupported mode or a structured-export lock mismatch."""
|
|
label = repr(mode) if mode is not None else 'missing'
|
|
if requested_mode == 'structured':
|
|
print(
|
|
"Error: --pptx-structure structured requires an explicit "
|
|
"spec_lock.md pptx_structure.mode: structured contract; found "
|
|
+ label + ".",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" A legacy lock without pptx_structure.mode defaults only to flat. "
|
|
"Mirror/layout reuse must first create a current template workspace "
|
|
"through skills/ppt-master/workflows/create-template.md, then generate "
|
|
"new structured SVG pages.",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
print(
|
|
"Error: unsupported spec_lock.md pptx_structure.mode " + label + ". "
|
|
"Current release modes are flat (style reference / free design / "
|
|
"brand-only) and structured (mirror/layout reuse).",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
" A legacy lock with no pptx_structure.mode defaults to flat. "
|
|
"Explicit legacy or unknown values are not inferred. Mirror/layout reuse "
|
|
"must first create a current template workspace "
|
|
"through skills/ppt-master/workflows/create-template.md, then generate "
|
|
"new structured SVG pages.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _native_object_fallbacks(svg_files: list[Path]) -> list[tuple[str, str, str]]:
|
|
"""Return fallback-only chart/table replacement statuses from SVG inputs."""
|
|
fallbacks: list[tuple[str, str, str]] = []
|
|
for svg_path in svg_files:
|
|
try:
|
|
root = ET.parse(svg_path).getroot()
|
|
except (OSError, ET.ParseError):
|
|
continue
|
|
for elem in root.iter():
|
|
status = native_replacement_status(elem)
|
|
if not status or elem.tag.rsplit('}', 1)[-1] == 'metadata':
|
|
continue
|
|
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
|
|
fallbacks.append((svg_path.name, marker_id, status))
|
|
return fallbacks
|
|
|
|
|
|
def _release_blocked_graphics(
|
|
svg_files: list[Path],
|
|
) -> list[tuple[str, str, str]]:
|
|
"""Return graphics whose status metadata is invalid."""
|
|
blocked: list[tuple[str, str, str]] = []
|
|
for svg_path in svg_files:
|
|
try:
|
|
root = ET.parse(svg_path).getroot()
|
|
except (OSError, ET.ParseError):
|
|
continue
|
|
for elem in root.iter():
|
|
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
|
|
continue
|
|
reason = native_marker_release_block_reason(elem)
|
|
if reason is None:
|
|
continue
|
|
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
|
|
blocked.append((svg_path.name, marker_id, reason))
|
|
return blocked
|
|
|
|
|
|
def _reconstruction_only_graphics(
|
|
svg_files: list[Path],
|
|
) -> list[tuple[str, str, bool]]:
|
|
"""Return valid placeholder routes for non-blocking diagnostics."""
|
|
diagnostics: list[tuple[str, str, bool]] = []
|
|
for svg_path in svg_files:
|
|
try:
|
|
root = ET.parse(svg_path).getroot()
|
|
except (OSError, ET.ParseError):
|
|
continue
|
|
for elem in root.iter():
|
|
if elem.tag.rsplit('}', 1)[-1] == 'metadata':
|
|
continue
|
|
if native_fallback_kind(elem) != 'placeholder':
|
|
continue
|
|
if native_marker_release_block_reason(elem) is not None:
|
|
continue
|
|
marker_id = elem.get('id') or elem.get('data-name') or '<unnamed>'
|
|
active_native = bool(native_replacement_kind(elem))
|
|
diagnostics.append((svg_path.name, marker_id, active_native))
|
|
return diagnostics
|
|
|
|
|
|
def _recorded_narration_on_click_slides(
|
|
ref_files: list[Path],
|
|
animation_config: dict | None,
|
|
animation: str | None,
|
|
animation_trigger: str,
|
|
animation_cli_overrides: dict[str, bool],
|
|
) -> list[str]:
|
|
"""Return slides whose effective recorded-video animation trigger is on-click."""
|
|
if animation_cli_overrides.get('animation') and animation is None:
|
|
return []
|
|
slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
|
|
blocked: list[str] = []
|
|
for svg_path in ref_files:
|
|
slide_cfg = _as_dict(slides_cfg.get(svg_path.stem))
|
|
anim_cfg = _as_dict(slide_cfg.get('animation'))
|
|
|
|
slide_animation = animation
|
|
if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
|
|
slide_animation = normalize_animation_effect(anim_cfg.get('effect'))
|
|
slide_trigger = animation_trigger
|
|
if (
|
|
not animation_cli_overrides.get('animation_trigger')
|
|
and anim_cfg.get('trigger')
|
|
):
|
|
slide_trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
|
|
|
|
groups_cfg = _as_dict(slide_cfg.get('groups'))
|
|
has_interactive_animation = False
|
|
for group_id, group_cfg in groups_cfg.items():
|
|
if not isinstance(group_cfg, dict):
|
|
continue
|
|
group_path = (
|
|
f'slides[{json.dumps(svg_path.stem, ensure_ascii=False)}]'
|
|
f'.groups[{json.dumps(str(group_id), ensure_ascii=False)}]'
|
|
)
|
|
for _effect_path, effect_cfg in animation_group_effect_entries(
|
|
group_cfg,
|
|
path=group_path,
|
|
):
|
|
row_effect = (
|
|
normalize_animation_effect(effect_cfg.get('effect'))
|
|
if 'effect' in effect_cfg
|
|
else slide_animation
|
|
)
|
|
if row_effect is None:
|
|
continue
|
|
row_trigger = (
|
|
normalize_animation_trigger(effect_cfg.get('trigger'))
|
|
if effect_cfg.get('trigger')
|
|
else slide_trigger
|
|
)
|
|
if effect_cfg.get('trigger_shape') is not None:
|
|
has_interactive_animation = True
|
|
break
|
|
if row_trigger == 'on-click':
|
|
has_interactive_animation = True
|
|
break
|
|
if has_interactive_animation:
|
|
break
|
|
|
|
if has_interactive_animation or (
|
|
slide_animation is not None and slide_trigger == 'on-click'
|
|
):
|
|
blocked.append(svg_path.stem)
|
|
return blocked
|
|
|
|
|
|
def _resolve_animation_config_source(
|
|
project_path: Path,
|
|
requested_config: str | None,
|
|
*,
|
|
recorded_narration: bool,
|
|
no_animations: bool,
|
|
) -> str | None:
|
|
"""Resolve the animation sidecar selected for this export."""
|
|
if requested_config is not None or not recorded_narration or no_animations:
|
|
return requested_config
|
|
|
|
canonical_exists = (project_path / 'animations.json').is_file()
|
|
narration_exists = (project_path / 'narration_animations.json').is_file()
|
|
if canonical_exists or narration_exists:
|
|
return 'narration_animations.json'
|
|
return None
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""CLI entry point for the SVG to PPTX conversion tool."""
|
|
require_skill_integrity()
|
|
transition_choices = [
|
|
'none',
|
|
*NATIVE_TRANSITION_KEYS,
|
|
*LEGACY_TRANSITION_KEYS,
|
|
]
|
|
|
|
animation_choices = ['none', *ANIMATIONS, 'auto', 'mixed', 'random']
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='PPT Master - SVG to native DrawingML PPTX Tool',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=f'''
|
|
Examples:
|
|
%(prog)s examples/ppt169_demo # Default: native pptx -> exports/, svg_output -> backup/<ts>/
|
|
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
|
|
%(prog)s projects/quick_generate_demo --quick-generate # Lockless flat export with normal postflight
|
|
|
|
# Disable transition / change transition effect
|
|
%(prog)s examples/ppt169_demo -t none
|
|
%(prog)s examples/ppt169_demo -t push --transition-duration 1.0
|
|
|
|
SVG source directory (-s):
|
|
output - svg_output (hand-authored source; native default)
|
|
final - svg_final (post-processed preview; diagnostic native input only)
|
|
<any> - Specify a subdirectory name directly
|
|
Omit -s to use the default: native export reads svg_output.
|
|
|
|
Transition effects (-t/--transition):
|
|
New selections use the 48 PowerPoint-native gallery keys. The 8 old
|
|
names remain accepted only as compatibility inputs. Run
|
|
scripts/pptx_animations.py --list for the categorized registry and
|
|
--describe-transition <effect> for its Effect Options.
|
|
|
|
Per-element object animation (-a/--animation, native shapes mode):
|
|
Use PowerPoint-native entrance_*, emphasis_*, path_*, and exit_* keys for
|
|
new animation choices. The 29 old short names remain accepted only as
|
|
compatibility inputs. Run scripts/pptx_animations.py --list for the
|
|
complete categorized 232-key input registry.
|
|
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
|
|
"none" (no auto element builds; page transitions still apply). Use
|
|
"-a auto" to map effects from group id: chart→entrance_wipe,
|
|
card-/step-/pillar-→entrance_fly,
|
|
title/takeaway→entrance_fade; image-like ids
|
|
hero/figure-/image/img-/kpi cycle canonical entrance presets;
|
|
unmatched ids cycle entrance_fade/entrance_wipe/entrance_fly/
|
|
entrance_zoom. Start mode set by --animation-trigger, matching
|
|
PowerPoint's Start dropdown:
|
|
on-click one presenter click per group
|
|
with-previous all groups start together on slide entry
|
|
after-previous (default) cascade on slide entry;
|
|
gap = --animation-stagger seconds
|
|
mixed (compatible mode name) cycles a larger 16-preset canonical
|
|
PowerPoint entrance pool by group order; random samples from the
|
|
same entrance pool. Use explicit canonical keys for emphasis,
|
|
motion-path, or exit duties. Use "-a none" to disable element
|
|
builds explicitly.
|
|
|
|
Speaker notes:
|
|
- Automatically reads Markdown notes files from the notes/ directory
|
|
- Supports two naming conventions:
|
|
1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
|
|
2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
|
|
- Enabled by default outside Quick Generate; use --no-notes to disable
|
|
- Disabled by default in Quick Generate; use --with-notes to enable
|
|
|
|
Recorded narration:
|
|
%(prog)s examples/ppt169_demo --recorded-narration audio \\
|
|
--inherit-motion-from validation/<base>.report.json
|
|
- Keeps speaker notes when enabled
|
|
- Prepares PowerPoint recorded timings and narrations
|
|
- Requires one m4a/mp3/wav file per slide
|
|
- Unless --no-animations is set, recorded narration without an explicit
|
|
config selects narration_animations.json when either sidecar exists
|
|
- With no sidecar, --inherit-motion-from may restore base-report deck motion
|
|
- Use --animation-config animations.json for narration-independent custom motion
|
|
- Use --no-animations for narration and timings without animation motion
|
|
- Embeds per-slide audio matched by SVG filename / slide number
|
|
- Sets slide auto-advance from audio duration so video export can use
|
|
"recorded timings and narrations"
|
|
- Rejects on-click object animations; use after-previous or with-previous
|
|
%(prog)s examples/ppt169_demo --narration-audio-dir audio
|
|
- Lower-level audio embedding: embeds matched files but allows partial matches
|
|
- Use only when you do not need a complete recorded-timings export
|
|
''',
|
|
)
|
|
|
|
parser.add_argument('project_path', type=str, help='Project directory path')
|
|
parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
|
|
parser.add_argument('-s', '--source', type=str, default=None,
|
|
help='Native SVG source directory. Default: svg_output/. '
|
|
'Pass output/final/<name> only for diagnostics.')
|
|
parser.add_argument('-f', '--format', type=str,
|
|
choices=list(CANVAS_FORMATS.keys()), default=None,
|
|
help='Require SVG canvases to match this registered format')
|
|
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
|
parser.add_argument(
|
|
'--quick-generate',
|
|
action='store_true',
|
|
help=(
|
|
'Export a Quick Generate SVG roster from svg_output/ without '
|
|
'spec_lock.md. Require a matching final quality report, infer one '
|
|
'consistent canvas, use a flat package with converter defaults, '
|
|
'and support normal export capabilities.'
|
|
),
|
|
)
|
|
|
|
text_flow_group = parser.add_mutually_exclusive_group()
|
|
text_flow_group.add_argument(
|
|
'--reflow-text',
|
|
action='store_const',
|
|
const=TEXT_FLOW_REFLOW,
|
|
dest='text_flow',
|
|
help=(
|
|
'Let PowerPoint automatically reflow conservative dy-stacked text '
|
|
'inside one editable text frame.'
|
|
),
|
|
)
|
|
text_flow_group.add_argument(
|
|
'--merge-paragraphs',
|
|
action='store_const',
|
|
const=TEXT_FLOW_REFLOW,
|
|
dest='text_flow',
|
|
help='Compatibility alias for --reflow-text.',
|
|
)
|
|
text_flow_group.add_argument(
|
|
'--no-merge',
|
|
action='store_const',
|
|
const=TEXT_FLOW_SPLIT,
|
|
dest='text_flow',
|
|
help=(
|
|
'Emit every positioned visual line as its own text frame for '
|
|
'strict per-line SVG positioning.'
|
|
),
|
|
)
|
|
parser.set_defaults(text_flow=TEXT_FLOW_PRESERVE)
|
|
parser.add_argument(
|
|
'--conversion-trace',
|
|
nargs='?',
|
|
const='',
|
|
default=None,
|
|
metavar='PATH',
|
|
help='Write per-slide SVG conversion diagnostics. Without PATH, write '
|
|
'<project>/validation/<output_stem>.trace.json; relative PATHs '
|
|
'are resolved from the project root.',
|
|
)
|
|
parser.add_argument(
|
|
'--native-charts-and-tables',
|
|
dest='native_objects',
|
|
action='store_true',
|
|
default=False,
|
|
help=(
|
|
'Replace explicit data-pptx-replace-with chart/table groups with '
|
|
'PowerPoint native Chart/Table objects. This data-object route may '
|
|
'normalize styling or omit fallback-only visuals. Default off: groups '
|
|
'export as editable SVG-derived DrawingML shapes. The default-flow '
|
|
'output uses <project>_<ts>_native_charts_tables.pptx.'
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
'--native-objects',
|
|
dest='native_objects',
|
|
action='store_true',
|
|
help=argparse.SUPPRESS,
|
|
)
|
|
parser.add_argument(
|
|
'--pptx-structure',
|
|
choices=[
|
|
'structured',
|
|
'flat',
|
|
'baseline',
|
|
'template',
|
|
'preserve',
|
|
'generated',
|
|
],
|
|
default=None,
|
|
help=(
|
|
'PPTX structure strategy for native export. Omitting this flag reads '
|
|
'spec_lock.md; a legacy lock without pptx_structure.mode defaults to '
|
|
'flat. Flat is the style-reference/free-design/brand-only release mode and '
|
|
'builds one clean project-owned Master plus Blank Layout while keeping '
|
|
'all SVG objects slide-local; structured is the mirror/layout reuse '
|
|
'mode and requires complete explicit metadata. baseline, template, '
|
|
'preserve, and generated are accepted only to report a migration error.'
|
|
),
|
|
)
|
|
parser.add_argument('--no-image-optimize', action='store_true',
|
|
help='Disable native PPTX raster image optimization and always embed '
|
|
'the original image bytes.')
|
|
parser.add_argument('--image-max-dimension', type=int, default=2560,
|
|
help='Preferred raster cap in pixels. Cap mode re-encodes only images '
|
|
'that require resizing or EXIF geometry normalization, and may '
|
|
'retain more pixels for cropped/stretched visible resolution '
|
|
'(default: 2560).')
|
|
parser.add_argument('--image-sizing', choices=['cap', 'display'], default='cap',
|
|
help='Raster sizing mode: cap preserves original bytes unless resizing '
|
|
'or EXIF geometry normalization is required; display targets the '
|
|
'SVG rendered box for explicit compaction (default: cap).')
|
|
parser.add_argument('--image-scale', type=float, default=2.0,
|
|
help='Target optimized image pixels per SVG display pixel '
|
|
'when --image-sizing=display (default: 2.0).')
|
|
parser.add_argument('--image-quality', type=int, default=85,
|
|
help='JPEG quality for raster images re-encoded during optimization, '
|
|
'1-100 (default: 85).')
|
|
|
|
def non_negative_float(value: str) -> float:
|
|
try:
|
|
number = float(value)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
|
|
if not math.isfinite(number):
|
|
raise argparse.ArgumentTypeError("must be finite")
|
|
if number < 0:
|
|
raise argparse.ArgumentTypeError("must be non-negative")
|
|
return number
|
|
|
|
def positive_float(value: str) -> float:
|
|
number = non_negative_float(value)
|
|
if number <= 0:
|
|
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
return number
|
|
|
|
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
|
|
help='Page transition effect (default: fade; "none" removes visual motion)')
|
|
parser.add_argument('--transition-duration', type=non_negative_float, default=None,
|
|
help='Transition duration in seconds (default: 0.4)')
|
|
parser.add_argument('--auto-advance', type=non_negative_float, default=None,
|
|
help='Auto-advance interval in seconds (default: manual advance)')
|
|
|
|
parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
|
|
default=None,
|
|
help='Per-element object animation (native shapes mode '
|
|
'only). Default "none" (no auto element builds; page '
|
|
'transitions still apply). Pick a native entrance_*/'
|
|
'emphasis_*/path_*/exit_* key or "auto" '
|
|
'(map effect from group id — image-like ids cycle a '
|
|
'richer canonical pool for visual variation, fallback '
|
|
'cycles entrance_fade/entrance_wipe/entrance_fly/'
|
|
'entrance_zoom), "mixed" (canonical 16-preset entrance '
|
|
'pool), or "random" (the same entrance pool). Use '
|
|
'explicit keys for emphasis/path/exit. Legacy short '
|
|
'names remain accepted only for compatibility.')
|
|
parser.add_argument('--animation-duration', type=positive_float, default=None,
|
|
help='Per-element object-animation duration in seconds '
|
|
'(default: 0.4; instantaneous native presets keep their '
|
|
'PowerPoint-authored duration)')
|
|
parser.add_argument('--animation-trigger', type=str,
|
|
choices=['on-click', 'with-previous', 'after-previous'],
|
|
default=None,
|
|
help='Per-element Start mode (matches PowerPoint Start dropdown): '
|
|
'"on-click" (one click per element), '
|
|
'"with-previous" (all start together on slide entry), '
|
|
'"after-previous" (default, cascade after the previous element).')
|
|
parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
|
|
help='Delay between elements in --animation-trigger=after-previous '
|
|
'(seconds, default 0.5). Ignored in other modes.')
|
|
animation_source = parser.add_mutually_exclusive_group()
|
|
animation_source.add_argument(
|
|
'--animation-config',
|
|
type=str,
|
|
default=None,
|
|
help=(
|
|
'Per-slide/per-object animation config. While motion remains enabled, '
|
|
'recorded narration without an explicit config selects '
|
|
'<project>/narration_animations.json when either animation sidecar '
|
|
'exists, or may inherit base postflight motion with '
|
|
'--inherit-motion-from when neither exists. '
|
|
'Other exports default to <project>/animations.json when present.'
|
|
),
|
|
)
|
|
animation_source.add_argument(
|
|
'--no-animations',
|
|
action='store_true',
|
|
help=(
|
|
'Export without object animations or page-transition motion. '
|
|
'Narration audio and slide advance timings are preserved.'
|
|
),
|
|
)
|
|
|
|
notes_mode = parser.add_mutually_exclusive_group()
|
|
notes_mode.add_argument(
|
|
'--with-notes',
|
|
action='store_true',
|
|
help='Embed speaker notes. Required to opt in during Quick Generate.',
|
|
)
|
|
notes_mode.add_argument(
|
|
'--no-notes',
|
|
action='store_true',
|
|
help='Disable speaker notes embedding (enabled by default outside Quick Generate)',
|
|
)
|
|
parser.add_argument('--narration-audio-dir', type=str, default=None,
|
|
help='Low-level audio embedding from this directory; allows partial matches. '
|
|
'Default-flow exports get the _narrated name suffix.')
|
|
parser.add_argument('--use-narration-timings', action='store_true',
|
|
help='Set slide auto-advance timings from narration audio durations')
|
|
parser.add_argument('--recorded-narration', type=str, default=None,
|
|
help='Prepare PowerPoint recorded timings and narrations from a complete audio '
|
|
'directory. Default-flow exports get the _narrated name suffix '
|
|
'(<project>_<ts>_narrated.pptx) to tell them apart from silent exports.')
|
|
parser.add_argument('--narration-padding', type=non_negative_float, default=0.5,
|
|
help='Seconds to add after each narration before auto-advance (default: 0.5)')
|
|
parser.add_argument(
|
|
'--narration-start-floor',
|
|
type=non_negative_float,
|
|
default=DEFAULT_NARRATION_START_FLOOR,
|
|
help=(
|
|
'Minimum seconds from slide-transition start to narration start; '
|
|
'set 0 to start immediately after the transition '
|
|
f'(default: {DEFAULT_NARRATION_START_FLOOR})'
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
'--inherit-motion-from',
|
|
type=str,
|
|
default=None,
|
|
metavar='BASE_POSTFLIGHT_REPORT',
|
|
help=(
|
|
'For recorded narration, inherit source-bound deck-wide transition, '
|
|
'animation, and advance settings from a successful base export report'
|
|
),
|
|
)
|
|
|
|
raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
|
legacy_native_objects = '--native-objects' in raw_argv
|
|
args = parser.parse_args(raw_argv)
|
|
if legacy_native_objects:
|
|
print(
|
|
'Warning: --native-objects is deprecated; use '
|
|
'--native-charts-and-tables.',
|
|
file=sys.stderr,
|
|
)
|
|
if args.animation_config is not None and not args.animation_config.strip():
|
|
print(
|
|
'Error: --animation-config must be a non-empty file path',
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if args.inherit_motion_from and not args.recorded_narration:
|
|
print(
|
|
'Error: --inherit-motion-from requires --recorded-narration',
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if args.inherit_motion_from and args.no_animations:
|
|
print(
|
|
'Error: --inherit-motion-from cannot be combined with --no-animations',
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
if args.quick_generate:
|
|
conflicts: list[str] = []
|
|
if args.source not in {None, 'output'}:
|
|
conflicts.append('--source must be omitted or output')
|
|
if args.pptx_structure not in {None, 'flat'}:
|
|
conflicts.append('--pptx-structure must be omitted or flat')
|
|
if conflicts:
|
|
print(
|
|
"Error: --quick-generate cannot be combined with: "
|
|
+ ", ".join(conflicts),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if not args.with_notes:
|
|
args.no_notes = True
|
|
args.pptx_structure = 'flat'
|
|
|
|
project_path = Path(args.project_path)
|
|
if not project_path.exists():
|
|
print(f"Error: Path does not exist: {project_path}")
|
|
return 1
|
|
|
|
structure_lock = None
|
|
native_structure_contract = None
|
|
pptx_structure = args.pptx_structure
|
|
lock_path = project_path / 'spec_lock.md'
|
|
if not args.quick_generate and not lock_path.is_file():
|
|
print(
|
|
"Error: spec_lock.md is required for release SVG export",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
declared_structure_mode = (
|
|
None
|
|
if args.quick_generate
|
|
else _declared_pptx_structure_mode(project_path)
|
|
)
|
|
primary_language = None
|
|
if not args.quick_generate:
|
|
try:
|
|
primary_language = _declared_primary_language(project_path)
|
|
except LanguageTagError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if primary_language is None:
|
|
print(
|
|
"Warning: spec_lock.md has no "
|
|
"communication.primary_language; using legacy per-run "
|
|
"language detection.",
|
|
file=sys.stderr,
|
|
)
|
|
if pptx_structure in _LEGACY_PPTX_STRUCTURE_MODES:
|
|
_print_structure_contract_error(pptx_structure)
|
|
return 1
|
|
if (
|
|
declared_structure_mode is not None
|
|
and declared_structure_mode not in _RELEASE_PPTX_STRUCTURE_MODES
|
|
):
|
|
_print_structure_contract_error(declared_structure_mode)
|
|
return 1
|
|
if pptx_structure is None:
|
|
if declared_structure_mode is None:
|
|
pptx_structure = 'flat'
|
|
print(
|
|
"Warning: spec_lock.md has no pptx_structure.mode; using flat "
|
|
"compatibility mode.",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
pptx_structure = declared_structure_mode
|
|
elif pptx_structure == 'structured' and declared_structure_mode != 'structured':
|
|
_print_structure_contract_error(
|
|
declared_structure_mode,
|
|
requested_mode='structured',
|
|
)
|
|
return 1
|
|
|
|
if (
|
|
pptx_structure in _RELEASE_PPTX_STRUCTURE_MODES
|
|
and declared_structure_mode == pptx_structure
|
|
):
|
|
try:
|
|
structure_lock = load_pptx_structure_lock(project_path)
|
|
except TemplateStructureError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if structure_lock is None or structure_lock.mode != pptx_structure:
|
|
print(
|
|
"Error: spec_lock.md must contain one complete "
|
|
f"pptx_structure.mode: {pptx_structure} contract",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
theme_font_spec = None
|
|
master_text_style_spec = None
|
|
theme_color_spec = None
|
|
if pptx_structure in {'flat', 'structured'} and not args.quick_generate:
|
|
try:
|
|
theme_font_spec = load_theme_font_spec(project_path)
|
|
master_text_style_spec = load_master_text_style_spec(project_path)
|
|
theme_color_spec = load_theme_color_spec(project_path)
|
|
except (ThemeFontError, ThemeColorError) as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
missing_theme_fields = []
|
|
if theme_font_spec is None:
|
|
missing_theme_fields.append(
|
|
'typography font_family/title_family/body_family'
|
|
)
|
|
if theme_color_spec is None:
|
|
missing_theme_fields.append('colors')
|
|
if missing_theme_fields:
|
|
print(
|
|
f"Error: {pptx_structure} export requires a current-project "
|
|
"theme contract in spec_lock.md; missing: "
|
|
+ ", ".join(missing_theme_fields),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
if args.image_max_dimension < 1:
|
|
print("Error: --image-max-dimension must be >= 1", file=sys.stderr)
|
|
return 1
|
|
if args.image_scale < 1:
|
|
print("Error: --image-scale must be >= 1", file=sys.stderr)
|
|
return 1
|
|
if not 1 <= args.image_quality <= 100:
|
|
print("Error: --image-quality must be between 1 and 100", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
project_info = get_project_info(str(project_path))
|
|
project_name = project_info.get('name', project_path.name)
|
|
except Exception:
|
|
project_name = project_path.name
|
|
|
|
canvas_format = args.format
|
|
expected_viewbox = (
|
|
None
|
|
if args.quick_generate
|
|
else _declared_canvas_viewbox(project_path)
|
|
)
|
|
if expected_viewbox is None and not args.quick_generate:
|
|
print(
|
|
"Error: spec_lock.md must contain canvas.viewBox for release export",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# Native DrawingML is the only PPTX product. A non-output ``-s`` remains a
|
|
# diagnostic source override; default and explicit output use release rules.
|
|
native_source = args.source or 'output'
|
|
native_files, native_source_dir = find_svg_files(
|
|
project_path,
|
|
native_source,
|
|
allow_fallback=args.source is None and not args.quick_generate,
|
|
)
|
|
ref_files = native_files
|
|
if not native_files:
|
|
if args.quick_generate:
|
|
print(
|
|
"Error: No SVG files found for --quick-generate in: "
|
|
f"{project_path / 'svg_output'}",
|
|
file=sys.stderr,
|
|
)
|
|
elif args.source is not None:
|
|
requested_dir = project_path / native_source_dir
|
|
print(
|
|
"Error: No SVG files found in explicitly requested source: "
|
|
f"{requested_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print("Error: No SVG files found", file=sys.stderr)
|
|
return 1
|
|
|
|
release_quality_gate = args.quick_generate or args.source in {None, 'output'}
|
|
if release_quality_gate:
|
|
source_fingerprint = _svg_source_fingerprint(native_files)
|
|
quality = _quality_report_context(project_path, source_fingerprint)
|
|
quality_gate, _ = _quality_gate_status(quality)
|
|
if quality_gate != 'passed':
|
|
export_mode = (
|
|
'--quick-generate'
|
|
if args.quick_generate
|
|
else 'default release export'
|
|
)
|
|
print(
|
|
f"Error: {export_mode} requires a passing final SVG quality "
|
|
f"report for the current {native_source_dir}/; found "
|
|
f"{quality_gate}.",
|
|
file=sys.stderr,
|
|
)
|
|
quick_flag = ' --quick-generate' if args.quick_generate else ''
|
|
print(
|
|
"Run: python3 skills/ppt-master/scripts/svg_quality_checker.py "
|
|
f'"{project_path}"{quick_flag} --stage final --json',
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# Compatibility kwargs remain until the builder's old baseline-specific
|
|
# parameters are removed. Structured export never activates either path.
|
|
structured_baseline = False
|
|
baseline_layout_specs = None
|
|
layout_definition_files: list[Path] = []
|
|
if pptx_structure == 'structured' and structure_lock is not None:
|
|
try:
|
|
template_specs = parse_template_slides(native_files)
|
|
except TemplateStructureError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
lock_errors = template_lock_errors(template_specs, structure_lock)
|
|
if lock_errors:
|
|
print("Error: PPTX structure does not match spec_lock.md:", file=sys.stderr)
|
|
for message in lock_errors:
|
|
print(f" {message}", file=sys.stderr)
|
|
return 1
|
|
try:
|
|
layout_definition_files = structured_layout_definition_files(
|
|
template_specs,
|
|
structure_lock,
|
|
)
|
|
except TemplateStructureError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
prototype_errors = template_prototype_errors(
|
|
template_specs,
|
|
structure_lock,
|
|
)
|
|
if prototype_errors:
|
|
print(
|
|
"Error: structured template output does not match page_layouts "
|
|
"prototypes:",
|
|
file=sys.stderr,
|
|
)
|
|
for message in prototype_errors:
|
|
print(f" {message}", file=sys.stderr)
|
|
return 1
|
|
|
|
release_blocked = _release_blocked_graphics(native_files)
|
|
if release_blocked:
|
|
print(
|
|
"Error: invalid PPTX graphic status metadata cannot enter an export. "
|
|
"Correct the reported replacement/fallback/import-source attributes first.",
|
|
file=sys.stderr,
|
|
)
|
|
for filename, marker_id, status in release_blocked[:20]:
|
|
print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
|
|
if len(release_blocked) > 20:
|
|
print(
|
|
f" ... and {len(release_blocked) - 20} more",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
reconstruction_only = _reconstruction_only_graphics(native_files)
|
|
if reconstruction_only:
|
|
print(
|
|
"Warning: reconstruction-only PPTX chart placeholder(s) have no baked "
|
|
"preview. Default export keeps the placeholder; "
|
|
"--native-charts-and-tables "
|
|
"reconstructs entries that carry a valid active replacement marker.",
|
|
file=sys.stderr,
|
|
)
|
|
for filename, marker_id, active_native in reconstruction_only[:20]:
|
|
route = (
|
|
"active native Chart/Table replacement"
|
|
if active_native else "placeholder fallback"
|
|
)
|
|
print(f" {filename}: {marker_id} ({route})", file=sys.stderr)
|
|
if len(reconstruction_only) > 20:
|
|
print(
|
|
f" ... and {len(reconstruction_only) - 20} more",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
if args.native_objects:
|
|
print(
|
|
"Warning: --native-charts-and-tables replaces shape-based SVG fallbacks "
|
|
"with PowerPoint Chart/Table objects. The native objects may normalize "
|
|
"styling or omit SVG details that are not represented by marker metadata; "
|
|
"use the default shape-based export when exact fallback artwork is required.",
|
|
file=sys.stderr,
|
|
)
|
|
fallbacks = _native_object_fallbacks(native_files)
|
|
if fallbacks:
|
|
print(
|
|
"Warning: --native-charts-and-tables found fallback-only PPTX objects; "
|
|
"they will export through their SVG-derived DrawingML shapes instead "
|
|
"of native Chart/Table objects.",
|
|
file=sys.stderr,
|
|
)
|
|
for filename, marker_id, status in fallbacks[:20]:
|
|
print(f" {filename}: {marker_id} ({status})", file=sys.stderr)
|
|
if len(fallbacks) > 20:
|
|
print(f" ... and {len(fallbacks) - 20} more", file=sys.stderr)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
backup_dir: Path | None = None
|
|
if args.output:
|
|
native_path = Path(args.output)
|
|
else:
|
|
exports_dir = project_path / "exports"
|
|
exports_dir.mkdir(parents=True, exist_ok=True)
|
|
# --native-charts-and-tables yields a materially different file (PowerPoint
|
|
# Chart/Table objects instead of SVG-derived DrawingML shapes), so mark it
|
|
# in the default-flow name to distinguish the two editable object models.
|
|
# Narration flags likewise mark _narrated (audio embedded per slide +
|
|
# auto-advance timings). Flag-driven (not content-sniffed) so the name
|
|
# is predictable; an explicit -o keeps the caller's exact name untouched.
|
|
native_tag = "_native_charts_tables" if args.native_objects else ""
|
|
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
|
|
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
|
|
# Preserve the authored svg_output/ beside every default-path export.
|
|
backup_dir = project_path / "backup" / timestamp
|
|
|
|
native_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
verbose = not args.quiet
|
|
|
|
enable_notes = not args.no_notes
|
|
notes: dict[str, str] = {}
|
|
if enable_notes:
|
|
try:
|
|
notes = find_notes_files(project_path, ref_files)
|
|
except NotesFileReadError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
narration_audio: dict[str, Path] = {}
|
|
narration_audio_dir_arg = args.recorded_narration or args.narration_audio_dir
|
|
use_narration_timings = args.use_narration_timings or bool(args.recorded_narration)
|
|
if narration_audio_dir_arg:
|
|
narration_audio_dir = Path(narration_audio_dir_arg)
|
|
if not narration_audio_dir.is_absolute():
|
|
narration_audio_dir = project_path / narration_audio_dir
|
|
if args.recorded_narration and not narration_audio_dir.is_dir():
|
|
print(
|
|
f"Error: Recorded narration directory does not exist: {narration_audio_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
try:
|
|
narration_audio = find_narration_files(
|
|
narration_audio_dir,
|
|
ref_files,
|
|
)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if verbose:
|
|
print(f" Narration audio directory: {narration_audio_dir}")
|
|
print(f" Narration audio matched: {len(narration_audio)}/{len(ref_files)} slide(s)")
|
|
if args.recorded_narration:
|
|
missing = [path.stem for path in ref_files if path.stem not in narration_audio]
|
|
if missing:
|
|
print(
|
|
"Error: Recorded narration requires one supported audio file per slide. "
|
|
f"Matched {len(narration_audio)}/{len(ref_files)} slide(s). "
|
|
f"Supported extensions: {', '.join(NARRATION_EXTENSIONS)}",
|
|
file=sys.stderr,
|
|
)
|
|
for stem in missing[:20]:
|
|
print(f" Missing audio for: {stem}", file=sys.stderr)
|
|
if len(missing) > 20:
|
|
print(f" ... and {len(missing) - 20} more", file=sys.stderr)
|
|
return 1
|
|
unreadable = [
|
|
f"{stem}: {audio_path}"
|
|
for stem, audio_path in sorted(narration_audio.items())
|
|
if probe_audio_duration(audio_path) is None
|
|
]
|
|
if unreadable:
|
|
print(
|
|
"Error: Recorded narration requires readable audio durations. "
|
|
"Install ffprobe/ffmpeg or replace the listed audio files.",
|
|
file=sys.stderr,
|
|
)
|
|
for item in unreadable[:20]:
|
|
print(f" {item}", file=sys.stderr)
|
|
if len(unreadable) > 20:
|
|
print(f" ... and {len(unreadable) - 20} more", file=sys.stderr)
|
|
return 1
|
|
elif narration_audio_dir_arg and verbose:
|
|
missing = [path.stem for path in ref_files if path.stem not in narration_audio]
|
|
if missing:
|
|
print(
|
|
f" [warn] Narration audio matched {len(narration_audio)}/{len(ref_files)} slide(s); "
|
|
"unmatched slides will export without audio."
|
|
)
|
|
|
|
if args.no_animations and any(
|
|
value is not None
|
|
for value in (
|
|
args.transition,
|
|
args.transition_duration,
|
|
args.animation,
|
|
args.animation_duration,
|
|
args.animation_trigger,
|
|
args.animation_stagger,
|
|
)
|
|
):
|
|
print(
|
|
"Error: --no-animations cannot be combined with transition or "
|
|
"object-animation overrides.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
effective_animation_config = _resolve_animation_config_source(
|
|
project_path,
|
|
args.animation_config,
|
|
recorded_narration=bool(args.recorded_narration),
|
|
no_animations=args.no_animations,
|
|
)
|
|
|
|
if effective_animation_config:
|
|
config_path = Path(effective_animation_config)
|
|
if not config_path.is_absolute():
|
|
config_path = project_path / config_path
|
|
if not config_path.exists():
|
|
print(
|
|
f"Error: Animation config does not exist: {config_path}",
|
|
file=sys.stderr,
|
|
)
|
|
if (
|
|
args.recorded_narration
|
|
and args.animation_config is None
|
|
and config_path.name == 'narration_animations.json'
|
|
):
|
|
print(
|
|
"Generate it with narration_sync.py animations, select the "
|
|
"canonical config with --animation-config animations.json, "
|
|
"or disable animations with --no-animations.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
try:
|
|
animation_config = (
|
|
None
|
|
if args.no_animations
|
|
else load_animation_config(
|
|
project_path,
|
|
effective_animation_config,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
print(f"Error: Failed to load animation config: {exc}", file=sys.stderr)
|
|
return 1
|
|
config_errors: list[str] = []
|
|
if animation_config:
|
|
config_errors.extend(validate_transition_config(animation_config))
|
|
config_errors.extend(validate_animation_config_errors(animation_config))
|
|
config_errors = list(dict.fromkeys(config_errors))
|
|
if config_errors:
|
|
for error in config_errors:
|
|
print(f"Error: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
config_warnings: list[str] = []
|
|
if animation_config:
|
|
reference_messages = validate_animation_config(
|
|
project_path,
|
|
animation_config,
|
|
svg_files=native_files,
|
|
)
|
|
config_warnings = [
|
|
message for message in reference_messages
|
|
if ' has no id and cannot be customized in animations.json' in message
|
|
]
|
|
reference_errors = [
|
|
message for message in reference_messages
|
|
if message not in config_warnings
|
|
]
|
|
if reference_errors:
|
|
for error in reference_errors:
|
|
print(f"Error: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
if animation_config and verbose:
|
|
config_label = (
|
|
effective_animation_config
|
|
or str(project_path / 'animations.json')
|
|
)
|
|
print(f" Animation config: {config_label}")
|
|
for warning in config_warnings:
|
|
print(f" [warn] {warning}")
|
|
elif args.no_animations and verbose:
|
|
print(" Animations: disabled")
|
|
|
|
inherited_motion: dict[str, object] = {}
|
|
if args.inherit_motion_from:
|
|
try:
|
|
inherited_motion = _load_deck_motion_handoff(
|
|
project_path,
|
|
args.inherit_motion_from,
|
|
native_files,
|
|
)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
if verbose:
|
|
print(f" Deck motion handoff: {args.inherit_motion_from}")
|
|
|
|
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
|
transition_defaults = _as_dict(defaults.get('transition')) if isinstance(defaults, dict) else {}
|
|
animation_defaults = _as_dict(defaults.get('animation')) if isinstance(defaults, dict) else {}
|
|
inherited_transition = _as_dict(inherited_motion.get('transition'))
|
|
inherited_animation = _as_dict(inherited_motion.get('animation'))
|
|
inherited_overrides = _as_dict(inherited_motion.get('cli_overrides'))
|
|
|
|
transition_arg = args.transition
|
|
transition_effect = (
|
|
'none'
|
|
if args.no_animations
|
|
else (
|
|
transition_arg
|
|
if transition_arg is not None
|
|
else (
|
|
inherited_transition['effect']
|
|
if 'effect' in inherited_transition
|
|
else transition_defaults.get('effect', 'fade')
|
|
)
|
|
)
|
|
)
|
|
try:
|
|
transition, transition_effect_options = (
|
|
normalize_transition_effect_request(
|
|
transition_effect,
|
|
(
|
|
None
|
|
if transition_arg is not None or args.no_animations
|
|
else (
|
|
inherited_transition.get('effect_options')
|
|
if 'effect' in inherited_transition
|
|
else transition_defaults.get('effect_options')
|
|
)
|
|
),
|
|
)
|
|
)
|
|
transition_duration = validate_seconds(
|
|
(
|
|
args.transition_duration
|
|
if args.transition_duration is not None
|
|
else (
|
|
inherited_transition['duration']
|
|
if 'duration' in inherited_transition
|
|
else transition_defaults.get('duration', 0.4)
|
|
)
|
|
),
|
|
"transition duration",
|
|
allow_zero=transition is None,
|
|
)
|
|
auto_advance = (
|
|
args.auto_advance
|
|
if args.auto_advance is not None
|
|
else (
|
|
inherited_transition['auto_advance']
|
|
if 'auto_advance' in inherited_transition
|
|
else transition_defaults.get('auto_advance')
|
|
)
|
|
)
|
|
if auto_advance is not None:
|
|
auto_advance = validate_seconds(
|
|
auto_advance,
|
|
"transition auto_advance",
|
|
allow_zero=True,
|
|
)
|
|
transition_sound = (
|
|
None
|
|
if args.no_animations
|
|
else (
|
|
inherited_transition['sound']
|
|
if 'sound' in inherited_transition
|
|
else transition_defaults.get('sound')
|
|
)
|
|
)
|
|
if transition_sound is not None and not isinstance(
|
|
transition_sound,
|
|
str,
|
|
):
|
|
raise ValueError(
|
|
'transition sound must be a project-relative .wav path or null'
|
|
)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
animation_effect = (
|
|
'none'
|
|
if args.no_animations
|
|
else (
|
|
args.animation
|
|
if args.animation is not None
|
|
# Per-element object motion is opt-in by default: unsolicited
|
|
# auto-firing builds read as the "AI deck" tell. Page transitions
|
|
# stay on; enable objects with -a or animations.json.
|
|
else (
|
|
inherited_animation['effect_request']
|
|
if 'effect_request' in inherited_animation
|
|
else animation_defaults.get('effect', 'none')
|
|
)
|
|
)
|
|
)
|
|
normalized_animation = normalize_animation_effect(animation_effect)
|
|
# Keep the raw request for the builder so legacy directional aliases
|
|
# can desugar into canonical effect_options instead of losing their
|
|
# direction during early CLI normalization.
|
|
animation = (
|
|
None
|
|
if normalized_animation is None
|
|
else str(animation_effect)
|
|
)
|
|
animation_duration = validate_seconds(
|
|
(
|
|
args.animation_duration
|
|
if args.animation_duration is not None
|
|
else (
|
|
inherited_animation['duration']
|
|
if 'duration' in inherited_animation
|
|
else animation_defaults.get('duration', 0.4)
|
|
)
|
|
),
|
|
"animation duration",
|
|
allow_zero=False,
|
|
)
|
|
animation_seconds_to_milliseconds(
|
|
animation_duration,
|
|
"animation duration",
|
|
allow_zero=False,
|
|
)
|
|
animation_stagger = validate_seconds(
|
|
(
|
|
args.animation_stagger
|
|
if args.animation_stagger is not None
|
|
else (
|
|
inherited_animation['stagger']
|
|
if 'stagger' in inherited_animation
|
|
else animation_defaults.get('stagger', 0.5)
|
|
)
|
|
),
|
|
"animation stagger",
|
|
allow_zero=True,
|
|
)
|
|
animation_seconds_to_milliseconds(
|
|
animation_stagger,
|
|
"animation stagger",
|
|
allow_zero=True,
|
|
)
|
|
animation_trigger = normalize_animation_trigger(
|
|
args.animation_trigger
|
|
if args.animation_trigger is not None
|
|
else (
|
|
inherited_animation['trigger']
|
|
if 'trigger' in inherited_animation
|
|
else animation_defaults.get('trigger', 'after-previous')
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
animation_cli_overrides = {
|
|
'transition': (
|
|
args.transition is not None
|
|
or inherited_overrides.get('transition') is True
|
|
),
|
|
'transition_duration': (
|
|
args.transition_duration is not None
|
|
or inherited_overrides.get('transition_duration') is True
|
|
),
|
|
'auto_advance': (
|
|
args.auto_advance is not None
|
|
or inherited_overrides.get('auto_advance') is True
|
|
),
|
|
'transition_sound': 'sound' in inherited_transition,
|
|
'animation': (
|
|
args.animation is not None
|
|
or inherited_overrides.get('animation') is True
|
|
),
|
|
'animation_duration': (
|
|
args.animation_duration is not None
|
|
or inherited_overrides.get('animation_duration') is True
|
|
),
|
|
'animation_stagger': (
|
|
args.animation_stagger is not None
|
|
or inherited_overrides.get('animation_stagger') is True
|
|
),
|
|
'animation_trigger': (
|
|
args.animation_trigger is not None
|
|
or inherited_overrides.get('animation_trigger') is True
|
|
),
|
|
}
|
|
|
|
deck_motion: dict[str, object] = {
|
|
'transition': {
|
|
'effect': transition,
|
|
'effect_options': transition_effect_options,
|
|
'duration': transition_duration,
|
|
'auto_advance': auto_advance,
|
|
'sound': transition_sound,
|
|
},
|
|
'animation': {
|
|
'effect': normalized_animation or 'none',
|
|
'effect_request': animation,
|
|
'duration': animation_duration,
|
|
'stagger': animation_stagger,
|
|
'trigger': animation_trigger,
|
|
},
|
|
'cli_overrides': animation_cli_overrides,
|
|
'narration_timings': use_narration_timings,
|
|
}
|
|
|
|
if args.recorded_narration:
|
|
on_click_slides = _recorded_narration_on_click_slides(
|
|
ref_files,
|
|
animation_config,
|
|
animation,
|
|
animation_trigger,
|
|
animation_cli_overrides,
|
|
)
|
|
if on_click_slides:
|
|
print(
|
|
"Error: --recorded-narration cannot be used with on-click object animations. "
|
|
"Use --animation-trigger after-previous or --animation-trigger with-previous.",
|
|
file=sys.stderr,
|
|
)
|
|
for slide in on_click_slides[:20]:
|
|
print(f" on-click trigger: {slide}", file=sys.stderr)
|
|
if len(on_click_slides) > 20:
|
|
print(f" ... and {len(on_click_slides) - 20} more", file=sys.stderr)
|
|
return 1
|
|
|
|
# Optional per-project document properties. Absent file → factual fields
|
|
# are still stamped at export; only the authored fields stay blank.
|
|
doc_metadata = None
|
|
metadata_path = project_path / 'metadata.json'
|
|
if metadata_path.is_file():
|
|
try:
|
|
loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
print(f" [warn] metadata.json ignored ({exc})", file=sys.stderr)
|
|
else:
|
|
if isinstance(loaded, dict):
|
|
doc_metadata = loaded
|
|
if verbose:
|
|
print(f" Document properties: metadata.json ({len(loaded)} field(s))")
|
|
else:
|
|
print(" [warn] metadata.json ignored (top level is not an object)", file=sys.stderr)
|
|
|
|
structure_name = project_name
|
|
if isinstance(doc_metadata, dict):
|
|
metadata_title = doc_metadata.get('title')
|
|
if isinstance(metadata_title, str) and metadata_title.strip():
|
|
structure_name = metadata_title
|
|
|
|
shared_kwargs = dict(
|
|
canvas_format=canvas_format,
|
|
expected_viewbox=expected_viewbox,
|
|
doc_metadata=doc_metadata,
|
|
structure_name=structure_name,
|
|
verbose=verbose,
|
|
transition=transition,
|
|
transition_effect_options=transition_effect_options,
|
|
transition_sound=transition_sound,
|
|
transition_duration=transition_duration,
|
|
auto_advance=auto_advance,
|
|
notes=notes,
|
|
enable_notes=enable_notes,
|
|
animation=animation,
|
|
animation_duration=animation_duration,
|
|
animation_stagger=animation_stagger,
|
|
animation_trigger=animation_trigger,
|
|
animation_config=animation_config,
|
|
animation_resource_root=project_path,
|
|
animation_cli_overrides=animation_cli_overrides,
|
|
narration_audio=narration_audio,
|
|
use_narration_timings=use_narration_timings,
|
|
narration_padding=args.narration_padding,
|
|
narration_start_floor=args.narration_start_floor,
|
|
text_flow=args.text_flow,
|
|
image_optimize=not args.no_image_optimize,
|
|
image_max_dimension=args.image_max_dimension,
|
|
image_sizing=args.image_sizing,
|
|
image_scale=args.image_scale,
|
|
image_quality=args.image_quality,
|
|
native_objects=args.native_objects,
|
|
pptx_structure=pptx_structure,
|
|
structured_baseline=structured_baseline,
|
|
baseline_layout_specs=baseline_layout_specs,
|
|
layout_definition_files=layout_definition_files,
|
|
native_structure_contract=native_structure_contract,
|
|
theme_font_spec=theme_font_spec,
|
|
master_text_style_spec=master_text_style_spec,
|
|
theme_color_spec=theme_color_spec,
|
|
primary_language=primary_language,
|
|
)
|
|
|
|
if verbose:
|
|
print("PPT Master - SVG to native DrawingML PPTX Tool")
|
|
print("=" * 50)
|
|
print(f" Project path: {project_path}")
|
|
print(f" SVG directory: {native_source_dir}")
|
|
print(f" Output file: {native_path}")
|
|
print()
|
|
|
|
conversion_trace_path: Path | None = None
|
|
if args.conversion_trace is not None:
|
|
if args.conversion_trace:
|
|
requested_trace_path = Path(args.conversion_trace).expanduser()
|
|
conversion_trace_path = (
|
|
requested_trace_path
|
|
if requested_trace_path.is_absolute()
|
|
else project_path / requested_trace_path
|
|
)
|
|
else:
|
|
conversion_trace_path = (
|
|
project_path / 'validation' / f'{native_path.stem}.trace.json'
|
|
)
|
|
try:
|
|
success = create_pptx_with_native_svg(
|
|
output_path=native_path,
|
|
use_native_shapes=True,
|
|
svg_files=native_files,
|
|
conversion_trace_path=conversion_trace_path,
|
|
**shared_kwargs,
|
|
)
|
|
except (TemplateStructureError, ValueError) as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Archive svg_output/ once per default-flow export. This preserves the
|
|
# authored SVG sources under backup/<ts>/svg_output/ for inspection and
|
|
# deterministic re-export.
|
|
backup_path: Path | None = None
|
|
if success and backup_dir is not None:
|
|
svg_output_src = project_path / "svg_output"
|
|
if svg_output_src.is_dir():
|
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
svg_output_dst = backup_dir / "svg_output"
|
|
try:
|
|
shutil.copytree(svg_output_src, svg_output_dst)
|
|
except Exception as exc:
|
|
if verbose:
|
|
print(f" [warn] svg_output backup skipped: {exc}")
|
|
else:
|
|
backup_path = svg_output_dst
|
|
if verbose:
|
|
print(f" svg_output backup: {svg_output_dst}")
|
|
payload_store_src = project_path / PAYLOAD_STORE_RELATIVE_PATH
|
|
if payload_store_src.is_file():
|
|
try:
|
|
payload_store_dst = backup_dir / PAYLOAD_STORE_RELATIVE_PATH
|
|
payload_store_dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(payload_store_src, payload_store_dst)
|
|
if verbose:
|
|
print(f" native payload backup: {payload_store_dst}")
|
|
except Exception as exc:
|
|
if verbose:
|
|
print(f" [warn] native payload backup skipped: {exc}")
|
|
elif verbose:
|
|
print(f" [info] svg_output/ not found, backup skipped")
|
|
|
|
if success:
|
|
try:
|
|
receipt = _write_postflight_report(
|
|
output_path=native_path,
|
|
project_path=project_path,
|
|
svg_files=native_files,
|
|
layout_definition_files=layout_definition_files,
|
|
pptx_structure=pptx_structure,
|
|
backup_path=backup_path,
|
|
conversion_trace_path=conversion_trace_path,
|
|
deck_motion=deck_motion,
|
|
)
|
|
except PptxPostflightValidationError as exc:
|
|
print(
|
|
"Error: generated PPTX failed postflight validation and must "
|
|
f"not be used: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
f" Invalid output remains at: {native_path}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
except OSError as exc:
|
|
print(
|
|
"Error: PPTX generation succeeded, but its postflight report "
|
|
f"could not be written: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
print(f" PPTX output remains at: {native_path}", file=sys.stderr)
|
|
return 1
|
|
if verbose:
|
|
_print_postflight_receipt(receipt)
|
|
|
|
return 0 if success else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|