84 lines
4.3 KiB
Python
84 lines
4.3 KiB
Python
#!/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, output: Path):
|
|
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)
|
|
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
|
|
source = boundary_file(root_path, asset["path"], "SVG asset")
|
|
contract_path = boundary_file(root_path, asset["paint_contract"], "paint contract")
|
|
contract = ref.read_json(contract_path, (1,))
|
|
text = source.read_text(encoding="utf-8")
|
|
ref.require(not re.search(r"<!DOCTYPE|<!ENTITY", text, re.I), "SVG declarations/entities forbidden")
|
|
root = ET.fromstring(text)
|
|
ref.require(not any(node.tag.rsplit("}", 1)[-1] in ("style", "script", "foreignObject") for node in root.iter()),
|
|
"Material helper requires explicit attributes without stylesheets or active elements")
|
|
by_id = {}
|
|
for node in root.iter():
|
|
if node.get("id"):
|
|
ref.require(node.get("id") not in by_id, "SVG IDs must be unique")
|
|
by_id[node.get("id")] = node
|
|
targets = {(target["selector"], target["channel"]): target["palette_index"]
|
|
for binding in theme["asset_bindings"] if binding["asset"] == role for target in binding["targets"]}
|
|
changes = []
|
|
for rule in contract["rules"]:
|
|
selector, channel = rule["selector"], rule["channel"]
|
|
ref.require(selector[1:] in by_id, f"Paint selector missing: {selector}")
|
|
node = by_id[selector[1:]]
|
|
ref.require(not re.search(r"(?:^|;)\s*" + channel + r"\s*:", node.get("style", ""), re.I),
|
|
f"Inline style overrides target channel: {selector}/{channel}")
|
|
value = node.get(channel)
|
|
ref.require(value is not None, f"Target paint must be explicit, not inherited: {selector}/{channel}")
|
|
if rule["policy"] in ("fixed", "structural"):
|
|
ref.require(value.lower() == rule["value"].lower(), f"Source disagrees with fixed/structural paint: {selector}/{channel}")
|
|
else:
|
|
ref.require(value.lower() != "none", f"Palette target is unpainted: {selector}/{channel}")
|
|
color = theme["palette"][targets[(selector, channel)]]
|
|
node.set(channel, color)
|
|
changes.append({"selector": selector, "channel": channel, "from": value, "to": color})
|
|
ref.require(output.suffix.lower() == ".svg", "Output must end in .svg")
|
|
ref.require(not output.exists() and not output.is_symlink(), "Paint helper refuses to overwrite output")
|
|
ref.require(not any(path.is_symlink() for path in output.parents), "Output ancestors cannot be symlinks")
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
with output.open("xb") as stream:
|
|
ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
|
|
return {"asset": role, "theme": theme_id, "output": str(output), "paint_changes": changes,
|
|
"verification_scope": "explicit SVG channel transformation; rendered material appearance unverified"}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--manifest", type=Path, required=True)
|
|
parser.add_argument("--theme", required=True)
|
|
parser.add_argument("--asset", required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
try:
|
|
print(json.dumps(paint(args.manifest, args.theme, args.asset, args.output), ensure_ascii=False))
|
|
return 0
|
|
except (ref.InputError, OSError, ValueError, KeyError, TypeError, ET.ParseError) as error:
|
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|