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:
254
tools/archcenter_batch_driver.py
Normal file
254
tools/archcenter_batch_driver.py
Normal 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()
|
||||
1260
tools/archcenter_case_runner.py
Normal file
1260
tools/archcenter_case_runner.py
Normal file
File diff suppressed because it is too large
Load Diff
174
tools/archcenter_description_fetcher.py
Normal file
174
tools/archcenter_description_fetcher.py
Normal 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()
|
||||
276
tools/archcenter_pattern_lookup.py
Normal file
276
tools/archcenter_pattern_lookup.py
Normal 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()
|
||||
216
tools/archcenter_zip_downloader.py
Normal file
216
tools/archcenter_zip_downloader.py
Normal 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()
|
||||
308
tools/diagram_spec_validator.py
Normal file
308
tools/diagram_spec_validator.py
Normal 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())
|
||||
149
tools/drawio_fidelity_eval.py
Normal file
149
tools/drawio_fidelity_eval.py
Normal 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()
|
||||
393
tools/drawio_template_transform.py
Normal file
393
tools/drawio_template_transform.py
Normal 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
169
tools/drawio_to_png.py
Normal 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()
|
||||
399
tools/drawio_visual_validator.py
Normal file
399
tools/drawio_visual_validator.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
159
tools/harvest_drawio_icon.py
Normal file
159
tools/harvest_drawio_icon.py
Normal 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
363
tools/import_oci_library.py
Normal 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(" ", " ")
|
||||
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(" ", " ").replace("&", "&")
|
||||
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("&nbsp;", " ").replace("&", "&"),
|
||||
"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()
|
||||
527
tools/oci_archcenter_batch.py
Normal file
527
tools/oci_archcenter_batch.py
Normal 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()
|
||||
248
tools/oci_archcenter_eval.py
Normal file
248
tools/oci_archcenter_eval.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
739
tools/oci_compare_diagram_gen.py
Normal file
739
tools/oci_compare_diagram_gen.py
Normal 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()
|
||||
@@ -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
1320
tools/oci_pptx_diagram_gen.py
Normal file
1320
tools/oci_pptx_diagram_gen.py
Normal file
File diff suppressed because it is too large
Load Diff
582
tools/oci_pptx_render.py
Normal file
582
tools/oci_pptx_render.py
Normal 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()
|
||||
66
tools/refresh_oci_drawio_toolkit.py
Normal file
66
tools/refresh_oci_drawio_toolkit.py
Normal 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()
|
||||
711
tools/refresh_pptx_icon_index.py
Normal file
711
tools/refresh_pptx_icon_index.py
Normal 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())
|
||||
Reference in New Issue
Block a user