From ee15e1f45e98c0fd67da225696d6088942155086 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 27 Apr 2026 15:15:31 -0300 Subject: [PATCH] Add third-party brand icon support --- config/brand-icons.yaml | 45 ++++ .../brand-icons/fallback/jirasoftware.svg | 1 + .../Jira-Service-Management_icon.svg | 11 + .../brand-icons/official/jira/Jira_icon.svg | 11 + .../official/slack/slack_hash_256.png | Bin 0 -> 5253 bytes tools/brand_icon_catalog.py | 194 ++++++++++++++++++ tools/oci_diagram_gen.py | 42 +++- tools/oci_pptx_diagram_gen.py | 181 +++++++++++++++- 8 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 config/brand-icons.yaml create mode 100644 kb/diagram/brand-icons/fallback/jirasoftware.svg create mode 100644 kb/diagram/brand-icons/official/jira-service-management/Jira-Service-Management_icon.svg create mode 100644 kb/diagram/brand-icons/official/jira/Jira_icon.svg create mode 100644 kb/diagram/brand-icons/official/slack/slack_hash_256.png create mode 100644 tools/brand_icon_catalog.py 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 @@ +Jira Software \ 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 0000000000000000000000000000000000000000..a045a159f712568ed197bc781ad0bc7ad2129b8d GIT binary patch literal 5253 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 BTY&%o literal 0 HcmV?d00001 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"""