#!/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())