forked from diegoecab/oci-deal-accelerator
Add third-party brand icon support
This commit is contained in:
45
config/brand-icons.yaml
Normal file
45
config/brand-icons.yaml
Normal file
@@ -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
|
||||||
1
kb/diagram/brand-icons/fallback/jirasoftware.svg
Normal file
1
kb/diagram/brand-icons/fallback/jirasoftware.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Jira Software</title><path d="M12.004 0c-2.35 2.395-2.365 6.185.133 8.585l3.412 3.413-3.197 3.198a6.501 6.501 0 0 1 1.412 7.04l9.566-9.566a.95.95 0 0 0 0-1.344L12.004 0zm-1.748 1.74L.67 11.327a.95.95 0 0 0 0 1.344C4.45 16.44 8.22 20.244 12 24c2.295-2.298 2.395-6.096-.08-8.533l-3.47-3.469 3.2-3.2c-1.918-1.955-2.363-4.725-1.394-7.057z"/></svg>
|
||||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#FFC716"/>
|
||||||
|
<g clip-path="url(#clip0_1_6409)">
|
||||||
|
<path d="M27.4016 20.4052H36.3282C37.6688 20.4052 38.1266 21.6804 37.3091 22.694L23.347 39.926C18.8347 36.3292 19.2597 30.6397 22.5623 26.487L27.4016 20.4052ZM20.5023 27.2718H11.5757C10.2351 27.2718 9.77728 25.9966 10.5947 24.9829L24.5569 7.75098C29.0692 11.3478 28.5787 16.9719 25.3089 21.1572L20.5023 27.2718Z" fill="#101214"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_6409">
|
||||||
|
<rect width="27.5283" height="32.175" fill="white" transform="translate(10.1876 7.75049)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 773 B |
11
kb/diagram/brand-icons/official/jira/Jira_icon.svg
Normal file
11
kb/diagram/brand-icons/official/jira/Jira_icon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z" fill="#1868DB"/>
|
||||||
|
<g clip-path="url(#clip0_1_5403)">
|
||||||
|
<path d="M17.9475 31.0469H15.2429C11.1638 31.0469 8.23755 28.5484 8.23755 24.8899H22.7804C23.5341 24.8899 24.0218 25.4252 24.0218 26.1837V40.8178C20.3861 40.8178 17.9475 37.8731 17.9475 33.7684V31.0469ZM25.1303 23.7745H22.4257C18.3466 23.7745 15.4203 21.3206 15.4203 17.6621H29.9631C30.7168 17.6621 31.2489 18.1528 31.2489 18.9113V33.5454C27.6132 33.5454 25.1303 30.6007 25.1303 26.496V23.7745ZM32.3573 16.5467H29.6527C25.5736 16.5467 22.6473 14.0482 22.6473 10.3896H37.1902C37.9439 10.3896 38.4316 10.925 38.4316 11.6389V26.273C34.7959 26.273 32.3573 23.3283 32.3573 19.2236V16.5467Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_5403">
|
||||||
|
<rect width="30.1941" height="30.4281" fill="white" transform="translate(8.23755 10.3901)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
kb/diagram/brand-icons/official/slack/slack_hash_256.png
Normal file
BIN
kb/diagram/brand-icons/official/slack/slack_hash_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
194
tools/brand_icon_catalog.py
Normal file
194
tools/brand_icon_catalog.py
Normal file
@@ -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/<type>,<base64> (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'<mxCell id="1" value="" '
|
||||||
|
f'style="shape=image;aspect=fixed;imageAspect=1;image={data_uri};'
|
||||||
|
f'strokeColor=none;fillColor=none;html=1;" '
|
||||||
|
f'vertex="1" parent="1">'
|
||||||
|
f'<mxGeometry x="0" y="0" width="{width}" height="{height}" as="geometry" />'
|
||||||
|
f'</mxCell>'
|
||||||
|
)
|
||||||
|
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)
|
||||||
@@ -35,6 +35,11 @@ from typing import Optional
|
|||||||
import drawpyo
|
import drawpyo
|
||||||
import drawpyo.diagram
|
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)
|
# OCI OFFICIAL STYLES (from OCI Style Guide for Draw.io v24.2)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -908,6 +913,10 @@ class OCIDiagramGenerator:
|
|||||||
return icon_entry, candidate
|
return icon_entry, candidate
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_brand_icon_entry(brand_icon: str | None):
|
||||||
|
return drawio_icon_entry(brand_icon)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._load_icon_cache()
|
self._load_icon_cache()
|
||||||
self.file = drawpyo.File()
|
self.file = drawpyo.File()
|
||||||
@@ -1194,6 +1203,8 @@ class OCIDiagramGenerator:
|
|||||||
self, cell_id: str, label: str,
|
self, cell_id: str, label: str,
|
||||||
x: int = 30, y: int = 30, w: int = 50, h: int = 80,
|
x: int = 30, y: int = 30, w: int = 50, h: int = 80,
|
||||||
icon: str = "user",
|
icon: str = "user",
|
||||||
|
brand_icon: Optional[str] = None,
|
||||||
|
font_size: Optional[int] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Add an external actor with icon.
|
"""Add an external actor with icon.
|
||||||
|
|
||||||
@@ -1202,6 +1213,24 @@ class OCIDiagramGenerator:
|
|||||||
"internet" — cloud stencil
|
"internet" — cloud stencil
|
||||||
"generic" — colored rectangle
|
"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"):
|
if icon in ("user", "users"):
|
||||||
# Create an invisible group for connectivity + label
|
# Create an invisible group for connectivity + label
|
||||||
self._create_object(cell_id, "", STYLES["external_user"], None, x, y, w, h)
|
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,
|
self, cell_id: str, label: str, service_type: str, parent: str,
|
||||||
x: int = 20, y: int = 35, w: int = 150, h: int = 50,
|
x: int = 20, y: int = 35, w: int = 150, h: int = 50,
|
||||||
font_size: Optional[int] = None,
|
font_size: Optional[int] = None,
|
||||||
|
brand_icon: Optional[str] = None,
|
||||||
tone: Optional[str] = None,
|
tone: Optional[str] = None,
|
||||||
badges: Optional[list] = None,
|
badges: Optional[list] = None,
|
||||||
caption: Optional[str] = None,
|
caption: Optional[str] = None,
|
||||||
@@ -1318,6 +1348,8 @@ class OCIDiagramGenerator:
|
|||||||
"h": max(int(raw_icon_h or h), 1),
|
"h": max(int(raw_icon_h or h), 1),
|
||||||
"cells": list(raw_icon_cells),
|
"cells": list(raw_icon_cells),
|
||||||
}
|
}
|
||||||
|
elif brand_icon:
|
||||||
|
icon_entry = self._resolve_brand_icon_entry(brand_icon)
|
||||||
else:
|
else:
|
||||||
icon_entry, _ = self._resolve_icon_entry(service_type)
|
icon_entry, _ = self._resolve_icon_entry(service_type)
|
||||||
if icon_entry:
|
if icon_entry:
|
||||||
@@ -2286,6 +2318,7 @@ class OCIDiagramGenerator:
|
|||||||
x=ext.get("x", 30), y=ext.get("y", 30),
|
x=ext.get("x", 30), y=ext.get("y", 30),
|
||||||
w=ext.get("w", 50), h=ext.get("h", 60),
|
w=ext.get("w", 50), h=ext.get("h", 60),
|
||||||
icon=ext.get("icon", "user"),
|
icon=ext.get("icon", "user"),
|
||||||
|
brand_icon=ext.get("brand_icon"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# External cloud containers (AWS, Azure, GCP)
|
# External cloud containers (AWS, Azure, GCP)
|
||||||
@@ -2322,10 +2355,12 @@ class OCIDiagramGenerator:
|
|||||||
gen._service_count += 1
|
gen._service_count += 1
|
||||||
else:
|
else:
|
||||||
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
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:
|
else:
|
||||||
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
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"))
|
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
|
||||||
cy += max(svc_h, actual_h) + 20
|
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)),
|
x=int(item.get("x", 0)), y=int(item.get("y", 0)),
|
||||||
w=int(item.get("w", 56)), h=int(item.get("h", 72)),
|
w=int(item.get("w", 56)), h=int(item.get("h", 72)),
|
||||||
icon=item.get("icon", "users"),
|
icon=item.get("icon", "users"),
|
||||||
|
brand_icon=item.get("brand_icon"),
|
||||||
|
font_size=_pt(item.get("fontSize")),
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in layout.get("services") or []:
|
for item in layout.get("services") or []:
|
||||||
@@ -3091,6 +3128,7 @@ class OCIDiagramGenerator:
|
|||||||
item["parent"] if "parent" in item else None,
|
item["parent"] if "parent" in item else None,
|
||||||
x=sx_v, y=sy_v, w=sw, h=sh,
|
x=sx_v, y=sy_v, w=sw, h=sh,
|
||||||
font_size=_pt(item.get("fontSize")),
|
font_size=_pt(item.get("fontSize")),
|
||||||
|
brand_icon=item.get("brand_icon"),
|
||||||
tone=item.get("tone"),
|
tone=item.get("tone"),
|
||||||
badges=item.get("badges"),
|
badges=item.get("badges"),
|
||||||
caption=item.get("caption"),
|
caption=item.get("caption"),
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ from pathlib import PurePosixPath
|
|||||||
|
|
||||||
from lxml import etree
|
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
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
DEFAULT_INDEX_PATH = PROJECT_ROOT / "kb" / "diagram" / "oci-pptx-icons-index.json"
|
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 []:
|
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
|
# Native PPTX currently has no user stencil in the OCI deck; use a
|
||||||
# compact neutral card instead of importing non-OCI artwork.
|
# compact neutral card instead of importing non-OCI artwork.
|
||||||
fragments.append(self._fallback_service_card(
|
fragments.append(self._fallback_service_card(
|
||||||
allocate_id(), sx(item.get("x", 0)), sy(item.get("y", 0)),
|
allocate_id(), item_x, item_y,
|
||||||
sl(item.get("w", 70)), sl(item.get("h", 52)),
|
item_w, item_h,
|
||||||
item.get("label", "Users"), color="2D5967",
|
item_label, color="2D5967",
|
||||||
))
|
))
|
||||||
service_positions[item.get("id", _slug(item.get("label", "external")))] = {
|
service_positions[item_id] = {
|
||||||
"x": sx(item.get("x", 0)), "y": sy(item.get("y", 0)),
|
"x": item_x,
|
||||||
"cx": sl(item.get("w", 70)), "cy": sl(item.get("h", 52)),
|
"y": item_y,
|
||||||
|
"cx": item_w,
|
||||||
|
"cy": item_h,
|
||||||
}
|
}
|
||||||
|
|
||||||
for item in layout.get("services") or []:
|
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:
|
def _render_service(self, service: dict, x: int, y: int, w: int, h: int, allocate_id, service_positions: dict) -> list:
|
||||||
fragments = []
|
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
|
# Multi-cloud support: spec may carry `cloud_icon: ec2` etc. for
|
||||||
# AWS/GCP components. Render as a provider-branded card scaled
|
# AWS/GCP components. Render as a provider-branded card scaled
|
||||||
# to 72% of the bbox so it visually matches the OCI line-art
|
# 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(
|
fragments.append(self._fallback_service_card(
|
||||||
allocate_id(), rx, ry, rw, rh, text, color=color,
|
allocate_id(), rx, ry, rw, rh, text, color=color,
|
||||||
))
|
))
|
||||||
sid = service.get("id") or _slug(text)
|
service_positions[service_id] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
|
||||||
service_positions[sid] = {"x": rx, "y": ry, "cx": rw, "cy": rh}
|
|
||||||
return fragments
|
return fragments
|
||||||
icon_ref = self._resolve_icon_ref(service)
|
icon_ref = self._resolve_icon_ref(service)
|
||||||
service_id = service.get("id") or _slug(service.get("name", service.get("type", "service")))
|
|
||||||
if icon_ref:
|
if icon_ref:
|
||||||
# Icon fills the spec's full (w, h) bbox. The earlier
|
# Icon fills the spec's full (w, h) bbox. The earlier
|
||||||
# aspect-ratio-preserving logic ("fit within w/h") left
|
# aspect-ratio-preserving logic ("fit within w/h") left
|
||||||
@@ -891,7 +941,6 @@ class NativePPTXDiagramRenderer:
|
|||||||
))
|
))
|
||||||
return fragments
|
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"]))
|
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))
|
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] = {
|
service_positions[service_id] = {
|
||||||
@@ -1259,6 +1308,27 @@ class NativePPTXDiagramRenderer:
|
|||||||
rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True)
|
rels_tree.write(str(rels_path), encoding="UTF-8", xml_declaration=True)
|
||||||
return new_rid
|
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:
|
def _target_slide_rels_path(self) -> Path:
|
||||||
slide_name = self._current_slide_path.name
|
slide_name = self._current_slide_path.name
|
||||||
rels_dir = self._current_slide_path.parent / "_rels"
|
rels_dir = self._current_slide_path.parent / "_rels"
|
||||||
@@ -1269,6 +1339,97 @@ class NativePPTXDiagramRenderer:
|
|||||||
for node in element.findall(".//p:cNvPr", namespaces=NS):
|
for node in element.findall(".//p:cNvPr", namespaces=NS):
|
||||||
node.set("id", str(allocate_id()))
|
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"""
|
||||||
|
<p:pic xmlns:p="{P_NS}" xmlns:a="{A_NS}" xmlns:r="{R_NS}">
|
||||||
|
<p:nvPicPr>
|
||||||
|
<p:cNvPr id="{shape_id}" name="{name}"/>
|
||||||
|
<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>
|
||||||
|
<p:nvPr/>
|
||||||
|
</p:nvPicPr>
|
||||||
|
<p:blipFill>
|
||||||
|
<a:blip r:embed="{rel_id}">
|
||||||
|
<a:extLst>
|
||||||
|
<a:ext uri="{{28A0092B-C50C-407E-A947-70E740481C1C}}">
|
||||||
|
<a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
|
||||||
|
</a:ext>
|
||||||
|
</a:extLst>
|
||||||
|
</a:blip>
|
||||||
|
<a:stretch><a:fillRect/></a:stretch>
|
||||||
|
</p:blipFill>
|
||||||
|
<p:spPr>
|
||||||
|
<a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>
|
||||||
|
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||||
|
</p:spPr>
|
||||||
|
</p:pic>
|
||||||
|
"""
|
||||||
|
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):
|
def _fallback_service_card(self, shape_id: int, x: int, y: int, cx: int, cy: int, label: str, color: str):
|
||||||
xml = f"""
|
xml = f"""
|
||||||
<p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
|
<p:sp xmlns:p="{P_NS}" xmlns:a="{A_NS}">
|
||||||
|
|||||||
Reference in New Issue
Block a user