934 lines
44 KiB
Python
934 lines
44 KiB
Python
"""CLI entry point for svg_to_pptx."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import json
|
||
import shutil
|
||
import argparse
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
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 .pptx_dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
|
||
from .pptx_discovery import find_svg_files, find_notes_files
|
||
from .pptx_builder import create_pptx_with_native_svg
|
||
from .pptx_narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
|
||
from .pptx_slide_xml import TRANSITIONS
|
||
from .animation_config import load_animation_config, validate_animation_config
|
||
|
||
try:
|
||
from pptx_animations import ANIMATIONS as _ANIMATIONS
|
||
except ImportError:
|
||
_ANIMATIONS = {}
|
||
|
||
|
||
def _as_dict(value: object) -> dict:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
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."""
|
||
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:
|
||
cfg_effect = str(anim_cfg.get('effect'))
|
||
slide_animation = None if cfg_effect == 'none' else cfg_effect
|
||
if slide_animation is None:
|
||
continue
|
||
|
||
slide_trigger = animation_trigger
|
||
if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
|
||
slide_trigger = str(anim_cfg.get('trigger'))
|
||
if slide_trigger == 'on-click':
|
||
blocked.append(svg_path.stem)
|
||
return blocked
|
||
|
||
|
||
def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]) -> bool:
|
||
"""Detect the export-boundary icon violation.
|
||
|
||
Returns True when ``spec_lock.md`` locks an icon library + non-empty
|
||
inventory but the source SVGs carry ZERO ``<use data-icon>`` placeholders —
|
||
i.e. the deck would export flat / icon-less despite the strategist intending
|
||
icons. Returns False otherwise (including on any internal error: detection
|
||
must never itself break the export path).
|
||
|
||
The caller turns a True into a fatal abort (unless ``--allow-iconless``).
|
||
This mirrors svg_quality_checker's deck-level icon gate, but at the export
|
||
boundary it is the LAST line of defense: the quality gate can be reordered
|
||
before export or have its non-zero exit swallowed by ``| head``, whereas a
|
||
refusal to write the pptx cannot be piped away.
|
||
|
||
Detection always reads ``svg_output/`` (the authored source of truth)
|
||
when it exists, regardless of which directory the export consumes:
|
||
finalize EXPANDS ``<use data-icon>`` placeholders in svg_final, so
|
||
checking an ``-s final`` file set produced a false "zero icons" alarm —
|
||
which in a real run handed the model a legitimate-looking reason to pass
|
||
``--allow-iconless`` on a deck whose icons were perfectly fine.
|
||
"""
|
||
try:
|
||
import re
|
||
|
||
lock_path = project_path / 'spec_lock.md'
|
||
if not lock_path.exists():
|
||
return False
|
||
try:
|
||
from update_spec import parse_lock
|
||
icons = (parse_lock(lock_path) or {}).get('icons') or {}
|
||
except Exception:
|
||
return False
|
||
library = (icons.get('library') or '').strip().lower()
|
||
inventory = (icons.get('inventory') or '').strip().lower()
|
||
_empty = ('', 'none', '(none)', '-', 'n/a')
|
||
if library in _empty or inventory in _empty:
|
||
return False
|
||
source_dir = project_path / 'svg_output'
|
||
sources = sorted(source_dir.glob('*.svg')) if source_dir.is_dir() else svg_files
|
||
total = 0
|
||
for p in sources:
|
||
try:
|
||
total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
|
||
except Exception:
|
||
continue
|
||
return total == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _quality_errors(project_path: Path) -> list[str]:
|
||
"""Export-boundary structural quality gate.
|
||
|
||
Runs svg_quality_checker's per-file checks on ``svg_output/`` right before
|
||
export and returns every hard error (forbidden features, malformed XML,
|
||
missing image files, icon-on-text / off-canvas geometry, spec_lock
|
||
violations). The stage-4 quality check is documented as mandatory, but a
|
||
real 25-page run simply never invoked it — embedding the same checks at
|
||
the export boundary makes "forgot / skipped the checker" impossible.
|
||
|
||
Only applies to spec_lock'd projects with an svg_output/ (same scope as
|
||
the other gates). Fails open on import/internal errors — the checker
|
||
lives one directory above this package and is optional at runtime; a
|
||
broken checker must not break exports. Deck-level aggregates (icon
|
||
totals, visual richness) are NOT re-derived here; icons already have
|
||
their own export gate and richness is advisory.
|
||
"""
|
||
try:
|
||
if not (project_path / 'spec_lock.md').exists():
|
||
return []
|
||
source_dir = project_path / 'svg_output'
|
||
if not source_dir.is_dir():
|
||
return []
|
||
try:
|
||
import sys as _sys
|
||
_scripts_dir = str(Path(__file__).resolve().parent.parent)
|
||
if _scripts_dir not in _sys.path:
|
||
_sys.path.insert(0, _scripts_dir)
|
||
from svg_quality_checker import SVGQualityChecker
|
||
except Exception:
|
||
return []
|
||
checker = SVGQualityChecker()
|
||
problems: list[str] = []
|
||
for svg in sorted(source_dir.glob('*.svg')):
|
||
result = checker.check_file(str(svg))
|
||
for err in result.get('errors', []):
|
||
problems.append(f"{svg.stem}: {err}")
|
||
return problems
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _acceptance_problems(project_path: Path,
|
||
svg_files: list[Path]) -> tuple[list[str], list[str]]:
|
||
"""Export-boundary visual-acceptance gate (companion to the icon gate).
|
||
|
||
A spec_lock'd deck must have every exported page visually accepted:
|
||
rendered by svg_preview.py (which records source sha1 + render time in
|
||
``.build/acceptance.json``), eyeballed, and marked ``pass`` via
|
||
accept_pages.py — with the svg_output source unchanged since that render.
|
||
|
||
Returns ``(hard, waivable)`` problem lists; both empty means the gate
|
||
passes or does not apply (no spec_lock.md — bare/ad-hoc conversions stay
|
||
unblocked). ``hard`` problems (never rendered / record unreadable / source
|
||
edited after render / pre-finalize render) block even under
|
||
``--allow-unreviewed`` — rendering is cheap and machine-checkable, so
|
||
there is no legitimate reason to export a page nobody could have seen.
|
||
``waivable`` problems (verdict not yet pass) yield to the flag. The only
|
||
bypass for hard problems is the ZCBOT_PPT_FORCE_EXPORT=1 environment
|
||
variable (operator emergency hatch, deliberately absent from --help).
|
||
|
||
Unexpected internal errors fail open: the gate must never itself break
|
||
the export path. A missing or unparseable acceptance record is NOT an
|
||
internal error — it is exactly the "never rendered, never looked"
|
||
failure this gate exists to stop.
|
||
|
||
Motivation: a real delivery shipped 25 hand-written pages with icon-on-text
|
||
and numeral-on-caption collisions because the acceptance stage was skipped
|
||
outright; the re-run then skipped rendering again by reaching straight for
|
||
--allow-unreviewed eight seconds after the gate fired. Like the icon gate
|
||
above, a refusal to write the pptx cannot be piped away with ``| head``.
|
||
"""
|
||
try:
|
||
if not (project_path / 'spec_lock.md').exists():
|
||
return [], []
|
||
acc_path = project_path / '.build' / 'acceptance.json'
|
||
if not acc_path.exists():
|
||
return (["no acceptance record (.build/acceptance.json) — the deck was "
|
||
"never rendered for review (svg_preview.py never ran)"], [])
|
||
try:
|
||
data = json.loads(acc_path.read_text(encoding='utf-8'))
|
||
pages = data.get('pages') if isinstance(data, dict) else None
|
||
if not isinstance(pages, dict):
|
||
raise ValueError('missing "pages" object')
|
||
except (json.JSONDecodeError, ValueError, OSError) as exc:
|
||
return ([f"acceptance record unreadable ({exc}) — re-run svg_preview.py"], [])
|
||
|
||
import hashlib
|
||
hard: list[str] = []
|
||
waivable: list[str] = []
|
||
for svg in svg_files:
|
||
stem = svg.stem
|
||
entry = pages.get(stem)
|
||
if not isinstance(entry, dict):
|
||
hard.append(f"{stem}: never rendered / reviewed")
|
||
continue
|
||
if entry.get('rendered_from') == 'svg_output':
|
||
hard.append(
|
||
f"{stem}: rendered pre-finalize (icons/images not embedded, "
|
||
f"the PNG shows the wrong page) — re-run finalize_svg + svg_preview")
|
||
continue
|
||
source = project_path / 'svg_output' / f'{stem}.svg'
|
||
if not source.exists():
|
||
source = svg
|
||
sha = hashlib.sha1(source.read_bytes()).hexdigest()
|
||
if sha != entry.get('source_sha1'):
|
||
hard.append(f"{stem}: source edited AFTER the last render — "
|
||
f"re-render and re-review this page")
|
||
continue
|
||
verdict = entry.get('verdict')
|
||
if verdict != 'pass':
|
||
waivable.append(f"{stem}: verdict is '{verdict or 'pending'}', not 'pass'")
|
||
return hard, waivable
|
||
except Exception:
|
||
return [], []
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""CLI entry point for the SVG to PPTX conversion tool."""
|
||
transition_choices = (
|
||
['none'] + (list(TRANSITIONS.keys()) if TRANSITIONS
|
||
else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
|
||
)
|
||
|
||
animation_choices = (
|
||
['none'] + (list(_ANIMATIONS.keys()) if _ANIMATIONS
|
||
else ['fade', 'fly', 'zoom', 'appear'])
|
||
+ ['auto', 'mixed', 'random']
|
||
)
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=f'''
|
||
Examples:
|
||
%(prog)s examples/ppt169_demo -s final # Default: native pptx -> exports/, svg_output -> .build/backup/latest/
|
||
%(prog)s examples/ppt169_demo --svg-snapshot # Also emit SVG-rendered snapshot pptx alongside native in exports/
|
||
%(prog)s examples/ppt169_demo --only legacy # Only SVG image version (skips native)
|
||
%(prog)s examples/ppt169_demo -o out.pptx # Explicit path (no backup/)
|
||
|
||
# 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 (original version)
|
||
final - svg_final (post-processed, recommended)
|
||
<any> - Specify a subdirectory name directly
|
||
|
||
Transition effects (-t/--transition):
|
||
{', '.join(transition_choices)}
|
||
|
||
Per-element entrance animation (-a/--animation, native shapes mode):
|
||
{', '.join(animation_choices)}
|
||
Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
|
||
"auto" (map effect from group id: chart→wipe, card-/step-/pillar-→fly,
|
||
title/takeaway→fade; image-like ids hero/figure-/image/img-/kpi cycle
|
||
zoom/dissolve/circle/box/diamond/wheel so multiple images vary across
|
||
the deck; unmatched ids cycle fade/wipe/fly/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 (legacy) cycles a larger 16-effect pool by group order;
|
||
random samples from the same legacy pool. Use "-a none" to disable.
|
||
|
||
Compatibility mode (enabled by default):
|
||
- Automatically generates PNG fallback images, SVG embedded as extension
|
||
- Compatible with all Office versions (including Office LTSC 2021)
|
||
- Newer Office still displays SVG (editable), older versions display PNG
|
||
- Requires svglib: pip install svglib reportlab
|
||
- Use --no-compat to disable (only Office 2019+ supported)
|
||
|
||
Speaker notes (enabled by default):
|
||
- 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)
|
||
- Use --no-notes to disable
|
||
|
||
Recorded narration:
|
||
%(prog)s examples/ppt169_demo -s final --recorded-narration audio
|
||
- Keeps speaker notes when enabled
|
||
- Prepares PowerPoint recorded timings and narrations
|
||
- Requires one m4a/mp3/wav file per slide
|
||
- 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='SVG source directory. Default: native reads '
|
||
'svg_output/ (high-fidelity, preserves icons / '
|
||
'preserveAspectRatio / rx-ry); legacy reads '
|
||
'svg_final/ (PPT-internal SVG parser fallback). '
|
||
'Pass output/final/<name> to force one source.')
|
||
parser.add_argument('-f', '--format', type=str,
|
||
choices=list(CANVAS_FORMATS.keys()), default=None,
|
||
help='Specify canvas format')
|
||
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')
|
||
|
||
parser.add_argument('--no-compat', action='store_true',
|
||
help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')
|
||
|
||
parser.add_argument('--allow-iconless', action='store_true', default=False,
|
||
help='Allow export even when spec_lock locks an icon inventory but '
|
||
'the SVGs author zero <use data-icon> (default: refuse — the deck '
|
||
'would render flat / icon-less). Use only for a stale lock or an '
|
||
'intentionally icon-less deck.')
|
||
|
||
parser.add_argument('--allow-unreviewed', action='store_true', default=False,
|
||
help='Allow export even when pages lack a passed visual acceptance '
|
||
'(svg_preview.py render + accept_pages.py --pass with unchanged '
|
||
'sources). Default: refuse — unreviewed hand-written coordinates '
|
||
'are how misaligned decks ship.')
|
||
|
||
mode_group = parser.add_mutually_exclusive_group()
|
||
mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
|
||
help='Only generate one version: native (editable shapes) or legacy (SVG image)')
|
||
mode_group.add_argument('--native', action='store_true', default=False,
|
||
help='(Deprecated, now default) Convert SVG to native DrawingML shapes')
|
||
merge_group = parser.add_mutually_exclusive_group()
|
||
merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
|
||
help='Compatibility no-op: mergeable paragraph blocks are merged '
|
||
'by default.')
|
||
merge_group.add_argument('--no-merge', action='store_false', dest='merge_paragraphs',
|
||
help='Disable paragraph merging. Every dy-stacked line becomes '
|
||
'its own text frame for strict SVG line-layout fidelity.')
|
||
parser.set_defaults(merge_paragraphs=True)
|
||
parser.add_argument('--conversion-trace', action='store_true', default=False,
|
||
help='Write a JSON diagnostics report next to the native PPTX '
|
||
'(<output>.trace.json). Records per-slide SVG element '
|
||
'conversion decisions for debugging.')
|
||
parser.add_argument('--svg-snapshot', action='store_true', default=False,
|
||
help='Also emit the SVG-rendered snapshot pptx alongside the native pptx in exports/ '
|
||
'(named <project>_<ts>_svg.pptx). Off by default — the native pptx is the '
|
||
'canonical output; live preview already provides the SVG visual reference. '
|
||
'Note: the svg_output/ source snapshot is always written to .build/backup/latest/ '
|
||
'regardless of this flag.')
|
||
|
||
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 number < 0:
|
||
raise argparse.ArgumentTypeError("must be non-negative")
|
||
return number
|
||
|
||
parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
|
||
help='Page transition effect (default: fade, use "none" to disable)')
|
||
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 entrance animation (native shapes mode '
|
||
'only). Default "none" (no auto element builds; page '
|
||
'transitions still apply). Pick a single effect, "auto" '
|
||
'(map effect from group id — image-like ids cycle a '
|
||
'richer pool for visual variation, fallback cycles fade/'
|
||
'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
|
||
'"random".')
|
||
parser.add_argument('--animation-duration', type=non_negative_float, default=None,
|
||
help='Per-element entrance duration in seconds (default: 0.4)')
|
||
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.')
|
||
parser.add_argument('--animation-config', type=str, default=None,
|
||
help='Optional per-slide/per-object animation config. '
|
||
'Default: <project>/animations.json when present.')
|
||
|
||
parser.add_argument('--no-notes', action='store_true',
|
||
help='Disable speaker notes embedding (enabled by default)')
|
||
parser.add_argument('--narration-audio-dir', type=str, default=None,
|
||
help='Low-level audio embedding from this directory; allows partial matches')
|
||
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')
|
||
parser.add_argument('--narration-padding', type=float, default=0.5,
|
||
help='Seconds to add after each narration before auto-advance (default: 0.5)')
|
||
|
||
parser.add_argument('--cache-dir', type=str, default=None,
|
||
help='Cache directory for SVG→PNG renders (default: '
|
||
'<project>/.cache/svg_png). Cache key uses SVG content '
|
||
'hash + size + renderer; safe across renderer switches. '
|
||
'Removed automatically after a successful export.')
|
||
parser.add_argument('--no-cache', action='store_true',
|
||
help='Disable the SVG→PNG cache for this run (still parallel).')
|
||
parser.add_argument('--keep-cache', action='store_true',
|
||
help='Keep the SVG→PNG cache directory after export '
|
||
'(default: removed on success to keep project clean).')
|
||
parser.add_argument('--workers', type=int, default=None,
|
||
help='Parallel workers for SVG→PNG pre-rendering. '
|
||
'Default: min(cpu, pages, 8). Set 1 for sequential.')
|
||
|
||
args = parser.parse_args(argv)
|
||
|
||
project_path = Path(args.project_path)
|
||
if not project_path.exists():
|
||
print(f"Error: Path does not exist: {project_path}")
|
||
return 1
|
||
|
||
try:
|
||
project_info = get_project_info(str(project_path))
|
||
project_name = project_info.get('name', project_path.name)
|
||
detected_format = project_info.get('format')
|
||
except Exception:
|
||
project_name = project_path.name
|
||
detected_format = None
|
||
|
||
canvas_format = args.format
|
||
if canvas_format is None and detected_format and detected_format != 'unknown':
|
||
canvas_format = detected_format
|
||
|
||
# Determine which versions to generate.
|
||
# Default is native-only; SVG snapshot is opt-in via --svg-snapshot.
|
||
# --only native / --only legacy still force a single version explicitly.
|
||
only_mode = args.only
|
||
if only_mode == 'native':
|
||
gen_native, gen_legacy = True, False
|
||
elif only_mode == 'legacy':
|
||
gen_native, gen_legacy = False, True
|
||
else:
|
||
gen_native = True
|
||
gen_legacy = args.svg_snapshot
|
||
|
||
# Pipeline split: native pptx gets the high-fidelity svg_output/ source
|
||
# (icons, preserveAspectRatio, rounded-rect rx/ry are all preserved by the
|
||
# converter); legacy pptx still needs svg_final/ because PowerPoint's
|
||
# internal SVG parser cannot handle <use data-icon> or honour
|
||
# preserveAspectRatio. An explicit -s overrides both branches so callers
|
||
# can keep the previous single-source behaviour for unusual workflows.
|
||
explicit_source = args.source is not None
|
||
native_source = args.source if explicit_source else 'output'
|
||
legacy_source = args.source if explicit_source else 'final'
|
||
|
||
native_files: list[Path] = []
|
||
legacy_files: list[Path] = []
|
||
native_source_dir = ''
|
||
legacy_source_dir = ''
|
||
|
||
if gen_native:
|
||
native_files, native_source_dir = find_svg_files(project_path, native_source)
|
||
if gen_legacy:
|
||
legacy_files, legacy_source_dir = find_svg_files(project_path, legacy_source)
|
||
|
||
# Reference list for cross-product lookups (notes / narration matching).
|
||
# native_files and legacy_files share filenames because svg_final/ is
|
||
# copytree'd from svg_output/, so either list works for matching.
|
||
ref_files = native_files or legacy_files
|
||
if not ref_files:
|
||
print("Error: No SVG files found")
|
||
return 1
|
||
|
||
# Export-boundary icon gate: a locked icon inventory with ZERO authored
|
||
# <use data-icon> means the deck exports flat / icon-less. This is the last
|
||
# line of defense (the quality gate can be reordered before export or its
|
||
# non-zero exit swallowed by `| head`), so it is FATAL by default — refuse to
|
||
# produce a pptx that the strategist's own spec_lock says is wrong.
|
||
# --allow-iconless is the explicit escape hatch (stale lock / intentional).
|
||
if _deck_locks_icons_but_authors_none(project_path, ref_files):
|
||
if args.allow_iconless:
|
||
print(
|
||
"[WARN] spec_lock locks an icon library + inventory but the source SVGs "
|
||
"contain ZERO <use data-icon> — exporting flat / icon-less anyway "
|
||
"(--allow-iconless).",
|
||
file=sys.stderr,
|
||
)
|
||
else:
|
||
print(
|
||
"[ERROR] spec_lock locks an icon library + inventory, but the source SVGs "
|
||
"contain ZERO <use data-icon> — this deck would export flat / icon-less.\n"
|
||
" Add inventory icons to content pages (KPI / list / process /\n"
|
||
" comparison layouts especially), then re-run. If the lock is stale\n"
|
||
" or icons are intentionally absent, pass --allow-iconless.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
import os as _os
|
||
force_export = _os.environ.get('ZCBOT_PPT_FORCE_EXPORT') == '1'
|
||
|
||
# Export-boundary structural quality gate: the stage-4 checker's per-file
|
||
# hard errors, re-run here so "never ran the checker" cannot ship a deck.
|
||
# No CLI flag waives this — these are real defects to fix, not judgments.
|
||
quality_problems = [] if force_export else _quality_errors(project_path)
|
||
if quality_problems:
|
||
print(
|
||
f"[ERROR] quality check failed with {len(quality_problems)} error(s) — "
|
||
"refusing to export:",
|
||
file=sys.stderr,
|
||
)
|
||
for p in quality_problems[:20]:
|
||
print(f" - {p}", file=sys.stderr)
|
||
if len(quality_problems) > 20:
|
||
print(f" ... and {len(quality_problems) - 20} more", file=sys.stderr)
|
||
print(
|
||
" Fix the listed pages in svg_output/ (run svg_quality_checker.py "
|
||
"<project_dir>\n"
|
||
" for the full report incl. warnings), then re-run finalize + "
|
||
"preview + export.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
# Export-boundary visual-acceptance gate: every page must have been
|
||
# rendered (svg_preview.py), eyeballed, and marked pass (accept_pages.py)
|
||
# with its source unchanged since. Hard problems (never rendered / stale
|
||
# render) block even under --allow-unreviewed; only un-passed verdicts
|
||
# yield to the flag. See _acceptance_problems for rationale.
|
||
hard_problems, waivable_problems = (
|
||
([], []) if force_export else _acceptance_problems(project_path, ref_files))
|
||
if hard_problems:
|
||
print(
|
||
f"[ERROR] {len(hard_problems)} blocking acceptance problem(s) — pages "
|
||
"never rendered for review, or changed since their last render. "
|
||
"Refusing to export; --allow-unreviewed does NOT cover this: rendering "
|
||
"is cheap and machine-checkable, there is no reason to ship a page "
|
||
"nobody could have seen.",
|
||
file=sys.stderr,
|
||
)
|
||
for p in hard_problems[:20]:
|
||
print(f" - {p}", file=sys.stderr)
|
||
if len(hard_problems) > 20:
|
||
print(f" ... and {len(hard_problems) - 20} more", file=sys.stderr)
|
||
print(
|
||
" Fix: run finalize_svg.py, then svg_preview.py <project_dir> "
|
||
"(full deck),\n"
|
||
" look at every PNG under .build/preview/, fix bad pages and "
|
||
"re-render,\n"
|
||
" then mark verdicts with accept_pages.py --pass ... "
|
||
"(or --pass-all).",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
if waivable_problems:
|
||
if args.allow_unreviewed:
|
||
print(
|
||
f"[WARN] {len(waivable_problems)} page(s) rendered but not marked "
|
||
"pass — exporting anyway (--allow-unreviewed).",
|
||
file=sys.stderr,
|
||
)
|
||
else:
|
||
print(
|
||
f"[ERROR] {len(waivable_problems)} page(s) rendered but not yet "
|
||
"reviewed/passed — refusing to export:",
|
||
file=sys.stderr,
|
||
)
|
||
for p in waivable_problems[:20]:
|
||
print(f" - {p}", file=sys.stderr)
|
||
if len(waivable_problems) > 20:
|
||
print(f" ... and {len(waivable_problems) - 20} more",
|
||
file=sys.stderr)
|
||
print(
|
||
" Look at every PNG under .build/preview/, then mark verdicts "
|
||
"with\n"
|
||
" accept_pages.py --pass ... (or --pass-all after a full "
|
||
"review).",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
|
||
backup_dir: Path | None = None
|
||
legacy_path: Path | None = None
|
||
if args.output:
|
||
output_base = Path(args.output)
|
||
native_path = output_base
|
||
if gen_legacy:
|
||
stem = output_base.stem
|
||
legacy_path = output_base.parent / f"{stem}_svg{output_base.suffix}"
|
||
else:
|
||
exports_dir = project_path / "exports"
|
||
exports_dir.mkdir(parents=True, exist_ok=True)
|
||
native_path = exports_dir / f"{project_name}_{timestamp}.pptx"
|
||
# svg_output/ snapshot goes under the hidden .build/backup/latest/ in
|
||
# default-flow mode (no -o). Latest-only — no timestamp pile-up; the
|
||
# persistent svg_output/ is the real source, this is just a re-export
|
||
# convenience copy. Literal ".build" keeps this package decoupled from
|
||
# project_utils; keep in sync with BUILD_DIR_NAME.
|
||
backup_dir = project_path / ".build" / "backup" / "latest"
|
||
if gen_legacy:
|
||
legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"
|
||
|
||
native_path.parent.mkdir(parents=True, exist_ok=True)
|
||
if legacy_path is not None:
|
||
legacy_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
verbose = not args.quiet
|
||
|
||
# Honor the actual SVG pixels over a stale project-recorded format. The
|
||
# canvas_format read from project init can disagree with what the Executor
|
||
# actually drew — e.g. a mirror template imported at 2560×1440 while the
|
||
# project was initialized as ppt169 (1280×720). When the first SVG's real
|
||
# viewBox doesn't match the recorded format's dimensions, drop the format
|
||
# so the builder sizes the slide by pixels (custom_pixels path). Standard
|
||
# decks match exactly, so this only changes behavior on the conflict case.
|
||
# An explicit --format always wins and is never second-guessed.
|
||
if args.format is None and canvas_format:
|
||
fmt_info = CANVAS_FORMATS.get(canvas_format)
|
||
actual_dims = get_viewbox_dimensions(ref_files[0])
|
||
if fmt_info and actual_dims:
|
||
fmt_dims = (fmt_info.get('width'), fmt_info.get('height'))
|
||
if fmt_dims != actual_dims:
|
||
if verbose:
|
||
print(
|
||
f" Recorded format '{canvas_format}' "
|
||
f"({fmt_dims[0]}×{fmt_dims[1]}) differs from SVG viewBox "
|
||
f"({actual_dims[0]}×{actual_dims[1]}); exporting by SVG pixels"
|
||
)
|
||
canvas_format = None
|
||
|
||
enable_notes = not args.no_notes
|
||
notes: dict[str, str] = {}
|
||
if enable_notes:
|
||
notes = find_notes_files(project_path, ref_files)
|
||
|
||
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
|
||
narration_audio = find_narration_files(narration_audio_dir, ref_files)
|
||
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.animation_config:
|
||
config_path = Path(args.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}")
|
||
return 1
|
||
|
||
try:
|
||
animation_config = load_animation_config(project_path, args.animation_config)
|
||
except Exception as exc:
|
||
print(f"Error: Failed to load animation config: {exc}")
|
||
return 1
|
||
if animation_config and verbose:
|
||
config_label = args.animation_config or str(project_path / 'animations.json')
|
||
print(f" Animation config: {config_label}")
|
||
for warning in validate_animation_config(project_path, animation_config):
|
||
print(f" [warn] {warning}")
|
||
|
||
defaults = animation_config.get('defaults', {}) if animation_config else {}
|
||
transition_defaults = defaults.get('transition', {}) if isinstance(defaults, dict) else {}
|
||
animation_defaults = defaults.get('animation', {}) if isinstance(defaults, dict) else {}
|
||
|
||
transition_arg = args.transition
|
||
transition_effect = (
|
||
transition_arg
|
||
if transition_arg is not None
|
||
else transition_defaults.get('effect', 'fade')
|
||
)
|
||
transition = None if transition_effect == 'none' else transition_effect
|
||
transition_duration = (
|
||
args.transition_duration
|
||
if args.transition_duration is not None
|
||
else float(transition_defaults.get('duration', 0.4))
|
||
)
|
||
|
||
animation_arg = args.animation
|
||
animation_effect = (
|
||
animation_arg
|
||
if animation_arg is not None
|
||
# Per-element entrance is opt-in by default: auto-firing element builds
|
||
# read as the "AI deck" tell and were unsolicited. Page transitions stay
|
||
# on (see transition default above). Re-enable with -a auto / animations.json.
|
||
else animation_defaults.get('effect', 'none')
|
||
)
|
||
animation = None if animation_effect == 'none' else animation_effect
|
||
animation_duration = (
|
||
args.animation_duration
|
||
if args.animation_duration is not None
|
||
else float(animation_defaults.get('duration', 0.4))
|
||
)
|
||
animation_stagger = (
|
||
args.animation_stagger
|
||
if args.animation_stagger is not None
|
||
else float(animation_defaults.get('stagger', 0.5))
|
||
)
|
||
animation_trigger = (
|
||
args.animation_trigger
|
||
if args.animation_trigger is not None
|
||
else animation_defaults.get('trigger', 'after-previous')
|
||
)
|
||
|
||
animation_cli_overrides = {
|
||
'transition': args.transition is not None,
|
||
'transition_duration': args.transition_duration is not None,
|
||
'auto_advance': args.auto_advance is not None,
|
||
'animation': args.animation is not None,
|
||
'animation_duration': args.animation_duration is not None,
|
||
'animation_stagger': args.animation_stagger is not None,
|
||
'animation_trigger': args.animation_trigger is not None,
|
||
}
|
||
|
||
if args.recorded_narration and gen_native:
|
||
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
|
||
|
||
if args.no_cache:
|
||
cache_dir: Path | None = None
|
||
elif args.cache_dir:
|
||
cache_dir = Path(args.cache_dir)
|
||
if not cache_dir.is_absolute():
|
||
cache_dir = project_path / cache_dir
|
||
else:
|
||
cache_dir = project_path / '.cache' / 'svg_png'
|
||
|
||
# svg_files is per-product (native vs legacy may now read different
|
||
# directories); everything else is shared.
|
||
# 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)
|
||
|
||
shared_kwargs = dict(
|
||
canvas_format=canvas_format,
|
||
doc_metadata=doc_metadata,
|
||
verbose=verbose,
|
||
transition=transition,
|
||
transition_duration=transition_duration,
|
||
auto_advance=args.auto_advance,
|
||
use_compat_mode=not args.no_compat,
|
||
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_cli_overrides=animation_cli_overrides,
|
||
narration_audio=narration_audio,
|
||
use_narration_timings=use_narration_timings,
|
||
narration_padding=args.narration_padding,
|
||
cache_dir=cache_dir,
|
||
workers=args.workers,
|
||
merge_paragraphs=args.merge_paragraphs,
|
||
)
|
||
|
||
success = True
|
||
|
||
# --- Native shapes version (primary) ---
|
||
if gen_native:
|
||
if verbose:
|
||
print("PPT Master - SVG to PPTX Tool")
|
||
print("=" * 50)
|
||
print(f" Project path: {project_path}")
|
||
print(f" SVG directory: {native_source_dir}")
|
||
print(f" Output file: {native_path}")
|
||
print()
|
||
|
||
ok = create_pptx_with_native_svg(
|
||
output_path=native_path,
|
||
use_native_shapes=True,
|
||
svg_files=native_files,
|
||
conversion_trace_path=(
|
||
native_path.with_name(native_path.name + '.trace.json')
|
||
if args.conversion_trace else None
|
||
),
|
||
**shared_kwargs,
|
||
)
|
||
success = success and ok
|
||
|
||
# --- SVG image reference version ---
|
||
if gen_legacy:
|
||
if verbose:
|
||
if gen_native:
|
||
print()
|
||
print("-" * 50)
|
||
print("PPT Master - SVG to PPTX Tool (SVG Reference)")
|
||
print("=" * 50)
|
||
print(f" Project path: {project_path}")
|
||
print(f" SVG directory: {legacy_source_dir}")
|
||
print(f" Output file: {legacy_path}")
|
||
print()
|
||
|
||
ok = create_pptx_with_native_svg(
|
||
output_path=legacy_path,
|
||
use_native_shapes=False,
|
||
svg_files=legacy_files,
|
||
**shared_kwargs,
|
||
)
|
||
success = success and ok
|
||
|
||
# svg_output/ snapshot — runs once per export in default-flow mode,
|
||
# decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
|
||
# under .build/backup/latest/svg_output/ for later inspection / re-export.
|
||
# Latest-only: wipe any prior snapshot so backups don't pile up.
|
||
if success and backup_dir is not None:
|
||
svg_output_src = project_path / "svg_output"
|
||
if svg_output_src.is_dir():
|
||
svg_output_dst = backup_dir / "svg_output"
|
||
try:
|
||
if backup_dir.exists():
|
||
shutil.rmtree(backup_dir)
|
||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||
shutil.copytree(svg_output_src, svg_output_dst)
|
||
if verbose:
|
||
print(f" svg_output backup: {svg_output_dst}")
|
||
except Exception as exc:
|
||
if verbose:
|
||
print(f" [warn] svg_output backup skipped: {exc}")
|
||
elif verbose:
|
||
print(f" [info] svg_output/ not found, backup skipped")
|
||
|
||
if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
|
||
try:
|
||
shutil.rmtree(cache_dir)
|
||
cache_parent = cache_dir.parent
|
||
if cache_parent.is_dir() and cache_parent.name == '.cache' and not any(cache_parent.iterdir()):
|
||
cache_parent.rmdir()
|
||
except Exception as exc:
|
||
if verbose:
|
||
print(f" [warn] cache cleanup skipped: {exc}")
|
||
|
||
return 0 if success else 1
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|