| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Visualization Catalog Resolver |
| 4 | |
| 5 | Resolve chart and table references from their live family indexes. |
| 6 | |
| 7 | Historical ``page_charts`` structure keys remain readable as intent-only |
| 8 | compatibility values. They do not resolve to SVG assets and are never part of |
| 9 | the live recall catalog. |
| 10 | |
| 11 | Usage: |
| 12 | Import load_visualization_entries() or resolve_visualization_reference(). |
| 13 | |
| 14 | Examples: |
| 15 | from visualization_catalog import resolve_visualization_reference |
| 16 | |
| 17 | Dependencies: |
| 18 | None (only uses the standard library) |
| 19 | """ |
| 20 | |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import json |
| 24 | import re |
| 25 | import sys |
| 26 | from dataclasses import dataclass |
| 27 | from pathlib import Path |
| 28 | from typing import Iterable |
| 29 | |
| 30 | |
| 31 | _SCRIPTS_DIR = Path(__file__).resolve().parent |
| 32 | _TEMPLATES_DIR = _SCRIPTS_DIR.parent / "templates" |
| 33 | _KEY_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$") |
| 34 | _FAMILY_SPECS = { |
| 35 | "chart": ("charts", "charts_index.json", "charts"), |
| 36 | "table": ("tables", "tables_index.json", "tables"), |
| 37 | } |
| 38 | VISUALIZATION_SVG_KIND = "visualization-svg" |
| 39 | LEGACY_STRUCTURE_INTENT_KIND = "legacy-structure-intent" |
| 40 | |
| 41 | # Frozen from the 36 Structure entries published in origin/main's historical |
| 42 | # broad charts catalog. Keep this exact allowlist: later split-only aliases and |
| 43 | # generated Structure keys were never part of that public compatibility input. |
| 44 | _LEGACY_STRUCTURE_INTENT_KEYS = frozenset( |
| 45 | { |
| 46 | "agenda_list", |
| 47 | "arc_anchored_list", |
| 48 | "chevron_chain_with_tail", |
| 49 | "chevron_process", |
| 50 | "circular_stages", |
| 51 | "client_server_flow", |
| 52 | "comparison_columns", |
| 53 | "concentric_circles", |
| 54 | "fishbone_diagram", |
| 55 | "hub_inward_arrows", |
| 56 | "hub_spoke", |
| 57 | "icon_grid", |
| 58 | "isometric_stairs", |
| 59 | "journey_map", |
| 60 | "kpi_cards", |
| 61 | "labeled_card", |
| 62 | "layered_architecture", |
| 63 | "mind_map", |
| 64 | "module_composition", |
| 65 | "numbered_steps", |
| 66 | "pipeline_with_stages", |
| 67 | "process_flow", |
| 68 | "pros_cons_chart", |
| 69 | "pyramid_chart", |
| 70 | "pyramid_isometric", |
| 71 | "quadrant_bubble_scatter", |
| 72 | "quadrant_text_bullets", |
| 73 | "roadmap_vertical", |
| 74 | "segmented_wheel", |
| 75 | "snake_flow", |
| 76 | "team_roster", |
| 77 | "timeline", |
| 78 | "top_down_tree", |
| 79 | "venn_diagram", |
| 80 | "vertical_list", |
| 81 | "vertical_pillars", |
| 82 | } |
| 83 | ) |
| 84 | |
| 85 | |
| 86 | class VisualizationCatalogError(RuntimeError): |
| 87 | """Reject an unreadable, malformed, missing, or ambiguous reference.""" |
| 88 | |
| 89 | |
| 90 | @dataclass(frozen=True) |
| 91 | class VisualizationEntry: |
| 92 | """One live SVG entry or one frozen legacy Structure intent.""" |
| 93 | |
| 94 | family: str |
| 95 | key: str |
| 96 | summary: str |
| 97 | path: Path | None |
| 98 | kind: str = VISUALIZATION_SVG_KIND |
| 99 | |
| 100 | @property |
| 101 | def reference(self) -> str: |
| 102 | """Return the canonical live ``family/key`` reference.""" |
| 103 | if self.kind != VISUALIZATION_SVG_KIND: |
| 104 | raise VisualizationCatalogError( |
| 105 | f"legacy Structure intent {self.key!r} has no canonical reference" |
| 106 | ) |
| 107 | return f"{self.family}/{self.key}" |
| 108 | |
| 109 | @property |
| 110 | def display_path(self) -> str: |
| 111 | """Return the Skill-relative SVG path.""" |
| 112 | if self.path is None: |
| 113 | raise VisualizationCatalogError( |
| 114 | f"{self.key!r} is a legacy Structure intent without an SVG path" |
| 115 | ) |
| 116 | return self.path.relative_to(_SCRIPTS_DIR.parent).as_posix() |
| 117 | |
| 118 | |
| 119 | @dataclass(frozen=True) |
| 120 | class VisualizationCatalog: |
| 121 | """Canonical entries plus family-local compatibility aliases.""" |
| 122 | |
| 123 | entries: dict[str, VisualizationEntry] |
| 124 | aliases: dict[str, str] |
| 125 | |
| 126 | |
| 127 | def visualization_families() -> tuple[str, ...]: |
| 128 | """Return the stable live visualization family order.""" |
| 129 | return tuple(_FAMILY_SPECS) |
| 130 | |
| 131 | |
| 132 | def legacy_structure_intent_keys() -> tuple[str, ...]: |
| 133 | """Return the frozen legacy ``page_charts`` Structure bare keys.""" |
| 134 | return tuple(sorted(_LEGACY_STRUCTURE_INTENT_KEYS)) |
| 135 | |
| 136 | |
| 137 | def _normalize_families(families: Iterable[str] | None) -> tuple[str, ...]: |
| 138 | requested = visualization_families() if families is None else tuple(families) |
| 139 | normalized: list[str] = [] |
| 140 | for raw_family in requested: |
| 141 | family = str(raw_family).strip().casefold() |
| 142 | if family not in _FAMILY_SPECS: |
| 143 | allowed = ", ".join(visualization_families()) |
| 144 | raise VisualizationCatalogError( |
| 145 | f"unknown visualization family {raw_family!r}; expected one of {allowed}" |
| 146 | ) |
| 147 | if family not in normalized: |
| 148 | normalized.append(family) |
| 149 | if not normalized: |
| 150 | raise VisualizationCatalogError("at least one visualization family is required") |
| 151 | return tuple(normalized) |
| 152 | |
| 153 | |
| 154 | def _load_family(family: str) -> tuple[dict[str, VisualizationEntry], dict[str, str]]: |
| 155 | directory_name, index_name, object_key = _FAMILY_SPECS[family] |
| 156 | family_dir = _TEMPLATES_DIR / directory_name |
| 157 | index_path = family_dir / index_name |
| 158 | try: |
| 159 | payload = json.loads(index_path.read_text(encoding="utf-8")) |
| 160 | except (OSError, json.JSONDecodeError) as exc: |
| 161 | raise VisualizationCatalogError( |
| 162 | f"cannot read {family} catalog {index_path}: {exc}" |
| 163 | ) from exc |
| 164 | if not isinstance(payload, dict): |
| 165 | raise VisualizationCatalogError(f"{family} catalog root must be an object") |
| 166 | raw_entries = payload.get(object_key) |
| 167 | if not isinstance(raw_entries, dict): |
| 168 | raise VisualizationCatalogError( |
| 169 | f"{family} catalog {index_path} has no '{object_key}' object" |
| 170 | ) |
| 171 | |
| 172 | entries: dict[str, VisualizationEntry] = {} |
| 173 | for raw_key, raw_item in raw_entries.items(): |
| 174 | if ( |
| 175 | not isinstance(raw_key, str) |
| 176 | or _KEY_RE.fullmatch(raw_key) is None |
| 177 | or not isinstance(raw_item, dict) |
| 178 | ): |
| 179 | raise VisualizationCatalogError( |
| 180 | f"{family} catalog entry {raw_key!r} is malformed" |
| 181 | ) |
| 182 | summary = raw_item.get("summary") |
| 183 | if not isinstance(summary, str) or not summary.strip(): |
| 184 | raise VisualizationCatalogError( |
| 185 | f"{family} catalog entry {raw_key!r} has no non-empty summary" |
| 186 | ) |
| 187 | entry = VisualizationEntry( |
| 188 | family=family, |
| 189 | key=raw_key, |
| 190 | summary=summary.strip(), |
| 191 | path=(family_dir / f"{raw_key}.svg").resolve(), |
| 192 | ) |
| 193 | entries[entry.reference] = entry |
| 194 | |
| 195 | raw_aliases = payload.get("aliases", {}) |
| 196 | if not isinstance(raw_aliases, dict): |
| 197 | raise VisualizationCatalogError(f"{family} catalog aliases must be an object") |
| 198 | aliases: dict[str, str] = {} |
| 199 | for raw_alias, raw_target in raw_aliases.items(): |
| 200 | if ( |
| 201 | not isinstance(raw_alias, str) |
| 202 | or _KEY_RE.fullmatch(raw_alias) is None |
| 203 | or not isinstance(raw_target, str) |
| 204 | or _KEY_RE.fullmatch(raw_target) is None |
| 205 | ): |
| 206 | raise VisualizationCatalogError( |
| 207 | f"{family} catalog alias {raw_alias!r} is malformed" |
| 208 | ) |
| 209 | target_reference = f"{family}/{raw_target}" |
| 210 | if raw_alias in raw_entries: |
| 211 | raise VisualizationCatalogError( |
| 212 | f"{family} alias {raw_alias!r} collides with a canonical key" |
| 213 | ) |
| 214 | if target_reference not in entries: |
| 215 | raise VisualizationCatalogError( |
| 216 | f"{family} alias {raw_alias!r} targets missing key {raw_target!r}" |
| 217 | ) |
| 218 | aliases[f"{family}/{raw_alias}"] = target_reference |
| 219 | return entries, aliases |
| 220 | |
| 221 | |
| 222 | def load_visualization_catalog( |
| 223 | families: Iterable[str] | None = None, |
| 224 | ) -> VisualizationCatalog: |
| 225 | """Load selected live family registries.""" |
| 226 | selected = _normalize_families(families) |
| 227 | entries: dict[str, VisualizationEntry] = {} |
| 228 | aliases: dict[str, str] = {} |
| 229 | for family in selected: |
| 230 | family_entries, family_aliases = _load_family(family) |
| 231 | entries.update(family_entries) |
| 232 | aliases.update(family_aliases) |
| 233 | if not entries: |
| 234 | shown = ", ".join(selected) |
| 235 | raise VisualizationCatalogError( |
| 236 | f"no visualization entries are available for family selection {shown}" |
| 237 | ) |
| 238 | return VisualizationCatalog(entries=entries, aliases=aliases) |
| 239 | |
| 240 | |
| 241 | def load_visualization_entries( |
| 242 | families: Iterable[str] | None = None, |
| 243 | ) -> dict[str, VisualizationEntry]: |
| 244 | """Return canonical entries keyed by ``family/key``.""" |
| 245 | return load_visualization_catalog(families).entries |
| 246 | |
| 247 | |
| 248 | def _require_svg(entry: VisualizationEntry) -> VisualizationEntry: |
| 249 | if entry.kind != VISUALIZATION_SVG_KIND: |
| 250 | raise VisualizationCatalogError( |
| 251 | f"{entry.family}/{entry.key} has unsupported kind {entry.kind!r}" |
| 252 | ) |
| 253 | if entry.path is None: |
| 254 | raise VisualizationCatalogError(f"{entry.reference!r} has no SVG asset path") |
| 255 | if entry.path.suffix.casefold() != ".svg" or not entry.path.is_file(): |
| 256 | raise VisualizationCatalogError( |
| 257 | f"{entry.reference!r} has no SVG asset at {entry.path}" |
| 258 | ) |
| 259 | return entry |
| 260 | |
| 261 | |
| 262 | def resolve_visualization_reference( |
| 263 | value: str, |
| 264 | *, |
| 265 | allow_legacy_bare: bool = False, |
| 266 | ) -> VisualizationEntry: |
| 267 | """Resolve one live ``family/key`` or supported legacy bare key. |
| 268 | |
| 269 | Legacy Structure intents are accepted only when ``allow_legacy_bare`` is |
| 270 | true. A qualified ``structure/<key>`` is never a live reference. |
| 271 | """ |
| 272 | normalized = str(value).strip().casefold() |
| 273 | if not normalized: |
| 274 | raise VisualizationCatalogError("visualization reference must not be empty") |
| 275 | |
| 276 | catalog = load_visualization_catalog() |
| 277 | if "/" in normalized: |
| 278 | family, separator, key = normalized.partition("/") |
| 279 | if ( |
| 280 | not separator |
| 281 | or family not in _FAMILY_SPECS |
| 282 | or _KEY_RE.fullmatch(key) is None |
| 283 | or "/" in key |
| 284 | ): |
| 285 | raise VisualizationCatalogError( |
| 286 | f"invalid canonical visualization reference {value!r}" |
| 287 | ) |
| 288 | reference = f"{family}/{key}" |
| 289 | entry = catalog.entries.get(reference) |
| 290 | if entry is None: |
| 291 | if reference in catalog.aliases: |
| 292 | raise VisualizationCatalogError( |
| 293 | f"{value!r} is a legacy alias; use {catalog.aliases[reference]!r}" |
| 294 | ) |
| 295 | raise VisualizationCatalogError( |
| 296 | f"canonical visualization reference {value!r} is not registered" |
| 297 | ) |
| 298 | return _require_svg(entry) |
| 299 | |
| 300 | if not allow_legacy_bare: |
| 301 | raise VisualizationCatalogError( |
| 302 | f"{value!r} must use canonical family/key grammar" |
| 303 | ) |
| 304 | if _KEY_RE.fullmatch(normalized) is None: |
| 305 | raise VisualizationCatalogError(f"invalid legacy visualization key {value!r}") |
| 306 | |
| 307 | matches = [ |
| 308 | entry |
| 309 | for entry in catalog.entries.values() |
| 310 | if entry.key == normalized |
| 311 | ] |
| 312 | for alias_reference, target_reference in catalog.aliases.items(): |
| 313 | _, alias = alias_reference.split("/", 1) |
| 314 | if alias == normalized: |
| 315 | matches.append(catalog.entries[target_reference]) |
| 316 | if normalized in _LEGACY_STRUCTURE_INTENT_KEYS: |
| 317 | matches.append( |
| 318 | VisualizationEntry( |
| 319 | family="structure", |
| 320 | key=normalized, |
| 321 | summary=( |
| 322 | "Frozen legacy page_charts Structure intent; author the " |
| 323 | "page structure from its semantic relationships." |
| 324 | ), |
| 325 | path=None, |
| 326 | kind=LEGACY_STRUCTURE_INTENT_KIND, |
| 327 | ) |
| 328 | ) |
| 329 | unique_matches = { |
| 330 | (entry.kind, entry.family, entry.key): entry |
| 331 | for entry in matches |
| 332 | } |
| 333 | if not unique_matches: |
| 334 | raise VisualizationCatalogError( |
| 335 | f"legacy visualization key {value!r} is not registered" |
| 336 | ) |
| 337 | if len(unique_matches) > 1: |
| 338 | candidates = ", ".join( |
| 339 | f"{family}/{key} ({kind})" |
| 340 | for kind, family, key in sorted(unique_matches) |
| 341 | ) |
| 342 | raise VisualizationCatalogError( |
| 343 | f"legacy visualization key {value!r} is ambiguous across {candidates}" |
| 344 | ) |
| 345 | resolved = next(iter(unique_matches.values())) |
| 346 | if resolved.kind == LEGACY_STRUCTURE_INTENT_KIND: |
| 347 | if resolved.path is not None: |
| 348 | raise VisualizationCatalogError( |
| 349 | f"legacy Structure intent {resolved.key!r} must not have an asset path" |
| 350 | ) |
| 351 | return resolved |
| 352 | return _require_svg(resolved) |
| 353 | |
| 354 | |
| 355 | if __name__ == "__main__" and any( |
| 356 | arg in {"-h", "--help", "help"} for arg in sys.argv[1:] |
| 357 | ): |
| 358 | print(__doc__) |
| 359 | raise SystemExit(0) |
| 360 | |
| 361 | |
| 362 | if __name__ == "__main__": |
| 363 | from console_encoding import configure_utf8_stdio |
| 364 | |
| 365 | configure_utf8_stdio() |
| 366 | print( |
| 367 | "Use visualization_catalog via visualization_recall.py or project_manager.py.", |
| 368 | file=sys.stderr, |
| 369 | ) |
| 370 | raise SystemExit(2) |
| 371 |