diff --git a/config/brand-icons.yaml b/config/brand-icons.yaml
new file mode 100644
index 0000000..9708bac
--- /dev/null
+++ b/config/brand-icons.yaml
@@ -0,0 +1,45 @@
+version: 1
+
+# Resolution policy:
+# 1. Prefer officially downloaded vendor assets committed under
+# kb/diagram/brand-icons/official/
+# 2. Fall back to vetted local assets under
+# kb/diagram/brand-icons/fallback/
+#
+# The renderers resolve by alias first, then walk `resolution_order`
+# until they find an existing file.
+resolution_order:
+ - official_svg
+ - official_png
+ - fallback_svg
+ - fallback_png
+
+icons:
+ slack:
+ label: Slack
+ aliases:
+ - slack_app
+ - slack_bot
+ official_png: kb/diagram/brand-icons/official/slack/slack_hash_256.png
+
+ jira:
+ label: Jira
+ aliases:
+ - atlassian_jira
+ official_svg: kb/diagram/brand-icons/official/jira/Jira_icon.svg
+
+ jira_service_management:
+ label: Jira Service Management
+ aliases:
+ - jsm
+ - jira-service-management
+ - jira service management
+ official_svg: kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg
+
+ jirasoftware:
+ label: Jira Software
+ aliases:
+ - jira_software
+ - jira-software
+ - jira software
+ fallback_svg: kb/diagram/brand-icons/fallback/jirasoftware.svg
diff --git a/kb/diagram/brand-icons/fallback/jirasoftware.svg b/kb/diagram/brand-icons/fallback/jirasoftware.svg
new file mode 100644
index 0000000..9e03408
--- /dev/null
+++ b/kb/diagram/brand-icons/fallback/jirasoftware.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg b/kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg
new file mode 100644
index 0000000..d82034e
--- /dev/null
+++ b/kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/kb/diagram/brand-icons/official/jira/Jira_icon.svg b/kb/diagram/brand-icons/official/jira/Jira_icon.svg
new file mode 100644
index 0000000..7e84ed4
--- /dev/null
+++ b/kb/diagram/brand-icons/official/jira/Jira_icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/kb/diagram/brand-icons/official/slack/slack_hash_256.png b/kb/diagram/brand-icons/official/slack/slack_hash_256.png
new file mode 100644
index 0000000..a045a15
Binary files /dev/null and b/kb/diagram/brand-icons/official/slack/slack_hash_256.png differ
diff --git a/tools/brand_icon_catalog.py b/tools/brand_icon_catalog.py
new file mode 100644
index 0000000..770998e
--- /dev/null
+++ b/tools/brand_icon_catalog.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""Shared resolver for non-OCI brand icons used by drawio and PPTX.
+
+Policy:
+- Prefer officially downloaded vendor assets when committed locally.
+- Fall back to vetted local assets only when an official asset is not
+ available or practical to automate.
+- Never depend on remote URLs at render time.
+"""
+
+from __future__ import annotations
+
+import base64
+import re
+import struct
+import xml.etree.ElementTree as ET
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_CATALOG_PATH = PROJECT_ROOT / "config" / "brand-icons.yaml"
+DEFAULT_RESOLUTION_ORDER = (
+ "official_svg",
+ "official_png",
+ "fallback_svg",
+ "fallback_png",
+)
+PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+
+def _slug(value: str | None) -> str:
+ if not value:
+ return ""
+ return re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_")
+
+
+@lru_cache(maxsize=1)
+def _load_catalog() -> dict[str, Any]:
+ raw = yaml.safe_load(DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")) or {}
+ icons = raw.get("icons") or {}
+ alias_map: dict[str, str] = {}
+ normalized_icons: dict[str, dict[str, Any]] = {}
+ for key, entry in icons.items():
+ icon_id = _slug(key)
+ normalized = dict(entry or {})
+ normalized["id"] = icon_id
+ normalized_icons[icon_id] = normalized
+ alias_map[icon_id] = icon_id
+ for alias in normalized.get("aliases") or []:
+ alias_map[_slug(alias)] = icon_id
+ return {
+ "resolution_order": tuple(raw.get("resolution_order") or DEFAULT_RESOLUTION_ORDER),
+ "icons": normalized_icons,
+ "aliases": alias_map,
+ }
+
+
+def resolve_brand_icon(brand_icon: str | None) -> dict[str, Any] | None:
+ slug = _slug(brand_icon)
+ if not slug:
+ return None
+ catalog = _load_catalog()
+ icon_id = catalog["aliases"].get(slug)
+ if not icon_id:
+ return None
+ entry = dict(catalog["icons"][icon_id])
+ for key in catalog["resolution_order"]:
+ rel_path = entry.get(key)
+ if not rel_path:
+ continue
+ path = PROJECT_ROOT / rel_path
+ if path.exists():
+ suffix = path.suffix.lower()
+ return {
+ **entry,
+ "path": path,
+ "resolution_key": key,
+ "format": suffix.lstrip("."),
+ "mime_type": _mime_type_for_suffix(suffix),
+ }
+ return None
+
+
+def available_brand_icons() -> list[str]:
+ return sorted(_load_catalog()["icons"].keys())
+
+
+def brand_icon_data_uri(brand_icon: str | None) -> str | None:
+ resolved = resolve_brand_icon(brand_icon)
+ if not resolved:
+ return None
+ data = resolved["path"].read_bytes()
+ encoded = base64.b64encode(data).decode("ascii")
+ # draw.io stores inline image payloads in mxCell styles as
+ # image=data:image/, (without ";base64"). Keeping the
+ # payload in that draw.io-native form avoids the semicolon breaking the
+ # style parser, which treats ";" as a style key separator.
+ return f"data:{resolved['mime_type']},{encoded}"
+
+
+def brand_icon_size(brand_icon: str | None) -> tuple[int, int] | None:
+ resolved = resolve_brand_icon(brand_icon)
+ if not resolved:
+ return None
+ data = resolved["path"].read_bytes()
+ fmt = resolved["format"]
+ if fmt == "svg":
+ return _svg_dimensions(data)
+ if fmt == "png":
+ return _png_dimensions(data)
+ return None
+
+
+def drawio_icon_entry(brand_icon: str | None) -> dict[str, Any] | None:
+ data_uri = brand_icon_data_uri(brand_icon)
+ size = brand_icon_size(brand_icon)
+ if not data_uri or not size:
+ return None
+ width, height = size
+ cell = (
+ f''
+ f''
+ f''
+ )
+ return {"w": width, "h": height, "cells": [cell]}
+
+
+def brand_icon_png_bytes(brand_icon: str | None) -> bytes | None:
+ resolved = resolve_brand_icon(brand_icon)
+ if not resolved:
+ return None
+ data = resolved["path"].read_bytes()
+ if resolved["format"] == "png":
+ return data
+ if resolved["format"] == "svg":
+ import cairosvg
+
+ return cairosvg.svg2png(bytestring=data)
+ return None
+
+
+def brand_icon_media_name(brand_icon: str | None, extension: str = "png") -> str:
+ resolved = resolve_brand_icon(brand_icon)
+ stem = _slug((resolved or {}).get("id") or brand_icon or "brand-icon")
+ return f"{stem}.{extension.lstrip('.')}"
+
+
+def _mime_type_for_suffix(suffix: str) -> str:
+ if suffix == ".svg":
+ return "image/svg+xml"
+ if suffix == ".png":
+ return "image/png"
+ return "application/octet-stream"
+
+
+def _svg_dimensions(svg_bytes: bytes) -> tuple[int, int]:
+ root = ET.fromstring(svg_bytes)
+ view_box = root.get("viewBox")
+ if view_box:
+ parts = view_box.replace(",", " ").split()
+ if len(parts) == 4:
+ width = max(int(round(float(parts[2]))), 1)
+ height = max(int(round(float(parts[3]))), 1)
+ return width, height
+ width = _svg_length(root.get("width"))
+ height = _svg_length(root.get("height"))
+ if width and height:
+ return width, height
+ return (24, 24)
+
+
+def _svg_length(value: str | None) -> int | None:
+ if not value:
+ return None
+ match = re.search(r"([0-9]+(?:\.[0-9]+)?)", value)
+ if not match:
+ return None
+ return max(int(round(float(match.group(1)))), 1)
+
+
+def _png_dimensions(png_bytes: bytes) -> tuple[int, int]:
+ if png_bytes[:8] != PNG_SIGNATURE:
+ raise ValueError("Invalid PNG signature")
+ if png_bytes[12:16] != b"IHDR":
+ raise ValueError("PNG missing IHDR chunk")
+ width, height = struct.unpack(">II", png_bytes[16:24])
+ return max(int(width), 1), max(int(height), 1)
diff --git a/tools/oci_diagram_gen.py b/tools/oci_diagram_gen.py
index 80aecf9..8c59c18 100644
--- a/tools/oci_diagram_gen.py
+++ b/tools/oci_diagram_gen.py
@@ -35,6 +35,11 @@ from typing import Optional
import drawpyo
import drawpyo.diagram
+try:
+ from brand_icon_catalog import drawio_icon_entry
+except ImportError:
+ from tools.brand_icon_catalog import drawio_icon_entry
+
# ============================================================
# OCI OFFICIAL STYLES (from OCI Style Guide for Draw.io v24.2)
# ============================================================
@@ -908,6 +913,10 @@ class OCIDiagramGenerator:
return icon_entry, candidate
return None, None
+ @staticmethod
+ def _resolve_brand_icon_entry(brand_icon: str | None):
+ return drawio_icon_entry(brand_icon)
+
def __init__(self):
self._load_icon_cache()
self.file = drawpyo.File()
@@ -1194,6 +1203,8 @@ class OCIDiagramGenerator:
self, cell_id: str, label: str,
x: int = 30, y: int = 30, w: int = 50, h: int = 80,
icon: str = "user",
+ brand_icon: Optional[str] = None,
+ font_size: Optional[int] = None,
) -> str:
"""Add an external actor with icon.
@@ -1202,6 +1213,24 @@ class OCIDiagramGenerator:
"internet" — cloud stencil
"generic" — colored rectangle
"""
+ brand_entry = self._resolve_brand_icon_entry(brand_icon)
+ if brand_entry:
+ return self.add_service(
+ cell_id,
+ label,
+ "external",
+ None,
+ x=x,
+ y=y,
+ w=w,
+ h=h,
+ font_size=font_size,
+ raw_icon_cells=brand_entry["cells"],
+ raw_icon_w=brand_entry["w"],
+ raw_icon_h=brand_entry["h"],
+ exact_size=True,
+ )
+
if icon in ("user", "users"):
# Create an invisible group for connectivity + label
self._create_object(cell_id, "", STYLES["external_user"], None, x, y, w, h)
@@ -1294,6 +1323,7 @@ class OCIDiagramGenerator:
self, cell_id: str, label: str, service_type: str, parent: str,
x: int = 20, y: int = 35, w: int = 150, h: int = 50,
font_size: Optional[int] = None,
+ brand_icon: Optional[str] = None,
tone: Optional[str] = None,
badges: Optional[list] = None,
caption: Optional[str] = None,
@@ -1318,6 +1348,8 @@ class OCIDiagramGenerator:
"h": max(int(raw_icon_h or h), 1),
"cells": list(raw_icon_cells),
}
+ elif brand_icon:
+ icon_entry = self._resolve_brand_icon_entry(brand_icon)
else:
icon_entry, _ = self._resolve_icon_entry(service_type)
if icon_entry:
@@ -2286,6 +2318,7 @@ class OCIDiagramGenerator:
x=ext.get("x", 30), y=ext.get("y", 30),
w=ext.get("w", 50), h=ext.get("h", 60),
icon=ext.get("icon", "user"),
+ brand_icon=ext.get("brand_icon"),
)
# External cloud containers (AWS, Azure, GCP)
@@ -2322,10 +2355,12 @@ class OCIDiagramGenerator:
gen._service_count += 1
else:
gen.add_service(svc_id, label, svc.get("type", "compute"),
- cloud_id, cx, cy, svc_w, svc_h)
+ cloud_id, cx, cy, svc_w, svc_h,
+ brand_icon=svc.get("brand_icon"))
else:
gen.add_service(svc_id, label, svc.get("type", "compute"),
- cloud_id, cx, cy, svc_w, svc_h)
+ cloud_id, cx, cy, svc_w, svc_h,
+ brand_icon=svc.get("brand_icon"))
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
cy += max(svc_h, actual_h) + 20
@@ -3050,6 +3085,8 @@ class OCIDiagramGenerator:
x=int(item.get("x", 0)), y=int(item.get("y", 0)),
w=int(item.get("w", 56)), h=int(item.get("h", 72)),
icon=item.get("icon", "users"),
+ brand_icon=item.get("brand_icon"),
+ font_size=_pt(item.get("fontSize")),
)
for item in layout.get("services") or []:
@@ -3091,6 +3128,7 @@ class OCIDiagramGenerator:
item["parent"] if "parent" in item else None,
x=sx_v, y=sy_v, w=sw, h=sh,
font_size=_pt(item.get("fontSize")),
+ brand_icon=item.get("brand_icon"),
tone=item.get("tone"),
badges=item.get("badges"),
caption=item.get("caption"),
diff --git a/tools/oci_pptx_diagram_gen.py b/tools/oci_pptx_diagram_gen.py
index c32f82d..fc914dd 100644
--- a/tools/oci_pptx_diagram_gen.py
+++ b/tools/oci_pptx_diagram_gen.py
@@ -19,6 +19,19 @@ from pathlib import PurePosixPath
from lxml import etree
+try:
+ from brand_icon_catalog import (
+ brand_icon_media_name,
+ brand_icon_png_bytes,
+ brand_icon_size,
+ )
+except ImportError:
+ from tools.brand_icon_catalog import (
+ brand_icon_media_name,
+ brand_icon_png_bytes,
+ brand_icon_size,
+ )
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INDEX_PATH = PROJECT_ROOT / "kb" / "diagram" / "oci-pptx-icons-index.json"
@@ -539,16 +552,39 @@ class NativePPTXDiagramRenderer:
))
for item in layout.get("external") or []:
+ item_id = item.get("id", _slug(item.get("label", "external")))
+ item_x = sx(item.get("x", 0))
+ item_y = sy(item.get("y", 0))
+ item_w = sl(item.get("w", 70))
+ item_h = sl(item.get("h", 52))
+ item_label = str(item.get("label", "Users")).replace("\\n", "\n")
+ brand_fragments = self._render_brand_icon(
+ brand_icon=item.get("brand_icon"),
+ x=item_x,
+ y=item_y,
+ w=item_w,
+ h=item_h,
+ allocate_id=allocate_id,
+ service_positions=service_positions,
+ service_id=item_id,
+ label=item_label,
+ font_size=item.get("fontSize"),
+ )
+ if brand_fragments:
+ fragments.extend(brand_fragments)
+ continue
# Native PPTX currently has no user stencil in the OCI deck; use a
# compact neutral card instead of importing non-OCI artwork.
fragments.append(self._fallback_service_card(
- allocate_id(), sx(item.get("x", 0)), sy(item.get("y", 0)),
- sl(item.get("w", 70)), sl(item.get("h", 52)),
- item.get("label", "Users"), color="2D5967",
+ allocate_id(), item_x, item_y,
+ item_w, item_h,
+ item_label, color="2D5967",
))
- service_positions[item.get("id", _slug(item.get("label", "external")))] = {
- "x": sx(item.get("x", 0)), "y": sy(item.get("y", 0)),
- "cx": sl(item.get("w", 70)), "cy": sl(item.get("h", 52)),
+ service_positions[item_id] = {
+ "x": item_x,
+ "y": item_y,
+ "cx": item_w,
+ "cy": item_h,
}
for item in layout.get("services") or []:
@@ -828,6 +864,22 @@ class NativePPTXDiagramRenderer:
def _render_service(self, service: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
fragments = []
+ service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
+ label = service.get("name") or service.get("label") or service.get("type", "Service")
+ brand_fragments = self._render_brand_icon(
+ brand_icon=service.get("brand_icon"),
+ x=x,
+ y=y,
+ w=w,
+ h=h,
+ allocate_id=allocate_id,
+ service_positions=service_positions,
+ service_id=service_id,
+ label=label,
+ font_size=service.get("fontSize"),
+ )
+ if brand_fragments:
+ return brand_fragments
# Multi-cloud support: spec may carry `cloud_icon: ec2` etc. for
# AWS/GCP components. Render as a provider-branded card scaled
# to 72% of the bbox so it visually matches the OCI line-art
@@ -845,11 +897,9 @@ class NativePPTXDiagramRenderer:
fragments.append(self._fallback_service_card(
allocate_id(), rx, ry, rw, rh, text, color=color,
))
- sid = service.get("id") or _slug(text)
- service_positions[sid] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
+ service_positions[service_id] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
return fragments
icon_ref = self._resolve_icon_ref(service)
- service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
if icon_ref:
# Icon fills the spec's full (w, h) bbox. The earlier
# aspect-ratio-preserving logic ("fit within w/h") left
@@ -891,7 +941,6 @@ class NativePPTXDiagramRenderer:
))
return fragments
- label = service.get("name") or service.get("label") or service.get("type", "Service")
color = SERVICE_COLORS.get(service.get("category"), SERVICE_COLORS.get(service.get("type"), SERVICE_COLORS["default"]))
fragments.append(self._fallback_service_card(allocate_id(), x + _emu(0.04), y + _emu(0.04), w - _emu(0.08), h - _emu(0.08), label, color=color))
service_positions[service_id] = {
@@ -1259,6 +1308,27 @@ class NativePPTXDiagramRenderer:
rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True)
return new_rid
+ def _write_media_blob(self, file_name: str, data: bytes) -> str:
+ if self._current_work_dir is None:
+ raise RuntimeError("Cannot write media without an active PPTX work dir")
+ media_dir = self._current_work_dir / "ppt" / "media"
+ media_dir.mkdir(parents=True, exist_ok=True)
+ dest_path = media_dir / Path(file_name).name
+ if dest_path.exists() and dest_path.read_bytes() != data:
+ stem = dest_path.stem
+ suffix = dest_path.suffix
+ counter = 1
+ while True:
+ candidate = media_dir / f"{stem}_{counter}{suffix}"
+ if not candidate.exists() or candidate.read_bytes() == data:
+ dest_path = candidate
+ break
+ counter += 1
+ if not dest_path.exists():
+ dest_path.write_bytes(data)
+ self._ensure_content_type_default(dest_path.suffix.lstrip(".").lower())
+ return f"../media/{dest_path.name}"
+
def _target_slide_rels_path(self) -> Path:
slide_name = self._current_slide_path.name
rels_dir = self._current_slide_path.parent / "_rels"
@@ -1269,6 +1339,97 @@ class NativePPTXDiagramRenderer:
for node in element.findall(".//p:cNvPr", namespaces=NS):
node.set("id", str(allocate_id()))
+ def _picture_shape(self, shape_id: int, rel_id: str, x: int, y: int, cx: int, cy: int,
+ name: str = "Brand Icon"):
+ xml = f"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+ return etree.fromstring(xml.encode("utf-8"))
+
+ def _render_brand_icon(self, brand_icon: str | None, x: int, y: int, w: int, h: int,
+ allocate_id, service_positions: dict, service_id: str,
+ label: str | None = None, font_size: int | None = None) -> list:
+ if not brand_icon:
+ return []
+ png_bytes = brand_icon_png_bytes(brand_icon)
+ size = brand_icon_size(brand_icon)
+ if not png_bytes or not size:
+ return []
+
+ label_text = str(label or "").replace("\\n", "\n")
+ label_font_size = int(font_size) if font_size is not None else 820
+ n_lines = label_text.count("\n") + 1 if label_text else 0
+ label_gap = _emu(0.03) if label_text else 0
+ label_cy = 0
+ if n_lines:
+ line_height_pt = max(label_font_size / 100.0, 8.2) * 1.2
+ label_cy = max(
+ _emu(0.18),
+ int(round((line_height_pt * n_lines / 72.0) * EMU_PER_INCH + _emu(0.03))),
+ )
+ icon_box_h = max(h - label_cy - label_gap, 1)
+ natural_w, natural_h = size
+ scale = min(w / max(natural_w, 1), icon_box_h / max(natural_h, 1))
+ icon_cx = max(int(round(natural_w * scale)), 1)
+ icon_cy = max(int(round(natural_h * scale)), 1)
+ icon_x = x + max(int((w - icon_cx) / 2), 0)
+ icon_y = y + max(int((icon_box_h - icon_cy) / 2), 0)
+
+ media_target = self._write_media_blob(
+ brand_icon_media_name(brand_icon, extension="png"),
+ png_bytes,
+ )
+ picture = self._picture_shape(
+ allocate_id(),
+ self._add_target_slide_relationship(media_target),
+ icon_x,
+ icon_y,
+ icon_cx,
+ icon_cy,
+ )
+ service_positions[service_id] = {
+ "x": icon_x,
+ "y": icon_y,
+ "cx": icon_cx,
+ "cy": icon_cy,
+ }
+ fragments = [picture]
+ if label_text:
+ label_y = y + h - label_cy
+ fragments.append(self._label_shape(
+ allocate_id(),
+ x,
+ label_y,
+ w,
+ label_cy,
+ label_text,
+ font_size=label_font_size,
+ color=self.bark,
+ bold=True,
+ align="ctr",
+ ))
+ return fragments
+
def _fallback_service_card(self, shape_id: int, x: int, y: int, cx: int, cy: int, label: str, color: str):
xml = f"""