返回 ppt-master
visualization_catalog.py
根目录 / skills / ppt-master / scripts / visualization_catalog.py
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
371 lines PYTHON