forked from diegoecab/oci-deal-accelerator
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>
394 lines
14 KiB
Python
394 lines
14 KiB
Python
#!/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>")
|