Add third-party brand icon support

This commit is contained in:
root
2026-04-27 15:15:31 -03:00
parent a032e7980b
commit ee15e1f45e
8 changed files with 473 additions and 12 deletions

45
config/brand-icons.yaml Normal file
View 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

View 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

View 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="#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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

194
tools/brand_icon_catalog.py Normal file
View 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)

View File

@@ -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"),

View File

@@ -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}">