Files
Templated-PPT-Master/runtime/ppt-master/scripts/visualization_catalog.py
T

364 lines
12 KiB
Python

#!/usr/bin/env python3
"""
PPT Master - Visualization Catalog Resolver
Resolve chart and table references from their live family indexes.
Historical ``page_charts`` structure keys remain readable as intent-only
compatibility values. They do not resolve to SVG assets and are never part of
the live recall catalog.
Usage:
Import load_visualization_entries() or resolve_visualization_reference().
Examples:
from visualization_catalog import resolve_visualization_reference
Dependencies:
None (only uses the standard library)
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
_SCRIPTS_DIR = Path(__file__).resolve().parent
_TEMPLATES_DIR = _SCRIPTS_DIR.parent / "templates"
_KEY_RE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
_FAMILY_SPECS = {
"chart": ("charts", "charts_index.json", "charts"),
"table": ("tables", "tables_index.json", "tables"),
}
VISUALIZATION_SVG_KIND = "visualization-svg"
LEGACY_STRUCTURE_INTENT_KIND = "legacy-structure-intent"
# Frozen from the 36 Structure entries published in origin/main's historical
# broad charts catalog. Keep this exact allowlist: later split-only aliases and
# generated Structure keys were never part of that public compatibility input.
_LEGACY_STRUCTURE_INTENT_KEYS = frozenset(
{
"agenda_list",
"arc_anchored_list",
"chevron_chain_with_tail",
"chevron_process",
"circular_stages",
"client_server_flow",
"comparison_columns",
"concentric_circles",
"fishbone_diagram",
"hub_inward_arrows",
"hub_spoke",
"icon_grid",
"isometric_stairs",
"journey_map",
"kpi_cards",
"labeled_card",
"layered_architecture",
"mind_map",
"module_composition",
"numbered_steps",
"pipeline_with_stages",
"process_flow",
"pros_cons_chart",
"pyramid_chart",
"pyramid_isometric",
"quadrant_bubble_scatter",
"quadrant_text_bullets",
"roadmap_vertical",
"segmented_wheel",
"snake_flow",
"team_roster",
"timeline",
"top_down_tree",
"venn_diagram",
"vertical_list",
"vertical_pillars",
}
)
class VisualizationCatalogError(RuntimeError):
"""Reject an unreadable, malformed, missing, or ambiguous reference."""
@dataclass(frozen=True)
class VisualizationEntry:
"""One live SVG entry or one frozen legacy Structure intent."""
family: str
key: str
summary: str
path: Path | None
kind: str = VISUALIZATION_SVG_KIND
@property
def reference(self) -> str:
"""Return the canonical live ``family/key`` reference."""
if self.kind != VISUALIZATION_SVG_KIND:
raise VisualizationCatalogError(
f"legacy Structure intent {self.key!r} has no canonical reference"
)
return f"{self.family}/{self.key}"
@property
def display_path(self) -> str:
"""Return the Skill-relative SVG path."""
if self.path is None:
raise VisualizationCatalogError(
f"{self.key!r} is a legacy Structure intent without an SVG path"
)
return self.path.relative_to(_SCRIPTS_DIR.parent).as_posix()
@dataclass(frozen=True)
class VisualizationCatalog:
"""Canonical entries plus family-local compatibility aliases."""
entries: dict[str, VisualizationEntry]
aliases: dict[str, str]
def visualization_families() -> tuple[str, ...]:
"""Return the stable live visualization family order."""
return tuple(_FAMILY_SPECS)
def legacy_structure_intent_keys() -> tuple[str, ...]:
"""Return the frozen legacy ``page_charts`` Structure bare keys."""
return tuple(sorted(_LEGACY_STRUCTURE_INTENT_KEYS))
def _normalize_families(families: Iterable[str] | None) -> tuple[str, ...]:
requested = visualization_families() if families is None else tuple(families)
normalized: list[str] = []
for raw_family in requested:
family = str(raw_family).strip().casefold()
if family not in _FAMILY_SPECS:
allowed = ", ".join(visualization_families())
raise VisualizationCatalogError(
f"unknown visualization family {raw_family!r}; expected one of {allowed}"
)
if family not in normalized:
normalized.append(family)
if not normalized:
raise VisualizationCatalogError("at least one visualization family is required")
return tuple(normalized)
def _load_family(family: str) -> tuple[dict[str, VisualizationEntry], dict[str, str]]:
directory_name, index_name, object_key = _FAMILY_SPECS[family]
family_dir = _TEMPLATES_DIR / directory_name
index_path = family_dir / index_name
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise VisualizationCatalogError(
f"cannot read {family} catalog {index_path}: {exc}"
) from exc
if not isinstance(payload, dict):
raise VisualizationCatalogError(f"{family} catalog root must be an object")
raw_entries = payload.get(object_key)
if not isinstance(raw_entries, dict):
raise VisualizationCatalogError(
f"{family} catalog {index_path} has no '{object_key}' object"
)
entries: dict[str, VisualizationEntry] = {}
for raw_key, raw_item in raw_entries.items():
if (
not isinstance(raw_key, str)
or _KEY_RE.fullmatch(raw_key) is None
or not isinstance(raw_item, dict)
):
raise VisualizationCatalogError(
f"{family} catalog entry {raw_key!r} is malformed"
)
summary = raw_item.get("summary")
if not isinstance(summary, str) or not summary.strip():
raise VisualizationCatalogError(
f"{family} catalog entry {raw_key!r} has no non-empty summary"
)
entry = VisualizationEntry(
family=family,
key=raw_key,
summary=summary.strip(),
path=(family_dir / f"{raw_key}.svg").resolve(),
)
entries[entry.reference] = entry
raw_aliases = payload.get("aliases", {})
if not isinstance(raw_aliases, dict):
raise VisualizationCatalogError(f"{family} catalog aliases must be an object")
aliases: dict[str, str] = {}
for raw_alias, raw_target in raw_aliases.items():
if (
not isinstance(raw_alias, str)
or _KEY_RE.fullmatch(raw_alias) is None
or not isinstance(raw_target, str)
or _KEY_RE.fullmatch(raw_target) is None
):
raise VisualizationCatalogError(
f"{family} catalog alias {raw_alias!r} is malformed"
)
target_reference = f"{family}/{raw_target}"
if raw_alias in raw_entries:
raise VisualizationCatalogError(
f"{family} alias {raw_alias!r} collides with a canonical key"
)
if target_reference not in entries:
raise VisualizationCatalogError(
f"{family} alias {raw_alias!r} targets missing key {raw_target!r}"
)
aliases[f"{family}/{raw_alias}"] = target_reference
return entries, aliases
def load_visualization_catalog(
families: Iterable[str] | None = None,
) -> VisualizationCatalog:
"""Load selected live family registries."""
selected = _normalize_families(families)
entries: dict[str, VisualizationEntry] = {}
aliases: dict[str, str] = {}
for family in selected:
family_entries, family_aliases = _load_family(family)
entries.update(family_entries)
aliases.update(family_aliases)
if not entries:
shown = ", ".join(selected)
raise VisualizationCatalogError(
f"no visualization entries are available for family selection {shown}"
)
return VisualizationCatalog(entries=entries, aliases=aliases)
def load_visualization_entries(
families: Iterable[str] | None = None,
) -> dict[str, VisualizationEntry]:
"""Return canonical entries keyed by ``family/key``."""
return load_visualization_catalog(families).entries
def _require_svg(entry: VisualizationEntry) -> VisualizationEntry:
if entry.kind != VISUALIZATION_SVG_KIND:
raise VisualizationCatalogError(
f"{entry.family}/{entry.key} has unsupported kind {entry.kind!r}"
)
if entry.path is None:
raise VisualizationCatalogError(f"{entry.reference!r} has no SVG asset path")
if entry.path.suffix.casefold() != ".svg" or not entry.path.is_file():
raise VisualizationCatalogError(
f"{entry.reference!r} has no SVG asset at {entry.path}"
)
return entry
def resolve_visualization_reference(
value: str,
*,
allow_legacy_bare: bool = False,
) -> VisualizationEntry:
"""Resolve one live ``family/key`` or supported legacy bare key.
Legacy Structure intents are accepted only when ``allow_legacy_bare`` is
true. A qualified ``structure/<key>`` is never a live reference.
"""
normalized = str(value).strip().casefold()
if not normalized:
raise VisualizationCatalogError("visualization reference must not be empty")
catalog = load_visualization_catalog()
if "/" in normalized:
family, separator, key = normalized.partition("/")
if (
not separator
or family not in _FAMILY_SPECS
or _KEY_RE.fullmatch(key) is None
or "/" in key
):
raise VisualizationCatalogError(
f"invalid canonical visualization reference {value!r}"
)
reference = f"{family}/{key}"
entry = catalog.entries.get(reference)
if entry is None:
if reference in catalog.aliases:
raise VisualizationCatalogError(
f"{value!r} is a legacy alias; use {catalog.aliases[reference]!r}"
)
raise VisualizationCatalogError(
f"canonical visualization reference {value!r} is not registered"
)
return _require_svg(entry)
if not allow_legacy_bare:
raise VisualizationCatalogError(
f"{value!r} must use canonical family/key grammar"
)
if _KEY_RE.fullmatch(normalized) is None:
raise VisualizationCatalogError(f"invalid legacy visualization key {value!r}")
matches = [
entry
for entry in catalog.entries.values()
if entry.key == normalized
]
for alias_reference, target_reference in catalog.aliases.items():
_, alias = alias_reference.split("/", 1)
if alias == normalized:
matches.append(catalog.entries[target_reference])
if normalized in _LEGACY_STRUCTURE_INTENT_KEYS:
matches.append(
VisualizationEntry(
family="structure",
key=normalized,
summary=(
"Frozen legacy page_charts Structure intent; author the "
"page structure from its semantic relationships."
),
path=None,
kind=LEGACY_STRUCTURE_INTENT_KIND,
)
)
unique_matches = {
(entry.kind, entry.family, entry.key): entry
for entry in matches
}
if not unique_matches:
raise VisualizationCatalogError(
f"legacy visualization key {value!r} is not registered"
)
if len(unique_matches) > 1:
candidates = ", ".join(
f"{family}/{key} ({kind})"
for kind, family, key in sorted(unique_matches)
)
raise VisualizationCatalogError(
f"legacy visualization key {value!r} is ambiguous across {candidates}"
)
resolved = next(iter(unique_matches.values()))
if resolved.kind == LEGACY_STRUCTURE_INTENT_KIND:
if resolved.path is not None:
raise VisualizationCatalogError(
f"legacy Structure intent {resolved.key!r} must not have an asset path"
)
return resolved
return _require_svg(resolved)
if __name__ == "__main__":
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
print(
"Use visualization_catalog via visualization_recall.py or project_manager.py.",
file=sys.stderr,
)
raise SystemExit(2)