#!/usr/bin/env python3 """Extract a ready-to-paste for a named SAP BTP service icon. Usage: extract_icon.py [--x X --y Y --w W --h H --id ID --parent P --label "text"] extract_icon.py --list Search is fuzzy (case-insensitive substring + slug alias). Emits the to stdout. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from xml.sax.saxutils import escape HERE = Path(__file__).resolve().parent INDEX = HERE.parent / "assets" / "icon-index.json" ASSET_INDEX = HERE.parent / "assets" / "asset-index.json" try: from extract_asset import emit_asset as emit_general_asset from extract_asset import find_asset as find_general_asset except ImportError: # pragma: no cover - fallback for copied standalone script use emit_general_asset = None find_general_asset = None def load_index() -> dict: if not INDEX.exists(): print(f"icon index not found at {INDEX}; run build_icon_index.py first", file=sys.stderr) sys.exit(1) return json.loads(INDEX.read_text(encoding="utf-8")) def load_asset_index() -> dict | None: if not ASSET_INDEX.exists() or emit_general_asset is None or find_general_asset is None: return None return json.loads(ASSET_INDEX.read_text(encoding="utf-8")) def _normalize(text: str) -> str: """Strip HTML entities ( , &), lowercase, collapse non-alphanumeric to single spaces.""" t = text.lower() # decode common HTML newline / ampersand entities t = re.sub(r"&#?\w+;", " ", t) # kill the -10- slug artifact from earlier encoding t = re.sub(r"(^|-)10(-|$)", r"\1 \2", t) return re.sub(r"[^a-z0-9]+", " ", t).strip() def _tokens(text: str) -> set[str]: return {tok for tok in _normalize(text).split() if tok} def is_backend_system_query(query: str) -> bool: """Return True for backend product names that are not BTP service icons.""" normalized = _normalize(query) compact = re.sub(r"[^a-z0-9]+", "", query.lower()) if normalized in {"sap s 4hana", "sap s 4hana cloud", "s 4hana", "s 4hana cloud"}: return True if compact in {"s4hana", "saps4hana", "s4hanacloud", "saps4hanacloud"}: return True return False def backend_system_guidance(query: str) -> str: return ( f"'{query}' is a backend system/product label, not a BTP service icon. " "Use extract_asset.py \"sap-s-4hana\" --kind sap-brand-name for the label " "or extract_asset.py \"on-premise-sap\" --kind generic-icon for a generic backend icon." ) # Hand-curated aliases for common SAP service abbreviations / nicknames. # These map a user-typed query → a slug substring that uniquely identifies the icon. COMMON_ALIASES: dict[str, str] = { "xsuaa": "sap-authorization-10-and-10-trust-management-service", "uaa": "sap-authorization-10-and-10-trust-management-service", "auth trust": "sap-authorization-10-and-10-trust-management-service", "ias": "identity-10-authentication", "ips": "identity-provisioning", "btp cockpit": "sap-btp-cockpit", "hana": "sap-hana-cloud", "hana cloud": "sap-hana-cloud", "cf": "cloud-10-foundry-runtime", "cloud foundry": "cloud-10-foundry-runtime", "cap": "cloud-application-programming", "destination": "sap-destination-10-service", "cc": "cloud-10-connector", "connector": "cloud-10-connector", "abap env": "abap-environment", "abap environment": "abap-environment", "build apps": "sap-build-apps", "build process": "sap-build-process-automation", "build code": "sap-build-code", "build work zone": "sap-build-work-zone-10-standard-edition", "sap build work zone": "sap-build-work-zone-10-standard-edition", "work zone": "sap-build-work-zone-10-standard-edition", "joule": "joule-studio", "task center": "sap-task-center", "cpi": "cloud-10-integration", "integration suite": "integration-suite", } def find(index: dict, query: str) -> tuple[str, dict] | None: if is_backend_system_query(query): return None q = query.lower().strip() qslug = re.sub(r"[^a-z0-9]+", "-", q).strip("-") # curated alias table if q in COMMON_ALIASES: slug = COMMON_ALIASES[q] if slug in index: return slug, index[slug] # exact slug if qslug in index: return qslug, index[qslug] # alias match for slug, entry in index.items(): if qslug in entry["aliases"] or qslug == entry["display"].lower(): return slug, entry qtokens = _tokens(q) if not qtokens: return None # token-subset match: every query token appears in (display | slug | aliases) token_candidates: list[tuple[str, dict]] = [] for slug, entry in index.items(): pool = _tokens(slug) | _tokens(entry["display"]) | {a for alias in entry["aliases"] for a in _tokens(alias)} if qtokens <= pool: token_candidates.append((slug, entry)) if len(token_candidates) == 1: return token_candidates[0] if len(token_candidates) > 1: # prefer the candidate whose normalized display is shortest (tightest match) token_candidates.sort(key=lambda se: len(_normalize(se[1]["display"]))) # if the top candidate is strictly shorter than the runner-up, take it top = _normalize(token_candidates[0][1]["display"]) second = _normalize(token_candidates[1][1]["display"]) if len(top) < len(second): return token_candidates[0] print(f"ambiguous '{query}' — {len(token_candidates)} matches:", file=sys.stderr) for s, e in token_candidates[:10]: print(f" {s} — {e['display']}", file=sys.stderr) sys.exit(2) # substring fallback candidates = [(s, e) for s, e in index.items() if q in e["display"].lower() or qslug in s] if len(candidates) == 1: return candidates[0] if len(candidates) > 1: print(f"ambiguous '{query}' — {len(candidates)} matches:", file=sys.stderr) for s, e in candidates[:10]: print(f" {s} — {e['display']}", file=sys.stderr) sys.exit(2) return None def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("query", nargs="?") ap.add_argument("--list", action="store_true") ap.add_argument("--x", type=int, default=0) ap.add_argument("--y", type=int, default=0) # Defaults match the SAP corpus convention: 32x32 is the dominant icon # size (used 224x across the 71 bundled templates), then 48x48 (157x). # The previous default 64x80 caused icons to overlap card text — the # most common visible bug. Override with --w 48 --h 48 only when the # icon is the focal anchor of a zone (e.g. a brand mark beside a # top-left zone label). ap.add_argument("--w", type=int, default=32) ap.add_argument("--h", type=int, default=32) ap.add_argument("--id", default="icon1") ap.add_argument("--parent", default="1") ap.add_argument("--label", default=None, help="Override icon label text") args = ap.parse_args() asset_index = load_asset_index() index = load_index() if args.list: if asset_index: assets = asset_index["assets"] for key in sorted(assets): entry = assets[key] if entry["kind"] == "btp-service-icon": slug = key.split(":", 1)[1] print(f"{slug:60s} {entry['display']}") else: for slug in sorted(index): print(f"{slug:60s} {index[slug]['display']}") return 0 if not args.query: ap.print_usage(sys.stderr) return 2 if is_backend_system_query(args.query): print(backend_system_guidance(args.query), file=sys.stderr) return 1 prefer_icon_index = args.query.lower().strip() in COMMON_ALIASES if asset_index and find_general_asset and emit_general_asset and not prefer_icon_index: try: asset_match = find_general_asset(asset_index, args.query, "btp-service-icon") except SystemExit: asset_match = None if asset_match: slug, asset = asset_match print(emit_general_asset(asset, args)) print(f"# matched: {slug} — {asset['display']}", file=sys.stderr) return 0 match = find(index, args.query) if not match: print(f"no icon matches '{args.query}'", file=sys.stderr) return 1 slug, entry = match style = entry["style"] label = args.label if args.label is not None else entry["label"] label_xml = escape(label, {'"': """}) # Snap to 10-px grid (quiet fix) x = round(args.x / 10) * 10 y = round(args.y / 10) * 10 w = round(args.w / 10) * 10 h = round(args.h / 10) * 10 cell = ( f'' f'' f"" ) print(cell) print(f"# matched: {slug} — {entry['display']}", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())