#!/usr/bin/env python3 """Apply an external theme's whitelisted SVG fill/stroke bindings, locally.""" from __future__ import annotations import argparse import json from pathlib import Path import re import sys import xml.etree.ElementTree as ET import template_ref as ref from native_export import boundary_file, regular def paint(manifest_path: Path, theme_id: str, role: str | None, output: Path, material_path: Path | None = None): manifest_path = regular(manifest_path, "manifest") manifest = ref.load_manifest(manifest_path) ref.require(manifest["schema_version"] == 2, "Paint application requires manifest schema 2") ref.require(theme_id in manifest["themes"], "Unknown theme") _, theme = ref.load_theme(manifest_path.parent, manifest["themes"][theme_id], theme_id) if material_path is None: asset = theme["assets"].get(role) ref.require(asset is not None and "paint_contract" in asset, "Asset needs an explicit SVG paint contract") root_path = manifest_path.parent bindings = [target for binding in theme["asset_bindings"] if binding["asset"] == role for target in binding["targets"]] else: ref.require(role is None, "Choose a template asset or a local material, not both") material_path = regular(material_path, "material descriptor") asset = ref.read_json(material_path, (1,)) ref.require(set(asset) == {"schema_version", "id", "path", "paint_contract", "bindings"}, "Material descriptor requires exactly schema_version/id/path/paint_contract/bindings") role = ref.identifier(asset["id"], "material ID") root_path = material_path.parent bindings = asset["bindings"] ref.require(isinstance(bindings, list), "Material bindings must be an array") _, contract = ref.load_paint_contract(root_path, asset, role) source = boundary_file(root_path, asset["path"], "SVG asset") boundary_file(root_path, asset["paint_contract"], "paint contract") allowed = {(rule["selector"], rule["channel"]) for rule in contract["rules"] if rule["policy"] == "palette"} targets = {} for target in bindings: ref.require(isinstance(target, dict) and set(target) == {"selector", "channel", "palette_index"}, "Binding requires exactly selector/channel/palette_index") key = (ref.string(target["selector"], "selector"), ref.string(target["channel"], "channel")) ref.require(key in allowed and key not in targets, "Unknown, fixed, structural or duplicate paint binding") index = ref.string(target["palette_index"], "palette index") ref.require(index in theme["palette"], "Unknown palette index") targets[key] = index ref.require(set(targets) == allowed, "Palette bindings must cover exactly the permitted channels") if material_path is not None: ref.require(bool(allowed), "A new icon needs at least one adjustable palette channel") text = source.read_text(encoding="utf-8") ref.require(not re.search(r"