216 lines
12 KiB
Python
216 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Compile current-page SVG sources with a separate released template, locally."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from urllib.parse import unquote, urlsplit
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import template_ref as ref
|
|
|
|
SVG = "http://www.w3.org/2000/svg"
|
|
ET.register_namespace("", SVG)
|
|
DEPENDENCIES = {"python-pptx": "pptx", "lxml": "lxml", "Pillow": "PIL",
|
|
"XlsxWriter": "xlsxwriter", "typing_extensions": "typing_extensions",
|
|
"defusedxml": "defusedxml", "numpy": "numpy", "openpyxl": "openpyxl",
|
|
"et_xmlfile": "et_xmlfile"}
|
|
|
|
|
|
def regular(path: Path, label: str, *, directory=False) -> Path:
|
|
path = Path(os.path.abspath(path))
|
|
ref.require(not any(item.is_symlink() for item in (path, *path.parents)), f"{label}: symlinks forbidden")
|
|
ref.require(path.is_dir() if directory else path.is_file(), f"{label}: missing {'directory' if directory else 'file'}: {path}")
|
|
return path
|
|
|
|
|
|
def boundary_file(root: Path, value: str, label: str) -> Path:
|
|
ref.local_path(root, value, label)
|
|
return regular(root / value, label)
|
|
|
|
|
|
def runtime_root() -> Path:
|
|
# The installed Skill and a prepared portable project both keep scripts/
|
|
# adjacent to runtime/. No machine path or other installed Skill is read.
|
|
root = Path(__file__).resolve().parent.parent / "runtime/ppt-master"
|
|
regular(root / "scripts/attribution_guard.py", "bundled attribution guard")
|
|
return root
|
|
|
|
|
|
def load_compiler():
|
|
if sys.version_info < (3, 12):
|
|
raise ValueError("Native compiler requires Python 3.12+; metadata-only project commands require Python 3.10+")
|
|
missing = [name for name, module in DEPENDENCIES.items() if importlib.util.find_spec(module) is None]
|
|
if missing:
|
|
raise ValueError("Native compiler dependencies missing: " + ", ".join(missing)
|
|
+ "; provision runtime/requirements.txt using the installation environment")
|
|
sys.path.insert(0, str(runtime_root() / "scripts"))
|
|
from attribution_guard import require_skill_integrity
|
|
require_skill_integrity()
|
|
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
|
|
from svg_to_pptx.drawingml.theme_fonts import MasterTextStyleSpec
|
|
from finalize_theme import finalize, typography, verify, preserve_declared_font_families
|
|
return create_pptx_with_native_svg, MasterTextStyleSpec, finalize, typography, verify, preserve_declared_font_families
|
|
|
|
|
|
def prepare_svg(source: Path, target: Path, allowed: Path):
|
|
regular(source, "SVG source")
|
|
ref.require(source.is_relative_to(allowed), "SVG source is outside its declared resource boundary")
|
|
text = source.read_text(encoding="utf-8")
|
|
ref.require(not re.search(r"<!DOCTYPE|<!ENTITY", text, re.I), "SVG declarations/entities are forbidden")
|
|
root = ET.fromstring(text)
|
|
ref.require(root.tag == "{" + SVG + "}svg", "Source must be an SVG document")
|
|
for element in root.iter():
|
|
kind = element.tag.rsplit("}", 1)[-1]
|
|
ref.require(kind not in ("script", "foreignObject"), f"Unsupported active SVG element: {kind}")
|
|
for key, value in list(element.attrib.items()):
|
|
ref.require(not key.lower().startswith("on"), "SVG event handlers are forbidden")
|
|
if key.rsplit("}", 1)[-1] != "href":
|
|
continue
|
|
if value.startswith("#"):
|
|
continue
|
|
ref.require(kind == "image", "Only fragment references and local bitmap image href are supported")
|
|
if value.startswith("data:"):
|
|
ref.require(bool(re.fullmatch(r"data:image/(?:png|jpeg);base64,[A-Za-z0-9+/=\s]+", value)), "Only PNG/JPEG data images are supported")
|
|
base64.b64decode(value.split(",", 1)[1], validate=True)
|
|
continue
|
|
parsed = urlsplit(value)
|
|
decoded = unquote(parsed.path)
|
|
ref.require(not parsed.scheme and not parsed.netloc and not parsed.query and not parsed.fragment
|
|
and not decoded.startswith(("/", "\\")) and "\\" not in decoded and ":" not in decoded,
|
|
"Image href must be a relative local bitmap path")
|
|
path = Path(os.path.abspath(source.parent / decoded))
|
|
ref.require(path.is_relative_to(allowed), f"Image escapes declared resource boundary: {value}")
|
|
regular(path, "SVG bitmap")
|
|
ref.require(path.suffix.lower() in (".png", ".jpg", ".jpeg"), "Only PNG/JPEG bitmap references are supported")
|
|
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
|
element.set(key, f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii"))
|
|
# Local paint/filter references are fragments; other CSS url() forms would
|
|
# bypass image boundary checks and are intentionally refused.
|
|
for value in re.findall(r"url\(\s*['\"]?([^) '\"]+)['\"]?\s*\)", text):
|
|
ref.require(value.startswith("#"), "SVG CSS url() supports local fragments only")
|
|
ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
|
return root
|
|
|
|
|
|
def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path,
|
|
notes: dict | None = None, source_root: Path | None = None):
|
|
manifest_path = regular(manifest_path, "template manifest")
|
|
manifest = ref.load_manifest(manifest_path)
|
|
ref.require(manifest["schema_version"] == 2, "Native SVG export requires manifest schema 2; legacy native PPTX filling uses the existing application route")
|
|
selection = argparse.Namespace(theme=theme_id, mode=None, layout=None, route="native", workflow="interactive-page")
|
|
receipt = ref.resolve(manifest_path, manifest, selection)
|
|
template_root = manifest_path.parent
|
|
variant = manifest["themes"][theme_id]
|
|
workspace = regular(template_root / variant["workspace"], "template workspace", directory=True)
|
|
style = ref.read_json(boundary_file(template_root, manifest["style"], "style"), None)
|
|
theme = ref.read_json(boundary_file(template_root, variant["theme"], "theme"), None)
|
|
roster = ref.read_json(regular(workspace / "templates/roster.json", "template roster"), None)
|
|
layouts = {item["id"]: item["name"] for item in manifest["layouts"]}
|
|
ref.require(set(roster) == set(layouts), "Template roster IDs must match all manifest layouts")
|
|
ref.require(len(set(layouts.values())) == len(layouts), "Native layout friendly names must be unique")
|
|
ref.require(sources, "At least one current-page SVG source is required")
|
|
sources = [regular(path, "page source") for path in sources]
|
|
stems = [path.stem for path in sources]
|
|
ref.require(len(set(stems)) == len(stems), "SVG source stems must be unique for notes binding")
|
|
notes = notes or {}
|
|
ref.require(isinstance(notes, dict) and set(notes) <= set(stems)
|
|
and all(isinstance(value, str) for value in notes.values()), "Notes must map source stems to strings, without unknown pages")
|
|
source_root = regular(source_root, "source resource root", directory=True) if source_root else None
|
|
output = Path(os.path.abspath(output))
|
|
ref.require(output.suffix.lower() == ".pptx", "Output must end in .pptx")
|
|
ref.require(not output.exists() and not output.is_symlink(), "Export refuses to overwrite an existing output")
|
|
ref.require(not any(parent.is_symlink() for parent in output.parents), "Output ancestors cannot be symlinks")
|
|
compiler, master_style, finalize, typography, verify, preserve_fonts = load_compiler()
|
|
_, _, _, title_hpt, body_hpt = typography(style)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(prefix="native-export-", dir=output.parent) as temporary:
|
|
temporary = Path(temporary)
|
|
definitions, definition_roots = [], {}
|
|
for layout_id, filename in roster.items():
|
|
source = boundary_file(workspace / "templates", filename, f"layout {layout_id}")
|
|
target = temporary / f"layout_{layout_id}.svg"
|
|
root = prepare_svg(source, target, template_root)
|
|
ref.require(root.get("data-pptx-layout") == layout_id, f"Roster/source layout identity mismatch: {layout_id}")
|
|
definitions.append(target)
|
|
definition_roots[layout_id] = root
|
|
from math_component import prepare_math, apply_math
|
|
from slide_number import prepare_slide_number, finalize_slide_numbers
|
|
public, expected_names, compiled_notes, indexed_notes, math_pages, number_pages = [], [], {}, {}, [], []
|
|
for index, source in enumerate(sources):
|
|
target = temporary / f"page_{index:04d}.svg"
|
|
allowed = source_root or (template_root if source.is_relative_to(template_root) else source.parent)
|
|
root = prepare_svg(source, target, allowed)
|
|
layout_id = root.get("data-pptx-layout")
|
|
ref.require(layout_id in layouts, f"Source references an undeclared layout: {layout_id}")
|
|
ref.require(root.get("viewBox") == style["canvas"]["viewbox"], "Source viewBox differs from declared template canvas")
|
|
number_pages.append(prepare_slide_number(root, definition_roots[layout_id], index + 1))
|
|
equations = prepare_math(root, style["canvas"])
|
|
if equations or number_pages[-1]:
|
|
ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
|
math_pages.append(equations)
|
|
public.append(target)
|
|
expected_names.append(layouts[layout_id])
|
|
if source.stem in notes:
|
|
compiled_notes[target.stem] = notes[source.stem]
|
|
indexed_notes[index] = notes[source.stem]
|
|
generated = temporary / "generated.pptx"
|
|
with preserve_fonts(style):
|
|
success = compiler(svg_files=public, output_path=generated,
|
|
expected_viewbox=style["canvas"]["viewbox"], verbose=False,
|
|
transition=None, notes=compiled_notes, enable_notes=bool(notes), animation=None,
|
|
image_optimize=False, native_objects=True, pptx_structure="structured",
|
|
use_layout_placeholder_frames=True,
|
|
master_text_style_spec=master_style(title_hpt=title_hpt, body_hpt=body_hpt),
|
|
structure_name=manifest["template_id"], layout_definition_files=definitions,
|
|
workers=1, primary_language=style.get("language"))
|
|
ref.require(success, "Bundled structured compiler failed")
|
|
finalize(generated, theme, style)
|
|
math_facts = apply_math(generated, math_pages, style["canvas"])
|
|
number_facts = finalize_slide_numbers(generated, number_pages)
|
|
facts = verify(generated, layouts, expected_names, indexed_notes)
|
|
# Exclusive publication also protects against a writer racing the initial
|
|
# existence check. A failed compile never disturbs the previous output.
|
|
created = False
|
|
try:
|
|
with output.open("xb") as target, generated.open("rb") as source:
|
|
created = True
|
|
while block := source.read(1024 * 1024):
|
|
target.write(block)
|
|
except Exception:
|
|
if created:
|
|
output.unlink(missing_ok=True)
|
|
raise
|
|
return {**facts, **math_facts, **number_facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
|
|
"theme": theme_id, "output": str(output)}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--manifest", type=Path, required=True)
|
|
parser.add_argument("--theme", required=True)
|
|
parser.add_argument("--source", type=Path, action="append", required=True)
|
|
parser.add_argument("--source-root", type=Path, help="Explicit local boundary for author SVG resources; default is each source directory")
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--notes", type=Path, help="JSON object: source filename stem to speaker-notes string")
|
|
args = parser.parse_args()
|
|
try:
|
|
notes = ref.read_json(regular(args.notes, "notes JSON"), None) if args.notes else None
|
|
print(json.dumps(export(args.manifest, args.theme, args.source, args.output, notes, args.source_root), ensure_ascii=False))
|
|
return 0
|
|
except (ref.InputError, OSError, ValueError, KeyError, TypeError, ET.ParseError, ImportError, RuntimeError) as error:
|
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|