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>
712 lines
26 KiB
Python
712 lines
26 KiB
Python
#!/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())
|