返回 ppt-master
semantics.py
根目录 / skills / ppt-master / scripts / pptx_shapes / semantics.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - DrawingML Preset Shape Semantics
4
5 Load and validate the bundled Office-category and authoring-use catalog for
6 DrawingML preset shapes.
7
8 Usage:
9 Import get_preset_shape_semantics from pptx_shapes.semantics.
10
11 Examples:
12 catalog = get_preset_shape_semantics()
13 arrow = catalog.describe("rightArrow")
14
15 Dependencies:
16 None (only uses standard library and local PPT Master modules)
17 """
18
19 from __future__ import annotations
20
21 import json
22 import re
23 from functools import lru_cache
24 from pathlib import Path
25 from typing import Sequence
26
27 from .errors import PresetShapeDataError, UnknownPresetShapeError
28 from .registry import get_preset_registry
29
30
31 BUNDLED_SEMANTICS_PATH = (
32 Path(__file__).resolve().parent / "data" / "presetShapeSemantics.json"
33 )
34
35 SEMANTIC_ROLES = (
36 "field",
37 "carrier",
38 "node",
39 "edge",
40 "spine",
41 "boundary",
42 "callout",
43 "label",
44 "symbol",
45 "control",
46 "accent",
47 )
48 SEMANTIC_RELATIONSHIPS = (
49 "none",
50 "membership",
51 "order",
52 "link",
53 "hierarchy",
54 "contrast",
55 "convergence",
56 "cycle",
57 "overlap",
58 "annotation",
59 "flow",
60 "literal",
61 "navigation",
62 )
63 SEMANTIC_DIRECTIONALITY = (
64 "none",
65 "horizontal",
66 "vertical",
67 "diagonal",
68 "bidirectional",
69 "multidirectional",
70 "bent",
71 "curved",
72 "radial",
73 "callout",
74 )
75 SEMANTIC_ASPECTS = (
76 "flexible",
77 "wide",
78 "tall",
79 "square",
80 "linear",
81 )
82 SEMANTIC_TEXT_CAPACITIES = ("none", "short", "medium", "high")
83 SEMANTIC_VISUAL_WEIGHTS = ("quiet", "moderate", "strong", "emblematic")
84 SEMANTIC_SCOPES = ("general", "literal", "flowchart", "navigation")
85
86 _QUERY_TOKEN_RE = re.compile(r"[a-z0-9]+")
87 _CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
88 _QUERY_STOP_WORDS = frozenset({
89 "a",
90 "an",
91 "and",
92 "for",
93 "of",
94 "or",
95 "the",
96 "to",
97 "with",
98 })
99
100
101 class PresetShapeSemantics:
102 """Read-only semantic catalog aligned exactly with the preset registry."""
103
104 def __init__(self, payload: object, expected_names: Sequence[str]) -> None:
105 root = _require_dict(payload, "catalog")
106 self.schema = _require_string(root.get("schema"), "catalog.schema")
107 self.sources = tuple(
108 _require_dict(item, f"catalog.sources[{index}]")
109 for index, item in enumerate(
110 _require_list(root.get("sources"), "catalog.sources")
111 )
112 )
113
114 categories = _require_list(
115 root.get("office_categories"),
116 "catalog.office_categories",
117 )
118 self._categories: list[dict[str, object]] = []
119 self._category_by_id: dict[str, dict[str, object]] = {}
120 for index, raw_category in enumerate(categories):
121 label = f"catalog.office_categories[{index}]"
122 category = _require_dict(raw_category, label)
123 _reject_unknown_fields(category, label, {"id", "label", "summary"})
124 category_id = _require_string(category.get("id"), f"{label}.id")
125 if category_id in self._category_by_id:
126 raise PresetShapeDataError(
127 f"Duplicate Office shape category: {category_id!r}"
128 )
129 normalized = {
130 "id": category_id,
131 "label": _require_string(category.get("label"), f"{label}.label"),
132 "summary": _require_string(
133 category.get("summary"),
134 f"{label}.summary",
135 ),
136 }
137 self._categories.append(normalized)
138 self._category_by_id[category_id] = normalized
139
140 groups = _require_list(root.get("groups"), "catalog.groups")
141 self._groups: list[dict[str, object]] = []
142 self._preset_semantics: dict[str, dict[str, object]] = {}
143 group_ids: set[str] = set()
144 for index, raw_group in enumerate(groups):
145 group = self._normalize_group(raw_group, index)
146 group_id = str(group["id"])
147 if group_id in group_ids:
148 raise PresetShapeDataError(
149 f"Duplicate preset semantic group: {group_id!r}"
150 )
151 group_ids.add(group_id)
152 self._groups.append(group)
153 presets = group["presets"]
154 assert isinstance(presets, dict)
155 for preset_name, preset_details in presets.items():
156 if preset_name in self._preset_semantics:
157 raise PresetShapeDataError(
158 f"Preset appears in multiple semantic groups: {preset_name!r}"
159 )
160 details = dict(group)
161 details.pop("presets")
162 details["preset"] = preset_name
163 details.update(preset_details)
164 details["recommended_for"] = _merge_strings(
165 group["recommended_for"],
166 preset_details.get("recommended_for", ()),
167 )
168 details["avoid_for"] = _merge_strings(
169 group["avoid_for"],
170 preset_details.get("avoid_for", ()),
171 )
172 self._preset_semantics[preset_name] = details
173
174 expected = set(expected_names)
175 actual = set(self._preset_semantics)
176 missing = sorted(expected - actual)
177 extra = sorted(actual - expected)
178 if missing or extra:
179 raise PresetShapeDataError(
180 "Preset semantic catalog differs from ShapeTypeValues: "
181 f"missing={missing}, extra={extra}"
182 )
183
184 @classmethod
185 def bundled(cls) -> PresetShapeSemantics:
186 """Load the semantic catalog shipped with PPT Master."""
187
188 try:
189 payload = json.loads(BUNDLED_SEMANTICS_PATH.read_text(encoding="utf-8"))
190 except OSError as exc:
191 raise PresetShapeDataError(
192 f"Cannot read preset semantic catalog: {exc}"
193 ) from exc
194 except json.JSONDecodeError as exc:
195 raise PresetShapeDataError(
196 "Invalid preset semantic JSON at "
197 f"line {exc.lineno}, column {exc.colno}: {exc.msg}"
198 ) from exc
199 return cls(payload, get_preset_registry().names)
200
201 @property
202 def preset_count(self) -> int:
203 """Return the number of catalogued presets."""
204
205 return len(self._preset_semantics)
206
207 def describe(self, preset: str) -> dict[str, object]:
208 """Return the resolved semantic record for one preset."""
209
210 try:
211 return dict(self._preset_semantics[preset])
212 except KeyError as exc:
213 raise UnknownPresetShapeError(
214 f"Unknown DrawingML preset shape: {preset!r}"
215 ) from exc
216
217 def grouped(self, search: str = "") -> dict[str, object]:
218 """Return a compact Office/group index and matching preset names."""
219
220 query_tokens = _query_tokens(search)
221 category_payloads: list[dict[str, object]] = []
222 for category in self._categories:
223 group_payloads: list[dict[str, object]] = []
224 for group in self._groups:
225 if group["office_category"] != category["id"]:
226 continue
227 presets = group["presets"]
228 assert isinstance(presets, dict)
229 matching_presets = []
230 group_matches = _matches_query(
231 {
232 key: value
233 for key, value in group.items()
234 if key != "presets"
235 },
236 query_tokens,
237 )
238 for name, details in presets.items():
239 if query_tokens and not group_matches and not _matches_query(
240 {"preset": name, **details},
241 query_tokens,
242 ):
243 continue
244 matching_presets.append(name)
245 if not matching_presets:
246 continue
247 recommended_for = group["recommended_for"]
248 assert isinstance(recommended_for, tuple)
249 group_payload = {
250 "id": group["id"],
251 "label": group["label"],
252 "scope": group["scope"],
253 "intent": recommended_for[0],
254 "presets": matching_presets,
255 }
256 group_payloads.append(group_payload)
257 if group_payloads:
258 category_payloads.append({**category, "groups": group_payloads})
259 return {
260 "schema": self.schema,
261 "sources": [dict(source) for source in self.sources],
262 "preset_count": sum(
263 len(group["presets"])
264 for category in category_payloads
265 for group in category["groups"]
266 ),
267 "search": search,
268 "office_categories": category_payloads,
269 }
270
271 def _normalize_group(
272 self,
273 raw_group: object,
274 index: int,
275 ) -> dict[str, object]:
276 label = f"catalog.groups[{index}]"
277 group = _require_dict(raw_group, label)
278 _reject_unknown_fields(
279 group,
280 label,
281 {
282 "id",
283 "label",
284 "office_category",
285 "scope",
286 "roles",
287 "relationships",
288 "directionality",
289 "aspects",
290 "text_capacity",
291 "visual_weight",
292 "literal_only",
293 "recommended_for",
294 "avoid_for",
295 "presets",
296 },
297 )
298 category_id = _require_string(
299 group.get("office_category"),
300 f"{label}.office_category",
301 )
302 if category_id not in self._category_by_id:
303 raise PresetShapeDataError(
304 f"{label} references unknown Office category {category_id!r}"
305 )
306 scope = _require_choice(group.get("scope"), f"{label}.scope", SEMANTIC_SCOPES)
307 roles = _require_choices(group.get("roles"), f"{label}.roles", SEMANTIC_ROLES)
308 relationships = _require_choices(
309 group.get("relationships"),
310 f"{label}.relationships",
311 SEMANTIC_RELATIONSHIPS,
312 )
313 directionality = _require_choices(
314 group.get("directionality"),
315 f"{label}.directionality",
316 SEMANTIC_DIRECTIONALITY,
317 )
318 aspects = _require_choices(
319 group.get("aspects"),
320 f"{label}.aspects",
321 SEMANTIC_ASPECTS,
322 )
323 presets = _require_dict(group.get("presets"), f"{label}.presets")
324 if not presets:
325 raise PresetShapeDataError(f"{label}.presets must not be empty")
326 normalized_presets: dict[str, dict[str, object]] = {}
327 for preset_name, raw_details in presets.items():
328 preset_label = f"{label}.presets[{preset_name!r}]"
329 details = _require_dict(raw_details, preset_label)
330 _reject_unknown_fields(
331 details,
332 preset_label,
333 {
334 "intent",
335 "scope",
336 "roles",
337 "relationships",
338 "directionality",
339 "aspects",
340 "text_capacity",
341 "visual_weight",
342 "literal_only",
343 "recommended_for",
344 "avoid_for",
345 },
346 )
347 normalized_details: dict[str, object] = {
348 "intent": _require_string(
349 details.get("intent"),
350 f"{preset_label}.intent",
351 )
352 }
353 repeated_choices = {
354 "roles": SEMANTIC_ROLES,
355 "relationships": SEMANTIC_RELATIONSHIPS,
356 "directionality": SEMANTIC_DIRECTIONALITY,
357 "aspects": SEMANTIC_ASPECTS,
358 }
359 for field, choices in repeated_choices.items():
360 if field in details:
361 normalized_details[field] = _require_choices(
362 details[field],
363 f"{preset_label}.{field}",
364 choices,
365 )
366 single_choices = {
367 "scope": SEMANTIC_SCOPES,
368 "text_capacity": SEMANTIC_TEXT_CAPACITIES,
369 "visual_weight": SEMANTIC_VISUAL_WEIGHTS,
370 }
371 for field, choices in single_choices.items():
372 if field in details:
373 normalized_details[field] = _require_choice(
374 details[field],
375 f"{preset_label}.{field}",
376 choices,
377 )
378 if "literal_only" in details:
379 normalized_details["literal_only"] = _require_bool(
380 details["literal_only"],
381 f"{preset_label}.literal_only",
382 )
383 for field in ("recommended_for", "avoid_for"):
384 if field in details:
385 normalized_details[field] = _require_string_list(
386 details[field],
387 f"{preset_label}.{field}",
388 )
389 normalized_presets[preset_name] = normalized_details
390 recommended_for = _require_string_list(
391 group.get("recommended_for"),
392 f"{label}.recommended_for",
393 )
394 avoid_for = _require_string_list(
395 group.get("avoid_for"),
396 f"{label}.avoid_for",
397 )
398 if not recommended_for or not avoid_for:
399 raise PresetShapeDataError(
400 f"{label}.recommended_for and avoid_for must not be empty"
401 )
402 return {
403 "id": _require_string(group.get("id"), f"{label}.id"),
404 "label": _require_string(group.get("label"), f"{label}.label"),
405 "office_category": category_id,
406 "office_category_label": self._category_by_id[category_id]["label"],
407 "scope": scope,
408 "roles": roles,
409 "relationships": relationships,
410 "directionality": directionality,
411 "aspects": aspects,
412 "text_capacity": _require_choice(
413 group.get("text_capacity"),
414 f"{label}.text_capacity",
415 SEMANTIC_TEXT_CAPACITIES,
416 ),
417 "visual_weight": _require_choice(
418 group.get("visual_weight"),
419 f"{label}.visual_weight",
420 SEMANTIC_VISUAL_WEIGHTS,
421 ),
422 "literal_only": _require_bool(
423 group.get("literal_only"),
424 f"{label}.literal_only",
425 ),
426 "recommended_for": recommended_for,
427 "avoid_for": avoid_for,
428 "presets": normalized_presets,
429 }
430
431 @lru_cache(maxsize=1)
432 def get_preset_shape_semantics() -> PresetShapeSemantics:
433 """Return the process-wide, lazily loaded bundled semantic catalog."""
434
435 return PresetShapeSemantics.bundled()
436
437
438 def _require_dict(value: object, label: str) -> dict[str, object]:
439 if not isinstance(value, dict):
440 raise PresetShapeDataError(f"{label} must be a JSON object")
441 return value
442
443
444 def _reject_unknown_fields(
445 value: dict[str, object],
446 label: str,
447 allowed: set[str],
448 ) -> None:
449 unknown = sorted(set(value) - allowed)
450 if unknown:
451 raise PresetShapeDataError(
452 f"{label} contains unsupported fields: {', '.join(unknown)}"
453 )
454
455
456 def _require_list(value: object, label: str) -> list[object]:
457 if not isinstance(value, list):
458 raise PresetShapeDataError(f"{label} must be a JSON array")
459 return value
460
461
462 def _require_string(value: object, label: str) -> str:
463 if not isinstance(value, str) or not value.strip():
464 raise PresetShapeDataError(f"{label} must be a non-empty string")
465 return value.strip()
466
467
468 def _require_bool(value: object, label: str) -> bool:
469 if not isinstance(value, bool):
470 raise PresetShapeDataError(f"{label} must be a boolean")
471 return value
472
473
474 def _require_choice(value: object, label: str, choices: Sequence[str]) -> str:
475 text = _require_string(value, label)
476 if text not in choices:
477 raise PresetShapeDataError(
478 f"{label} must be one of {', '.join(choices)}; got {text!r}"
479 )
480 return text
481
482
483 def _require_choices(
484 value: object,
485 label: str,
486 choices: Sequence[str],
487 ) -> tuple[str, ...]:
488 values = _require_string_list(value, label)
489 if not values:
490 raise PresetShapeDataError(f"{label} must not be empty")
491 unknown = sorted(set(values) - set(choices))
492 if unknown:
493 raise PresetShapeDataError(
494 f"{label} contains unsupported values: {', '.join(unknown)}"
495 )
496 return values
497
498
499 def _require_string_list(value: object, label: str) -> tuple[str, ...]:
500 items = _require_list(value, label)
501 values = tuple(
502 _require_string(item, f"{label}[{index}]")
503 for index, item in enumerate(items)
504 )
505 if len(values) != len(set(values)):
506 raise PresetShapeDataError(f"{label} contains duplicate values")
507 return values
508
509
510 def _merge_strings(first: object, second: object) -> tuple[str, ...]:
511 merged: list[str] = []
512 for values in (first, second):
513 assert isinstance(values, tuple)
514 for value in values:
515 if value not in merged:
516 merged.append(value)
517 return tuple(merged)
518
519
520 def _query_tokens(query: str) -> tuple[str, ...]:
521 normalized = _CAMEL_BOUNDARY_RE.sub(" ", query).casefold()
522 return tuple(
523 token
524 for token in _QUERY_TOKEN_RE.findall(normalized)
525 if token not in _QUERY_STOP_WORDS
526 )
527
528
529 def _search_text(value: object) -> str:
530 if isinstance(value, str):
531 return _CAMEL_BOUNDARY_RE.sub(" ", value).casefold()
532 if isinstance(value, (list, tuple)):
533 return " ".join(_search_text(item) for item in value)
534 if isinstance(value, dict):
535 return " ".join(_search_text(item) for item in value.values())
536 return ""
537
538
539 def _matches_query(details: dict[str, object], query_tokens: Sequence[str]) -> bool:
540 if not query_tokens:
541 return True
542 haystack = _search_text(details)
543 return all(token in haystack for token in query_tokens)
544
544 lines PYTHON