Files
Templated-PPT-Master/scripts/template_ref.py
T

443 lines
21 KiB
Python

#!/usr/bin/env python3
"""Inspect, export style or bind one local presentation template. Standard library only."""
from __future__ import annotations
import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import sys
import zipfile
ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
PAINT_SELECTOR = re.compile(r"^#[A-Za-z_][A-Za-z0-9_.:-]*$")
ROUTES = ("native", "image-rebuild")
class InputError(ValueError):
pass
def require(condition: bool, message: str) -> None:
if not condition:
raise InputError(message)
def string(value: object, label: str) -> str:
require(isinstance(value, str) and bool(value.strip()), f"{label}: expected non-empty string")
return value
def identifier(value: object, label: str) -> str:
value = string(value, label)
require(bool(ID.fullmatch(value)), f"{label}: expected lowercase hyphenated ID")
return value
def strings(value: object, label: str, *, nonempty: bool = False) -> list[str]:
require(isinstance(value, list), f"{label}: expected string array")
for entry in value:
string(entry, label)
require(len(value) == len(set(value)), f"{label}: duplicate entries")
require(not nonempty or bool(value), f"{label}: must not be empty")
return value
def read_json(path: Path, schemas: tuple[int, ...] | None = (1, 2)) -> dict:
require(path.is_file(), f"Missing JSON file: {path}")
def unique_keys(pairs):
result = {}
for key, value in pairs:
require(key not in result, f"{path.name}: duplicate JSON key {key}")
result[key] = value
return result
def invalid_constant(value):
raise InputError(f"{path.name}: invalid JSON constant {value}")
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys,
parse_constant=invalid_constant)
require(isinstance(value, dict), f"{path.name}: expected object")
if schemas is not None:
require(type(value.get("schema_version")) is int and value["schema_version"] in schemas,
f"{path.name}: unsupported schema_version")
return value
def local_path(root: Path, value: object, label: str) -> Path:
raw = string(value, label)
relative = PurePosixPath(raw)
require(not relative.is_absolute() and ".." not in relative.parts
and "\\" not in raw and ":" not in raw and raw != ".",
f"{label}: expected package-relative POSIX path")
path = (root / raw).resolve()
require(path.is_relative_to(root.resolve()), f"{label}: path escapes package")
return path
def file_path(root: Path, value: object, label: str) -> Path:
path = local_path(root, value, label)
require(path.is_file(), f"{label}: missing file {path}")
return path
def load_paint_contract(root: Path, asset: dict, role: str) -> tuple[Path, dict]:
label = f"asset {role}.paint_contract"
asset_path = file_path(root, asset.get("path"), f"asset {role}.path")
require(asset_path.suffix.lower() == ".svg", f"{label}: only SVG assets support paint contracts")
path = file_path(root, asset.get("paint_contract"), label)
value = read_json(path, (1,))
string(value.get("asset_id"), f"{label}.asset_id")
require(value.get("default_policy") == "preserve", f"{label}.default_policy: expected preserve")
rules = value.get("rules")
require(isinstance(rules, list), f"{label}.rules: expected array")
ids, channels = set(), set()
for rule in rules:
require(isinstance(rule, dict), f"{label}.rule: expected object")
rule_id = string(rule.get("id"), f"{label}.rule.id")
require(rule_id not in ids, f"{label}: duplicate rule ID {rule_id}")
ids.add(rule_id)
selector = string(rule.get("selector"), f"{label}.rule.selector")
require(bool(PAINT_SELECTOR.fullmatch(selector)),
f"{label}.rule.selector: expected #stable-ID")
channel = rule.get("channel")
require(channel in ("fill", "stroke"), f"{label}.rule.channel: expected fill or stroke")
key = (selector, channel)
require(key not in channels, f"{label}: duplicate selector/channel {selector}/{channel}")
channels.add(key)
policy = rule.get("policy")
require(policy in ("palette", "fixed", "structural"), f"{label}.rule.policy: unknown policy")
if policy == "palette":
string(rule.get("token"), f"{label}.rule.token")
else:
require("token" not in rule, f"{label}.rule.token: only palette rules have tokens")
literal = string(rule.get("value"), f"{label}.rule.value")
if policy == "structural":
require(literal == "none", f"{label}.rule.value: structural requires none")
return path, value
def load_theme(root: Path, entry: dict, theme_id: str) -> tuple[Path, dict]:
path = file_path(root, entry.get("theme"), f"theme {theme_id}.theme")
value = read_json(path, (1,))
require(value.get("theme_id") == theme_id, f"theme {theme_id}: theme_id mismatch")
palette = value.get("palette")
require(isinstance(palette, dict) and bool(palette), "palette: expected non-empty object")
for index, color in palette.items():
string(index, "palette index")
require(isinstance(color, str) and bool(COLOR.fullmatch(color)),
f"palette {index}: expected #RRGGBB")
assets = value.get("assets")
require(isinstance(assets, dict), "assets: expected object")
contracts = {}
for role, asset in assets.items():
string(role, "asset role")
require(isinstance(asset, dict), f"asset {role}: expected object")
file_path(root, asset.get("path"), f"asset {role}.path")
if "paint_contract" in asset:
_, contract = load_paint_contract(root, asset, role)
contracts[role] = {(rule["selector"], rule["channel"]): rule for rule in contract["rules"]}
bindings = value.get("asset_bindings")
require(isinstance(bindings, list), "asset_bindings: expected array")
bound_channels = set()
for binding in bindings:
require(isinstance(binding, dict), "asset binding: expected object")
role = string(binding.get("asset"), "binding.asset")
require(role in assets, f"Unknown asset role: {role}")
targets = binding.get("targets")
require(isinstance(targets, list) and bool(targets), "binding.targets: expected non-empty array")
for target in targets:
require(isinstance(target, dict), "binding target: expected object")
string(target.get("selector"), "target.selector")
require(target.get("channel") in ("fill", "stroke"), "target.channel: expected fill or stroke")
index = string(target.get("palette_index"), "target.palette_index")
require(index in palette, f"Unknown palette index: {index}")
if role in contracts:
key = (target["selector"], target["channel"])
rule = contracts[role].get(key)
require(rule is not None and rule["policy"] == "palette",
f"asset {role}: target must match a palette paint rule: {key[0]}/{key[1]}")
bound = (role, *key)
require(bound not in bound_channels,
f"asset {role}: duplicate palette binding {key[0]}/{key[1]}")
bound_channels.add(bound)
for role, rules in contracts.items():
for (selector, channel), rule in rules.items():
if rule["policy"] == "palette":
require((role, selector, channel) in bound_channels,
f"asset {role}: missing palette binding {selector}/{channel}")
return path, value
def load_manifest(path: Path) -> dict:
value = read_json(path)
identifier(value.get("template_id"), "template_id")
require(bool(VERSION.fullmatch(string(value.get("version"), "version"))), "version: expected SemVer")
for key in ("name", "description"):
string(value.get(key), key)
require(value.get("status") in ("proposal", "approved", "released", "deprecated"), "Invalid status")
file_path(path.parent, value.get("spec"), "spec")
require("structure" in value, "Missing structure field")
if value["structure"] is not None:
file_path(path.parent, value["structure"], "structure")
v2 = value["schema_version"] == 2
if v2:
read_json(file_path(path.parent, value.get("style"), "style"), None)
if "page_library" in value:
library = read_json(file_path(path.parent, value["page_library"], "page_library"), (1,))
require(library.get("template_id") == value["template_id"],
"page_library: template_id mismatch")
variants_key = "themes" if v2 else "modes"
variants = value.get(variants_key)
require(isinstance(variants, dict) and bool(variants), f"{variants_key}: expected non-empty object")
for variant, entry in variants.items():
identifier(variant, f"{variants_key} ID")
require(isinstance(entry, dict), f"{variant}: expected object")
string(entry.get("name"), f"{variant}.name")
for key in ("workspace", "native_template"):
require(key in entry, f"{variant}: missing {key}")
if entry[key] is not None or (v2 and key == "workspace"):
local_path(path.parent, entry[key], f"{variant}.{key}")
if v2:
load_theme(path.parent, entry, variant)
layouts = value.get("layouts")
require(isinstance(layouts, list) and bool(layouts), "layouts: expected non-empty array")
seen = set()
for entry in layouts:
require(isinstance(entry, dict), "layout: expected object")
key = identifier(entry.get("id"), "layout ID")
require(key not in seen, f"Duplicate layout: {key}")
seen.add(key)
string(entry.get("name"), f"layout {key}.name")
strings(entry.get("slots"), f"layout {key}.slots")
routes = strings(value.get("routes"), "routes", nonempty=True)
require(all(route in ROUTES for route in routes), "Unsupported route")
policy = value.get("editable_policy")
require(isinstance(policy, dict), "editable_policy: expected object")
for key in ("native", "image_allowed", "disclose"):
strings(policy.get(key), f"editable_policy.{key}")
return value
def summary(path: Path, value: dict) -> dict:
# Preserve extension metadata for review; only declared paths are interpreted.
return {**deepcopy(value), "manifest": str(path),
"spec": str(file_path(path.parent, value["spec"], "spec"))}
def digest(path: Path) -> str:
with path.open("rb") as stream:
checksum = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
checksum.update(chunk)
return checksum.hexdigest()
def selected_variant(value: dict, args: argparse.Namespace) -> tuple[str, dict]:
v2 = value["schema_version"] == 2
key, other = ("theme", "mode") if v2 else ("mode", "theme")
require(getattr(args, other, None) is None, f"v{value['schema_version']} uses --{key}, not --{other}")
selected = getattr(args, key, None)
require(selected is not None, f"v{value['schema_version']} requires --{key}")
variants = value["themes" if v2 else "modes"]
require(selected in variants, f"Unknown {key}: {selected}")
return selected, variants[selected]
def selected_layout(value: dict, layout: str | None) -> dict | None:
if layout is None:
return None
result = next((entry for entry in value["layouts"] if entry["id"] == layout), None)
require(result is not None, f"Unknown layout: {layout}")
return result
def workspace_inputs(root: Path, workspace: Path) -> set[Path]:
inputs = set()
# Exports are derived evidence. Hash the adapter's actual authoring surfaces.
for folder in ("templates", "images", "icons"):
source = workspace / folder
if not source.exists():
continue
require(source.resolve().is_relative_to(root), f"{folder}: path escapes package")
for candidate in source.rglob("*"):
resolved = candidate.resolve()
require(resolved.is_relative_to(root), f"{candidate.name}: path escapes package")
if resolved.is_file():
inputs.add(resolved)
return inputs
def input_hashes(root: Path, inputs: set[Path]) -> list[dict]:
return [{"path": str(item.relative_to(root)), "sha256": digest(item)} for item in sorted(inputs)]
def style_inputs(root: Path, value: dict, variant: dict, selected: str) -> tuple[Path, dict, Path, dict, set[Path]]:
style_path = file_path(root, value["style"], "style")
style = read_json(style_path, None)
theme_path, theme = load_theme(root, variant, selected)
inputs = {style_path, theme_path}
theme = deepcopy(theme)
for role, asset in theme["assets"].items():
asset_path = file_path(root, asset["path"], f"asset {role}.path")
inputs.add(asset_path)
if "paint_contract" in asset:
contract_path, contract = load_paint_contract(root, asset, role)
inputs.add(contract_path)
asset["paint_contract"] = str(contract_path)
asset["paint_contract_data"] = contract
asset["path"] = str(asset_path)
return style_path, style, theme_path, theme, inputs
def export_style(path: Path, value: dict, args: argparse.Namespace) -> dict:
require(value["schema_version"] == 2, "export-style requires a v2 manifest")
selected, variant = selected_variant(value, args)
layout = selected_layout(value, args.layout)
root = path.parent
style_path, style, theme_path, theme, inputs = style_inputs(root, value, variant, selected)
spec = file_path(root, value["spec"], "spec")
structure = file_path(root, value["structure"], "structure") if value["structure"] is not None else None
workspace = local_path(root, variant["workspace"], "workspace")
native = local_path(root, variant["native_template"], "native_template") if variant["native_template"] is not None else None
inputs.update({path, spec})
if structure is not None:
inputs.add(structure)
inputs.update(workspace_inputs(root, workspace))
if native is not None and native.is_file():
inputs.add(native)
library = file_path(root, value["page_library"], "page_library") if "page_library" in value else None
if library is not None:
inputs.add(library)
return {"schema_version": 2, "template_id": value["template_id"], "version": value["version"],
"status": value["status"], "production_ready": value["status"] == "released",
"manifest": str(path), "manifest_sha256": digest(path), "theme": selected,
"layout": args.layout, "layout_definition": deepcopy(layout),
"spec": str(spec), "structure": str(structure) if structure is not None else None,
"workspace": str(workspace), "native_template": str(native) if native is not None else None,
"style_path": str(style_path), "theme_path": str(theme_path),
"style": style, "theme_data": theme, "editable_policy": deepcopy(value["editable_policy"]),
"page_library": str(library) if library is not None else None,
"inputs": input_hashes(root, inputs),
"verification_scope": "style review and alignment only; no PPT creation, selector execution or release acceptance"}
def resolve(path: Path, value: dict, args: argparse.Namespace) -> dict:
require(value["status"] == "released", f"NOT_RELEASED: status={value['status']}; inspect or export-style only")
selected, variant = selected_variant(value, args)
require(args.route in value["routes"], f"Route unavailable: {args.route}")
selected_layout(value, args.layout)
root = path.parent
structure = file_path(root, value["structure"], "structure")
spec = file_path(root, value["spec"], "spec")
workspace = local_path(root, variant["workspace"], "workspace")
require(workspace.is_dir(), f"Missing workspace: {workspace}")
file_path(workspace, "templates/design_spec.md", "workspace specification")
require(bool(list((workspace / "templates").glob("*.svg"))), "Workspace has no SVG prototypes")
native = None
v2 = value["schema_version"] == 2
if variant["native_template"] is not None or not v2:
native = file_path(root, variant["native_template"], "native_template")
require(native.suffix.lower() == ".pptx", "native_template must be .pptx")
require("exports" not in native.relative_to(root).parts,
"native_template cannot use an exports review artifact")
with zipfile.ZipFile(native) as package:
require({"[Content_Types].xml", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels"}
<= set(package.namelist()), "Incomplete PPTX ZIP members")
inputs = {path, structure, spec} | workspace_inputs(root, workspace)
if native is not None:
inputs.add(native)
result = {"schema_version": value["schema_version"], "template_id": value["template_id"],
"version": value["version"], "manifest": str(path), "manifest_sha256": digest(path),
"theme" if v2 else "mode": selected, "layout": args.layout, "route": args.route,
"workflow": args.workflow, "workspace": str(workspace),
"native_template": str(native) if native is not None else None,
"spec": str(spec), "structure": str(structure),
"verification_scope": "metadata, local paths, input hashes, workspace shape and declared PPTX ZIP members only"}
if v2:
style_path, _, theme_path, _, theme_files = style_inputs(root, value, variant, selected)
inputs.update(theme_files)
result.update(status=value["status"], production_ready=True,
style_path=str(style_path), theme_path=str(theme_path))
library = file_path(root, value["page_library"], "page_library") if "page_library" in value else None
if library is not None:
inputs.add(library)
result["page_library"] = str(library) if library is not None else None
result["inputs"] = input_hashes(root, inputs)
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
listing = sub.add_parser("list")
listing.add_argument("--catalog", type=Path, required=True)
for command in ("inspect", "resolve", "export-style"):
item = sub.add_parser(command)
item.add_argument("--manifest", type=Path, required=True, action="append")
if command != "inspect":
selection = item.add_mutually_exclusive_group(required=True)
selection.add_argument("--mode", action="append")
selection.add_argument("--theme", action="append")
item.add_argument("--layout", action="append")
item.add_argument("--out", type=Path)
if command == "resolve":
item.add_argument("--route", choices=ROUTES, required=True)
item.add_argument("--workflow", choices=("interactive-page", "batch"), required=True)
args = parser.parse_args()
try:
if hasattr(args, "manifest"):
require(len(args.manifest) == 1, "Only one --manifest may be active")
args.manifest = args.manifest[0]
# Reject repeated selectors instead of argparse's usual last-value-wins behavior.
for key in ("mode", "theme", "layout"):
selections = getattr(args, key, None)
if selections is not None:
require(len(selections) == 1, f"Only one --{key} may be active")
setattr(args, key, selections[0])
if args.command == "list":
catalog = args.catalog.resolve()
entries = read_json(catalog).get("templates")
require(isinstance(entries, list), "templates: expected array")
output, seen = [], set()
for entry in entries:
require(isinstance(entry, dict), "catalog entry: expected object")
path = file_path(catalog.parent, entry.get("manifest"), "manifest")
value = load_manifest(path)
require(value["template_id"] not in seen, f"Duplicate template: {value['template_id']}")
seen.add(value["template_id"])
output.append(summary(path, value))
else:
path = args.manifest.resolve()
value = load_manifest(path)
if args.command == "inspect":
output = summary(path, value)
elif args.command == "export-style":
output = export_style(path, value, args)
else:
output = resolve(path, value, args)
encoded = json.dumps(output, ensure_ascii=False, indent=2) + "\n"
if args.command in ("resolve", "export-style") and args.out is not None:
with args.out.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(encoded)
print(encoded, end="")
return 0
except (InputError, OSError, ValueError, zipfile.BadZipFile) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())