Files

206 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Find AI / LLM brand logos (OpenAI, Claude, Gemini, ...) as draw.io styles.
draw.io's bundled shape libraries have no modern AI/LLM brand logos, so an
"LLM app architecture" renders as generic boxes. This resolves a brand name to a
draw.io `image` style that references the matching SVG from the lobe-icons set
(https://github.com/lobehub/lobe-icons, MIT) on the unpkg CDN.
python3 aiicons.py "openai"
python3 aiicons.py "claude" --json
python3 aiicons.py "langchain" --variant mono --size 48
The icon is referenced by URL (data/lobe-icons.json carries only the name list,
not the assets), so draw.io fetches it from the CDN when the diagram is rendered
or opened. That means **network is required at render time**; an offline export
draws a blank box. Use --embed to fetch the SVG once and inline it as a
self-contained data URI instead (portable, no network at render time).
The logos are trademarks of their respective owners and are referenced here for
identification only — the same basis on which draw.io ships AWS/Azure icons.
Usage: python3 aiicons.py <query> [--limit N] [--variant color|mono|text]
[--size PX] [--embed] [--json] [--list]
"""
import argparse
import base64
import json
import os
import re
import sys
import urllib.request
MANIFEST = os.path.join(os.path.dirname(__file__), "..", "data", "lobe-icons.json")
STYLE = ("shape=image;html=1;imageAspect=0;aspect=fixed;"
"verticalLabelPosition=bottom;verticalAlign=top;image=")
_VARIANT = re.compile(r"-(?:color|text(?:-[a-z]{2})?|brand(?:-color)?)$")
# Common RAG/LLM data stores that lobe-icons lacks, mapped to simple-icons
# slugs (https://simpleicons.org, CC0). Served from the simple-icons CDN. Each
# slug below is verified to return HTTP 200 at https://cdn.simpleicons.org/<slug>.
_SIMPLEICONS_CDN = "https://cdn.simpleicons.org/"
_SUPPLEMENT = {
"qdrant": "qdrant",
"milvus": "milvus",
"supabase": "supabase",
"redis": "redis",
"postgresql": "postgresql",
"mongodb": "mongodb",
"elasticsearch": "elasticsearch",
"neo4j": "neo4j",
"kafka": "apachekafka",
"clickhouse": "clickhouse",
"duckdb": "duckdb",
"mysql": "mysql",
"sqlite": "sqlite",
"cassandra": "apachecassandra",
"snowflake": "snowflake",
"databricks": "databricks",
"mariadb": "mariadb",
"couchbase": "couchbase",
}
def families(icons):
"""base brand name -> set of its variant filenames (without .svg)."""
fam = {}
for name in icons:
base = _VARIANT.sub("", name)
fam.setdefault(base, set()).add(name)
return fam
def squish(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
def search(fam, query, limit):
"""Rank brand bases against the query (squished + per-token matching)."""
q = squish(query)
tokens = [t for t in re.findall(r"[a-z0-9]+", query.lower()) if t]
scored = {}
for base in fam:
b = squish(base)
s = 0
if q and q == b:
s = 100
elif q and b.startswith(q):
s = 60
elif q and q in b:
s = 40
for t in tokens:
if t == b:
s = max(s, 90)
elif len(t) >= 3 and b.startswith(t):
s = max(s, 50)
elif len(t) >= 3 and t in b:
s = max(s, 30)
if s:
scored[base] = s
return sorted(scored, key=lambda base: (-scored[base], base))[:limit]
def search_supplement(query):
"""Fall back to the simple-icons supplement (exact or substring match)."""
q = squish(query)
if not q:
return None
if q in _SUPPLEMENT:
return q
for brand in _SUPPLEMENT:
if q in brand or brand in q:
return brand
return None
def pick_variant(base, variants, prefer):
order = {"color": ["-color", "-brand-color", "", "-brand", "-text", "-text-cn"],
"mono": ["", "-brand", "-color", "-brand-color", "-text", "-text-cn"],
"text": ["-text", "-text-cn", "-brand", "-brand-color", "-color", ""]}[prefer]
for suffix in order:
cand = base + suffix
if cand in variants:
return cand
return next(iter(sorted(variants)), None)
def main():
ap = argparse.ArgumentParser(description="Find AI/LLM brand logos as draw.io styles (lobe-icons via CDN).")
ap.add_argument("query", nargs="?", help='brand name, e.g. "openai" or "claude"')
ap.add_argument("--limit", type=int, default=8)
ap.add_argument("--variant", choices=["color", "mono", "text"], default="color")
ap.add_argument("--size", type=int, default=48, help="cell width/height in px (icons are square)")
ap.add_argument("--embed", action="store_true",
help="inline the SVG as a data URI (fetches it now; portable, no network at render time)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--list", action="store_true", help="list all brand names and exit")
args = ap.parse_args()
if not os.path.exists(MANIFEST):
sys.exit(f"error: manifest not found at {MANIFEST}")
with open(MANIFEST, encoding="utf-8") as f:
manifest = json.load(f)
fam = families(manifest["icons"])
cdn = manifest["cdn"]
if args.list:
for base in sorted(fam):
print(base)
return
if not args.query:
ap.error("a query is required (or use --list)")
matches = search(fam, args.query, args.limit)
results = []
if matches:
for base in matches:
file = pick_variant(base, fam[base], args.variant)
url = f"{cdn}{file}.svg"
if args.embed:
try:
svg = urllib.request.urlopen(url, timeout=15).read()
except Exception as exc: # noqa: BLE001 - report and skip
sys.stderr.write(f"warning: could not fetch {url} ({exc})\n")
continue
# Rewrite the 1em intrinsic size so draw.io scales the inlined SVG.
svg = svg.replace(b'width="1em"', b'width="24"').replace(b'height="1em"', b'height="24"')
# Marker-less base64: draw.io splits style values on ';', so a
# ';base64,' marker would truncate the image= value (issue #80).
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
else:
image = url
results.append({"brand": base, "file": file, "w": args.size, "h": args.size,
"style": STYLE + image})
else:
# lobe has no logo for this brand; fall back to the simple-icons supplement.
brand = search_supplement(args.query)
if brand:
slug = _SUPPLEMENT[brand]
url = _SIMPLEICONS_CDN + slug
image = url
if args.embed:
try:
svg = urllib.request.urlopen(url, timeout=15).read()
# Marker-less base64 (see issue #80 note above).
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
except Exception as exc: # noqa: BLE001 - keep the CDN URL
sys.stderr.write(f"warning: could not fetch {url} ({exc}); using CDN URL\n")
results.append({"brand": brand, "file": f"simpleicons:{slug}",
"w": args.size, "h": args.size, "style": STYLE + image})
if not results:
sys.exit(f"no logo for {args.query!r} — for a data store try a cylinder "
f"(shape=cylinder3) or shapesearch.py '{args.query} database'")
if args.json:
print(json.dumps(results, indent=2, ensure_ascii=False))
else:
for r in results:
shown = r["style"] if len(r["style"]) < 160 else r["style"][:157] + "..."
print(f"{r['brand']} ({r['file']}, {r['w']}x{r['h']})\n {shown}")
if __name__ == "__main__":
main()