#!/usr/bin/env python3 """Extract a ready-to-paste mxCell snippet from any bundled SAP draw.io asset. Examples: extract_asset.py --list --kind connector extract_asset.py "direct one-directional" --kind connector --id flow1 --x 100 --y 200 extract_asset.py "database non sap" --kind generic-icon --id db1 --x 300 --y 160 """ from __future__ import annotations import argparse import copy import json import re import sys import xml.etree.ElementTree as ET from pathlib import Path from typing import Any from xml.sax.saxutils import escape, unescape HERE = Path(__file__).resolve().parent ASSETS = HERE.parent / "assets" INDEX = ASSETS / "asset-index.json" LIB_DIR = ASSETS / "libraries" def load_index() -> dict[str, Any]: if not INDEX.exists(): print(f"asset index not found at {INDEX}; run build_asset_index.py first", file=sys.stderr) sys.exit(1) return json.loads(INDEX.read_text(encoding="utf-8")) def load_library_entry(entry: dict[str, Any]) -> dict[str, Any]: path = LIB_DIR / entry["library"] raw = path.read_text(encoding="utf-8") raw = re.sub(r"", "", raw, flags=re.S).strip() body = raw[len("") : -len("")].strip() return json.loads(body)[entry["entry"]] def normalize(text: str) -> str: return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() def slugify(text: str) -> str: return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") def tokens(text: str) -> set[str]: return {token for token in normalize(text).split() if token} def find_asset(index: dict[str, Any], query: str, kind: str | None) -> tuple[str, dict[str, Any]] | None: assets = index["assets"] query_raw = query.strip() query_slug = slugify(query) query_tokens = tokens(query) filtered = [ (key, asset) for key, asset in assets.items() if kind is None or asset["kind"] == kind ] for key, asset in filtered: key_short = key.split(":", 1)[-1] if ( query_raw == key or query_raw == key_short or query_slug == slugify(key) or query_slug == slugify(key_short) ): return key, asset if query_slug in {slugify(alias) for alias in asset.get("aliases", [])}: return key, asset candidates: list[tuple[int, str, dict[str, Any]]] = [] for key, asset in filtered: pool = tokens(key) | tokens(asset["display"]) | tokens(asset.get("title", "")) for alias in asset.get("aliases", []): pool |= tokens(alias) if query_tokens and query_tokens <= pool: candidates.append((len(pool), key, asset)) if len(candidates) == 1: _, key, asset = candidates[0] return key, asset if len(candidates) > 1: candidates.sort(key=lambda item: (item[0], item[1])) top = candidates[0] if len(candidates) == 1 or top[0] < candidates[1][0]: return top[1], top[2] print(f"ambiguous '{query}' — {len(candidates)} matches:", file=sys.stderr) for _, key, asset in candidates[:12]: print(f" {key} — {asset['display']}", file=sys.stderr) sys.exit(2) substring = [ (key, asset) for key, asset in filtered if normalize(query) in normalize(asset["display"]) or query_slug in key ] if len(substring) == 1: return substring[0] if len(substring) > 1: print(f"ambiguous '{query}' — {len(substring)} matches:", file=sys.stderr) for key, asset in substring[:12]: print(f" {key} — {asset['display']}", file=sys.stderr) sys.exit(2) return None def snap(value: float) -> int: return round(value / 10) * 10 def mxcell_for_data(asset: dict[str, Any], library_entry: dict[str, Any], args: argparse.Namespace) -> str: width = snap(args.w if args.w is not None else int(asset.get("width") or 40)) height = snap(args.h if args.h is not None else int(asset.get("height") or 40)) label = args.label if args.label is not None else asset["display"] label_xml = escape(label, {'"': """}) style = ( "shape=image;verticalLabelPosition=bottom;verticalAlign=top;aspect=fixed;" f"imageAspect=0;image={library_entry['data']};" ) return ( f'' f'' "" ) def set_edge_points(cell: ET.Element, x: int, y: int, width: int, height: int) -> None: geom = cell.find("mxGeometry") if geom is None: return source = geom.find("mxPoint[@as='sourcePoint']") target = geom.find("mxPoint[@as='targetPoint']") if source is not None: source.set("x", str(x)) source.set("y", str(y)) if target is not None: target.set("x", str(x + width)) target.set("y", str(y + height)) def mx_cells_for_xml(asset: dict[str, Any], library_entry: dict[str, Any], args: argparse.Namespace) -> str: root = ET.fromstring(unescape(library_entry["xml"])) cells = [copy.deepcopy(c) for c in root.iter("mxCell") if c.get("id") not in {"0", "1"}] if not cells: raise ValueError(f"{asset['display']} contains no extractable mxCell") id_map: dict[str, str] = {} for index, cell in enumerate(cells): old_id = cell.get("id") if old_id: id_map[old_id] = args.id if index == 0 else f"{args.id}-{index + 1}" original_top_ids = {cell.get("id") for cell in cells if cell.get("parent") == "1"} x = snap(args.x) y = snap(args.y) width = snap(args.w) if args.w is not None else snap(int(asset.get("width") or 120)) height = snap(args.h) if args.h is not None else 0 for cell in cells: old_id = cell.get("id") old_parent = cell.get("parent") if old_id in id_map: cell.set("id", id_map[old_id]) if old_parent == "1": cell.set("parent", args.parent) elif old_parent in id_map: cell.set("parent", id_map[old_parent]) for ref in ("source", "target"): if cell.get(ref) in id_map: cell.set(ref, id_map[cell.get(ref)]) if args.label is not None and old_id in original_top_ids: cell.set("value", escape(args.label, {'"': """})) geom = cell.find("mxGeometry") if geom is not None and old_id in original_top_ids and cell.get("vertex") == "1": geom.set("x", str(x)) geom.set("y", str(y)) if args.w is not None: geom.set("width", str(width)) if args.h is not None: geom.set("height", str(snap(args.h))) if cell.get("edge") == "1" and old_id in original_top_ids: set_edge_points(cell, x, y, width, height) return "\n".join(ET.tostring(cell, encoding="unicode") for cell in cells) def emit_asset(asset: dict[str, Any], args: argparse.Namespace) -> str: library_entry = load_library_entry(asset) if "data" in library_entry: return mxcell_for_data(asset, library_entry, args) if "xml" in library_entry: return mx_cells_for_xml(asset, library_entry, args) raise ValueError(f"unsupported library entry for {asset['display']}") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("query", nargs="?") ap.add_argument("--list", action="store_true") ap.add_argument("--kind", help="Filter by asset kind, e.g. btp-service-icon, generic-icon, connector") ap.add_argument("--x", type=int, default=0) ap.add_argument("--y", type=int, default=0) ap.add_argument("--w", type=int) ap.add_argument("--h", type=int) ap.add_argument("--id", default="asset1") ap.add_argument("--parent", default="1") ap.add_argument("--label") args = ap.parse_args() index = load_index() assets = index["assets"] if args.list: for key, asset in assets.items(): if args.kind and asset["kind"] != args.kind: continue print(f"{key:70s} {asset['display']}") return 0 if not args.query: ap.print_usage(sys.stderr) return 2 match = find_asset(index, args.query, args.kind) if not match: print(f"no asset matches '{args.query}'", file=sys.stderr) return 1 key, asset = match print(emit_asset(asset, args)) print(f"# matched: {key} — {asset['display']}", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())