返回 ppt-master
register_template.py
根目录 / skills / ppt-master / scripts / register_template.py
1 #!/usr/bin/env python3
2 """Register a brand / style / layout / deck template in the global index.
3
4 Four kinds, four workspace roots, four index files. The shared model lives
5 in ``templates/README.md``; each kind's schema lives in its directory README:
6
7 | --kind | Workspace roots | Index file |
8 |---------|-------------------------|-------------------------------|
9 | brand | ``templates/brands/`` | ``brands_index.json`` |
10 | style | ``templates/styles/`` | ``styles_index.json`` |
11 | layout | ``templates/layouts/`` | ``layouts_index.json`` |
12 | deck | ``templates/decks/`` | ``decks_index.json`` |
13
14 Current workspaces keep ``design_spec.md`` and any SVG roster under
15 ``<workspace>/templates/``. Assets live in optional ``images/`` / ``icons/``
16 directories. Explicitly generated review artifacts go to the optional, ignored
17 ``exports/`` directory. Legacy flat roots remain readable for Brand/Layout/Deck;
18 Style uses only the current nested one-file contract.
19
20 Index entry schemas (the JSON file is the single source of truth — README
21 files describe the kind and usage in prose but do **not** enumerate templates;
22 discovery happens exclusively against the index file):
23
24 - brand: ``{ summary, primary_color }``
25 - style: ``{ summary, keywords[] }``
26 - layout: ``{ summary, canvas_format, page_count, page_types[] }``
27 - deck: ``{ summary, canvas_format, page_count, primary_color }``
28
29 Usage::
30
31 python3 scripts/register_template.py <id> --kind deck # default kind=deck
32 python3 scripts/register_template.py <id> --kind layout
33 python3 scripts/register_template.py <id> --kind brand
34 python3 scripts/register_template.py <id> --kind style
35 python3 scripts/register_template.py --rebuild-all --kind deck
36 python3 scripts/register_template.py <id> --dry-run
37
38 ``--rebuild-all`` rebuilds every entry from scratch within the chosen kind;
39 recommended for repairing index drift across many templates at once.
40
41 Project-scoped Brand and Style workspaces are validated, not registered,
42 through ``svg_quality_checker.py <workspace>/templates --template-mode``. That
43 entry reuses :func:`validate_brand_workspace` or
44 :func:`validate_style_workspace`, so each schema has one authority.
45 """
46
47 from __future__ import annotations
48
49 import argparse
50 import json
51 import re
52 import sys
53 from collections import OrderedDict
54 from pathlib import Path
55 from xml.etree import ElementTree as ET
56
57 from attribution_guard import require_skill_integrity
58 from console_encoding import configure_utf8_stdio
59 from config import CANVAS_FORMATS
60
61 try:
62 import yaml # type: ignore
63 except ImportError:
64 yaml = None
65
66
67 configure_utf8_stdio()
68
69
70 SCRIPT_DIR = Path(__file__).resolve().parent
71 SKILL_DIR = SCRIPT_DIR.parent
72 TEMPLATES_DIR = SKILL_DIR / "templates"
73
74 KIND_CONFIG = {
75 "brand": {
76 "dir": TEMPLATES_DIR / "brands",
77 "index": TEMPLATES_DIR / "brands" / "brands_index.json",
78 "id_key": "brand_id",
79 "needs_svg_roster": False,
80 },
81 "style": {
82 "dir": TEMPLATES_DIR / "styles",
83 "index": TEMPLATES_DIR / "styles" / "styles_index.json",
84 "id_key": "style_id",
85 "needs_svg_roster": False,
86 },
87 "layout": {
88 "dir": TEMPLATES_DIR / "layouts",
89 "index": TEMPLATES_DIR / "layouts" / "layouts_index.json",
90 "id_key": "layout_id",
91 "needs_svg_roster": True,
92 },
93 "deck": {
94 "dir": TEMPLATES_DIR / "decks",
95 "index": TEMPLATES_DIR / "decks" / "decks_index.json",
96 "id_key": "deck_id",
97 "needs_svg_roster": True,
98 },
99 }
100
101 _BRAND_REQUIRED_SECTIONS = (
102 ("I", "Brand Overview"),
103 ("II", "Color Scheme"),
104 ("III", "Typography"),
105 ("IV", "Logo"),
106 ("V", "Voice & Tone"),
107 ("VI", "Icon Style"),
108 )
109 _BRAND_FORBIDDEN_SECTIONS = (
110 "Page Roster",
111 "Signature Design Elements",
112 )
113 _BRAND_ALLOWED_FRONTMATTER_FIELDS = frozenset({
114 "brand_id",
115 "kind",
116 "summary",
117 "primary_color",
118 })
119 _BRAND_PROVENANCE_VALUES = {"fact", "approx", "user"}
120 _BRAND_ASSET_REF_RE = re.compile(
121 r"`((?:\.\./)+(?:images|icons)/[^`]+)`"
122 )
123 _STYLE_REQUIRED_SECTIONS = (
124 ("I", "Style Overview"),
125 ("II", "Communication Method"),
126 ("III", "Page Role Vocabulary"),
127 ("IV", "Evidence & Data Expression"),
128 ("V", "Visual System Defaults"),
129 ("VI", "Image & Icon Direction"),
130 ("VII", "Review Focus"),
131 )
132 _STYLE_FORBIDDEN_SECTIONS = (
133 "Brand Overview",
134 "Template Overview",
135 "Color Scheme",
136 "Typography",
137 "Logo",
138 "Voice & Tone",
139 "Icon Style",
140 "Assets",
141 "Signature Design Elements",
142 "Page Roster",
143 "Placeholder Overrides",
144 )
145 _STYLE_REQUIRED_FIELDS = {
146 "I. Style Overview": (
147 "Style Name",
148 "Best Fit",
149 "Reusable Intent",
150 "Sources",
151 ),
152 "II. Communication Method": (
153 "Argument Flow",
154 "Page Message Discipline",
155 "Claim Discipline",
156 ),
157 "IV. Evidence & Data Expression": (
158 "Argument Trace",
159 "Charts",
160 "Tables",
161 "Sources",
162 "Native Editability",
163 ),
164 "V. Visual System Defaults": (
165 "Composition",
166 "Density",
167 "Decoration",
168 "Color Behavior",
169 "Typography Character",
170 ),
171 "VI. Image & Icon Direction": (
172 "Image Usage",
173 "Image Treatment",
174 "Icon Treatment",
175 ),
176 }
177 _STYLE_CUSTOM_FIELDS = (
178 (
179 "II. Communication Method",
180 "Preferred Mode",
181 "Mode Behavior",
182 "Mode References",
183 SKILL_DIR / "references" / "modes",
184 ),
185 (
186 "V. Visual System Defaults",
187 "Preferred Visual Style",
188 "Visual Style Behavior",
189 "Visual Style References",
190 SKILL_DIR / "references" / "visual-styles",
191 ),
192 (
193 "VI. Image & Icon Direction",
194 "Preferred Image Rendering",
195 "Image Rendering Behavior",
196 "Image Rendering References",
197 SKILL_DIR / "references" / "image-renderings",
198 ),
199 )
200 _STYLE_FORBIDDEN_FIELDS = (
201 "Brand Overview",
202 "Template Overview",
203 "Color Scheme",
204 "Typography",
205 "Signature Design Elements",
206 "Page Roster",
207 "Placeholder Overrides",
208 "Target Audience",
209 "Communication Objective",
210 "Desired Outcome",
211 "Core Message",
212 "Delivery Context",
213 "Artifact Afterlife",
214 "Application Context",
215 "Content Outline",
216 "Page Count",
217 "Page Types",
218 "Page Order",
219 "Page Sequence",
220 "Canvas Format",
221 "Canvas Width",
222 "Canvas Height",
223 "Canvas ViewBox",
224 "Replication Mode",
225 "Native Structure Mode",
226 "Master",
227 "Layout",
228 "Placeholders",
229 "Page Assignment",
230 "Icon Inventory",
231 "Image Resources",
232 "Primary Color",
233 "Logo",
234 "Voice & Tone",
235 "Icon Style",
236 "Assets",
237 )
238 _STYLE_ALLOWED_FRONTMATTER_FIELDS = frozenset({
239 "style_id",
240 "kind",
241 "summary",
242 "keywords",
243 })
244 _STYLE_REVIEW_TRIGGER_MARKER = "<!-- visual-review-trigger: explicit-user-only -->"
245 _HEX_COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
246 _PORTABLE_STYLE_ID_RE = re.compile(r"^\w[\w.-]*$")
247
248
249 # ---------------------------------------------------------------------------
250 # design_spec.md parsing
251 # ---------------------------------------------------------------------------
252
253 class SpecParseError(RuntimeError):
254 """Raised when a design_spec.md cannot be turned into an index entry."""
255
256
257 def _read_spec(spec_path: Path) -> tuple[dict | None, str]:
258 """Split YAML frontmatter from the body. Returns ``(frontmatter, body)``."""
259 text = spec_path.read_text(encoding="utf-8")
260 if not text.startswith("---\n"):
261 return None, text
262 end = text.find("\n---\n", 4)
263 if end == -1:
264 return None, text
265 fm_block = text[4:end]
266 body = text[end + 5:]
267 if yaml is None:
268 raise SpecParseError(
269 "design_spec.md has YAML frontmatter but PyYAML is not installed; "
270 "install pyyaml or remove the frontmatter."
271 )
272 try:
273 data = yaml.safe_load(fm_block) or {}
274 except yaml.YAMLError as exc:
275 raise SpecParseError(f"invalid YAML frontmatter: {exc}") from exc
276 if not isinstance(data, dict):
277 raise SpecParseError("YAML frontmatter must be a mapping")
278 return data, body
279
280
281 def _extract_section_field(body: str, section_title: str, labels: list[str]) -> str | None:
282 section_re = re.compile(
283 rf"^##\s+{re.escape(section_title)}\b.*?(?=^##\s+|\Z)",
284 re.MULTILINE | re.DOTALL,
285 )
286 section_match = section_re.search(body)
287 if section_match is None:
288 return None
289 section = section_match.group(0)
290
291 for label in labels:
292 row = re.search(
293 rf"^\|\s*\*?\*?{re.escape(label)}\*?\*?\s*\|\s*(.+?)\s*\|",
294 section, re.MULTILINE | re.IGNORECASE,
295 )
296 if row:
297 return _clean_field_value(row.group(1))
298
299 bullet = re.search(
300 rf"^[-*]\s*\*?\*?{re.escape(label)}\*?\*?\s*[::]\s*(.+?)\s*$",
301 section, re.MULTILINE | re.IGNORECASE,
302 )
303 if bullet:
304 return _clean_field_value(bullet.group(1))
305 return None
306
307
308 def _clean_field_value(value: str) -> str:
309 value = value.strip()
310 value = re.sub(r"^[`*_]+", "", value)
311 value = re.sub(r"[`*_]+$", "", value)
312 return value.strip()
313
314
315 def _find_first_color(section: str) -> str | None:
316 match = re.search(r"`(#[0-9A-Fa-f]{3,8})`", section)
317 return match.group(1).upper() if match else None
318
319
320 def _extract_primary_color(body: str) -> str | None:
321 section_match = re.search(
322 r"^##\s+[IVX]+\.\s+Color Scheme\b.*?(?=^##\s+|\Z)",
323 body, re.MULTILINE | re.DOTALL,
324 )
325 if section_match is None:
326 return None
327 return _find_first_color(section_match.group(0))
328
329
330 def _summary_from_use_cases(use_cases: str | None) -> str | None:
331 if not use_cases:
332 return None
333 cleaned = use_cases.strip().rstrip(".")
334 if not cleaned:
335 return None
336 return f"{cleaned}."
337
338
339 def _template_content_dir(template_root: Path) -> Path:
340 """Resolve the canonical source directory, with legacy-flat compatibility."""
341 nested = template_root / "templates"
342 if (nested / "design_spec.md").is_file():
343 return nested
344 if (template_root / "design_spec.md").is_file():
345 return template_root
346 raise SpecParseError(
347 f"missing templates/design_spec.md or legacy design_spec.md in {template_root}"
348 )
349
350
351 def _list_pages(template_dir: Path) -> list[str]:
352 return sorted(p.stem for p in template_dir.glob("*.svg"))
353
354
355 def _derive_page_types(pages: list[str]) -> list[str]:
356 """Derive canonical page-type list from SVG filenames (strips leading 'NN_')."""
357 types: list[str] = []
358 seen: set[str] = set()
359 for p in pages:
360 m = re.match(r"^\d+[a-z]?_(.+)$", p)
361 role = m.group(1) if m else p
362 if role not in seen:
363 seen.add(role)
364 types.append(role)
365 return types
366
367
368 def _numbered_section(body: str, title: str) -> str | None:
369 match = re.search(
370 rf"^##\s+[IVX]+\.\s+{re.escape(title)}\s*$.*?(?=^##\s+|\Z)",
371 body,
372 re.MULTILINE | re.DOTALL,
373 )
374 return match.group(0) if match else None
375
376
377 def _markdown_subsection(body: str, title: str) -> str | None:
378 match = re.search(
379 rf"^###\s+{re.escape(title)}\s*$.*?(?=^#{{2,3}}\s+|\Z)",
380 body,
381 re.MULTILINE | re.DOTALL,
382 )
383 return match.group(0) if match else None
384
385
386 def _markdown_table_rows(section: str) -> list[list[str]]:
387 rows: list[list[str]] = []
388 for line in section.splitlines():
389 if not line.lstrip().startswith("|"):
390 continue
391 cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
392 if not cells or all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
393 continue
394 rows.append(cells)
395 return rows
396
397
398 def _style_value_is_substantive(value: str | None) -> bool:
399 if value is None:
400 return False
401 cleaned = _clean_field_value(value)
402 if not cleaned or re.fullmatch(r"<[^>]+>", cleaned):
403 return False
404 return cleaned.casefold() not in {"tbd", "todo", "n/a", "none", "-", "—"}
405
406
407 def _style_field_is_declared(body: str, label: str) -> bool:
408 escaped = re.escape(label)
409 return bool(
410 re.search(
411 rf"^\s*(?:>\s*)?(?:[-*]\s*)?"
412 rf"\*{{0,2}}{escaped}\*{{0,2}}\s*[::]",
413 body,
414 re.MULTILINE | re.IGNORECASE,
415 )
416 or re.search(
417 rf"^\|\s*\*{{0,2}}{escaped}\*{{0,2}}\s*\|",
418 body,
419 re.MULTILINE | re.IGNORECASE,
420 )
421 )
422
423
424 def _validate_brand_spec(
425 expected_template_id: str | None,
426 template_root: Path,
427 template_dir: Path,
428 frontmatter: dict,
429 body: str,
430 pages: list[str],
431 ) -> None:
432 """Reject brand workspaces that cannot be locked as portable identity truth.
433
434 Args:
435 expected_template_id: Registry key to match in library scope. Project
436 workspaces pass ``None`` because their root name is the project id,
437 not the portable brand id.
438 template_root: Brand workspace root containing assets and templates.
439 template_dir: Directory containing ``design_spec.md`` and any page SVGs.
440 frontmatter: Parsed design-spec frontmatter.
441 body: Markdown content after the frontmatter block.
442 pages: SVG page stems discovered beside the design spec.
443 """
444 errors: list[str] = []
445
446 declared_id = str(frontmatter.get("brand_id") or "").strip()
447 if not declared_id:
448 errors.append("frontmatter brand_id must be non-empty")
449 elif expected_template_id is not None and declared_id != expected_template_id:
450 errors.append(
451 "frontmatter brand_id must match directory "
452 f"{expected_template_id!r}, "
453 f"got {declared_id!r}"
454 )
455
456 declared_kind = str(frontmatter.get("kind") or "").strip()
457 if declared_kind != "brand":
458 errors.append(
459 "frontmatter kind must be 'brand', "
460 f"got {declared_kind!r}"
461 )
462
463 if not str(frontmatter.get("summary") or "").strip():
464 errors.append("frontmatter summary must be non-empty")
465
466 unexpected_fields = sorted(
467 set(frontmatter) - _BRAND_ALLOWED_FRONTMATTER_FIELDS
468 )
469 if unexpected_fields:
470 errors.append(
471 "brand frontmatter contains non-identity field(s): "
472 + ", ".join(unexpected_fields)
473 )
474
475 if pages:
476 errors.append(
477 "brand workspaces must not contain page SVGs under templates/: "
478 + ", ".join(f"{page}.svg" for page in pages)
479 )
480
481 for numeral, title in _BRAND_REQUIRED_SECTIONS:
482 if re.search(
483 rf"^##\s+{numeral}\.\s+{re.escape(title)}\s*$",
484 body,
485 re.MULTILINE,
486 ) is None:
487 errors.append(f"missing required section: {numeral}. {title}")
488
489 for title in _BRAND_FORBIDDEN_SECTIONS:
490 if re.search(
491 rf"^##\s+(?:[IVX]+\.\s+)?{re.escape(title)}\s*$",
492 body,
493 re.MULTILINE,
494 ):
495 errors.append(f"brand scope must not declare section: {title}")
496
497 declared_primary = str(frontmatter.get("primary_color") or "").strip()
498 if _HEX_COLOR_RE.fullmatch(declared_primary) is None:
499 errors.append(
500 "frontmatter primary_color must use #RRGGBB, "
501 f"got {declared_primary!r}"
502 )
503
504 color_section = _numbered_section(body, "Color Scheme") or ""
505 primary_rows: list[str] = []
506 for line in color_section.splitlines():
507 if not line.lstrip().startswith("|"):
508 continue
509 cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
510 if len(cells) < 3:
511 continue
512 role = cells[0].strip("` ")
513 raw_color = cells[1].strip("` ")
514 if role.lower() == "role" or re.fullmatch(r":?-+:?", role):
515 continue
516 if _HEX_COLOR_RE.fullmatch(raw_color) is None:
517 errors.append(
518 f"color row {role!r} must use #RRGGBB, "
519 f"got {raw_color!r}"
520 )
521 continue
522 if role.lower() == "primary":
523 primary_rows.append(raw_color.upper())
524 provenance = cells[2].strip("` ").lower()
525 if provenance not in _BRAND_PROVENANCE_VALUES:
526 errors.append(
527 f"color {raw_color} must declare provenance as "
528 "fact, approx, or user"
529 )
530
531 if not primary_rows:
532 errors.append("Color Scheme must declare one primary color row")
533 elif len(primary_rows) > 1:
534 errors.append("Color Scheme must declare only one primary color row")
535 elif (
536 _HEX_COLOR_RE.fullmatch(declared_primary)
537 and primary_rows[0] != declared_primary.upper()
538 ):
539 errors.append(
540 "Color Scheme primary must match frontmatter primary_color: "
541 f"{primary_rows[0]} != {declared_primary.upper()}"
542 )
543
544 root = template_root.resolve()
545 for raw_ref in sorted(set(_BRAND_ASSET_REF_RE.findall(body))):
546 asset = (template_dir / raw_ref).resolve()
547 try:
548 asset.relative_to(root)
549 except ValueError:
550 errors.append(f"asset reference escapes brand workspace: {raw_ref}")
551 continue
552 if not asset.is_file():
553 errors.append(f"referenced brand asset does not exist: {raw_ref}")
554
555 if errors:
556 details = "\n".join(f" - {error}" for error in errors)
557 raise SpecParseError(f"invalid brand specification:\n{details}")
558
559
560 def _validate_style_spec(
561 expected_template_id: str | None,
562 template_root: Path,
563 template_dir: Path,
564 frontmatter: dict,
565 body: str,
566 ) -> None:
567 """Reject Style workspaces outside the roster-free method contract."""
568 errors: list[str] = []
569
570 raw_style_id = frontmatter.get("style_id")
571 declared_id = raw_style_id.strip() if isinstance(raw_style_id, str) else ""
572 if not declared_id:
573 errors.append("frontmatter style_id must be a non-empty string")
574 else:
575 if (
576 _PORTABLE_STYLE_ID_RE.fullmatch(declared_id) is None
577 or declared_id in {".", ".."}
578 or declared_id.endswith(".")
579 ):
580 errors.append(
581 "frontmatter style_id must be a filesystem-safe portable slug"
582 )
583 if (
584 expected_template_id is not None
585 and declared_id != expected_template_id
586 ):
587 errors.append(
588 "frontmatter style_id must match directory "
589 f"{expected_template_id!r}, got {declared_id!r}"
590 )
591
592 raw_kind = frontmatter.get("kind")
593 declared_kind = raw_kind.strip() if isinstance(raw_kind, str) else ""
594 if declared_kind != "style":
595 errors.append(
596 "frontmatter kind must be 'style', "
597 f"got {declared_kind!r}"
598 )
599
600 raw_summary = frontmatter.get("summary")
601 if not isinstance(raw_summary, str) or not _style_value_is_substantive(
602 raw_summary
603 ):
604 errors.append("frontmatter summary must be a non-empty string")
605
606 non_string_fields = [key for key in frontmatter if not isinstance(key, str)]
607 if non_string_fields:
608 errors.append("style frontmatter field names must be strings")
609 unexpected_fields = sorted(
610 key
611 for key in frontmatter
612 if isinstance(key, str) and key not in _STYLE_ALLOWED_FRONTMATTER_FIELDS
613 )
614 if unexpected_fields:
615 errors.append(
616 "style frontmatter contains unsupported field(s): "
617 + ", ".join(unexpected_fields)
618 )
619
620 keywords = frontmatter.get("keywords")
621 if (
622 not isinstance(keywords, list)
623 or not 3 <= len(keywords) <= 5
624 or not all(
625 isinstance(item, str) and _style_value_is_substantive(item)
626 for item in keywords
627 )
628 ):
629 errors.append(
630 "frontmatter keywords must contain 3-5 non-empty strings"
631 )
632 elif len({item.strip().casefold() for item in keywords}) != len(keywords):
633 errors.append("frontmatter keywords must be unique")
634
635 unexpected_source_entries = sorted(
636 path.relative_to(template_dir).as_posix()
637 + ("/" if path.is_dir() else "")
638 for path in template_dir.rglob("*")
639 if path.relative_to(template_dir).as_posix() != "design_spec.md"
640 )
641 if unexpected_source_entries:
642 errors.append(
643 "style workspaces must contain only templates/design_spec.md; "
644 "unexpected template entry(s): "
645 + ", ".join(unexpected_source_entries)
646 )
647
648 if expected_template_id is not None:
649 unexpected_workspace_entries = sorted(
650 path.relative_to(template_root).as_posix()
651 + ("/" if path.is_dir() else "")
652 for path in template_root.rglob("*")
653 if path.relative_to(template_root).as_posix()
654 not in {"templates", "templates/design_spec.md"}
655 )
656 if unexpected_workspace_entries:
657 errors.append(
658 "library Style workspaces must contain only "
659 "templates/design_spec.md; unexpected workspace entry(s): "
660 + ", ".join(unexpected_workspace_entries)
661 )
662
663 h1_headings = re.findall(r"^#\s+(.+?)\s*$", body, re.MULTILINE)
664 if len(h1_headings) != 1:
665 errors.append(
666 "style body must contain exactly one document-title H1; got "
667 f"{len(h1_headings)}"
668 )
669
670 expected_headings = [
671 f"{numeral}. {title}" for numeral, title in _STYLE_REQUIRED_SECTIONS
672 ]
673 actual_headings = re.findall(r"^##\s+(.+?)\s*$", body, re.MULTILINE)
674 if actual_headings != expected_headings:
675 errors.append(
676 "style body must contain exactly the required I-VII sections in "
677 "order; got: " + (", ".join(actual_headings) or "none")
678 )
679
680 nested_headings = re.findall(
681 r"^(#{3,6})\s+(.+?)\s*$",
682 body,
683 re.MULTILINE,
684 )
685 unexpected_nested_headings = [
686 f"{marks} {title}"
687 for marks, title in nested_headings
688 if len(marks) != 3
689 or title not in {"Fallback Color Scheme", "Fallback Typography"}
690 ]
691 if unexpected_nested_headings:
692 errors.append(
693 "style body contains unsupported nested heading(s): "
694 + ", ".join(unexpected_nested_headings)
695 )
696 allowed_h3 = [title for marks, title in nested_headings if len(marks) == 3]
697 if len(allowed_h3) != len(set(allowed_h3)):
698 errors.append("style fallback subsections must not be repeated")
699
700 for title in _STYLE_FORBIDDEN_SECTIONS:
701 if re.search(
702 rf"^#{{1,6}}\s+(?:[IVX]+\.\s+)?{re.escape(title)}\s*$",
703 body,
704 re.MULTILINE,
705 ):
706 errors.append(f"style scope must not declare section: {title}")
707
708 for section_title, labels in _STYLE_REQUIRED_FIELDS.items():
709 section_name = section_title.split(". ", 1)[1]
710 if _numbered_section(body, section_name) is None:
711 continue
712 for label in labels:
713 value = _extract_section_field(body, section_title, [label])
714 if not _style_value_is_substantive(value):
715 errors.append(
716 f"{section_title} must declare a non-empty {label} field"
717 )
718
719 role_section = _numbered_section(body, "Page Role Vocabulary") or ""
720 role_rows = [
721 row
722 for row in _markdown_table_rows(role_section)
723 if row and row[0].casefold() != "role"
724 ]
725 if not any(
726 len(row) >= 4
727 and all(_style_value_is_substantive(cell) for cell in row[:4])
728 for row in role_rows
729 ):
730 errors.append(
731 "III. Page Role Vocabulary must contain at least one complete "
732 "four-column role row"
733 )
734
735 for (
736 section_title,
737 preferred_label,
738 behavior_label,
739 references_label,
740 catalog_dir,
741 ) in _STYLE_CUSTOM_FIELDS:
742 preferred = _extract_section_field(
743 body,
744 section_title,
745 [preferred_label],
746 )
747 behavior = _extract_section_field(
748 body,
749 section_title,
750 [behavior_label],
751 )
752 references = _extract_section_field(
753 body,
754 section_title,
755 [references_label],
756 )
757 preferred_text = _clean_field_value(preferred or "")
758 catalog_ids = {
759 path.stem
760 for path in catalog_dir.glob("*.md")
761 if not path.stem.startswith("_")
762 }
763 is_custom = bool(
764 re.match(r"^custom(?:\b|\s*[:—-])", preferred_text, re.IGNORECASE)
765 )
766 if is_custom and not _style_value_is_substantive(behavior):
767 errors.append(
768 f"{behavior_label} is required when {preferred_label} is custom"
769 )
770 if not is_custom and _style_value_is_substantive(behavior):
771 errors.append(
772 f"{behavior_label} is allowed only when {preferred_label} is custom"
773 )
774 if not is_custom and _style_value_is_substantive(references):
775 errors.append(
776 f"{references_label} is allowed only when "
777 f"{preferred_label} is custom"
778 )
779 if (
780 _style_value_is_substantive(preferred)
781 and not is_custom
782 and preferred_text not in catalog_ids
783 ):
784 errors.append(
785 f"{preferred_label} references unknown catalog id "
786 f"{preferred_text!r}"
787 )
788 if is_custom and _style_value_is_substantive(references):
789 catalog_references = [
790 _clean_field_value(item)
791 for item in (references or "").split(",")
792 ]
793 if any(not item for item in catalog_references):
794 errors.append(
795 f"{references_label} must be a comma-separated list of "
796 "catalog ids"
797 )
798 duplicates = sorted(
799 item
800 for item in set(catalog_references)
801 if catalog_references.count(item) > 1
802 )
803 if duplicates:
804 errors.append(
805 f"{references_label} repeats catalog id(s): "
806 + ", ".join(duplicates)
807 )
808 unknown_references = sorted(
809 item
810 for item in set(catalog_references)
811 if item == "custom" or item not in catalog_ids
812 )
813 if unknown_references:
814 errors.append(
815 f"{references_label} references unknown catalog id(s): "
816 + ", ".join(unknown_references)
817 )
818
819 visual_section = _numbered_section(body, "Visual System Defaults") or ""
820 fallback_colors = _markdown_subsection(body, "Fallback Color Scheme")
821 if fallback_colors is not None:
822 if "### Fallback Color Scheme" not in visual_section:
823 errors.append("Fallback Color Scheme must appear under section V")
824 color_rows = [
825 row
826 for row in _markdown_table_rows(fallback_colors)
827 if row and row[0].casefold() != "role"
828 ]
829 if not color_rows:
830 errors.append("Fallback Color Scheme must contain at least one row")
831 for row in color_rows:
832 if (
833 len(row) < 3
834 or not _style_value_is_substantive(row[0])
835 or _HEX_COLOR_RE.fullmatch(row[1].strip("` ")) is None
836 or not _style_value_is_substantive(row[2])
837 ):
838 errors.append(
839 "Fallback Color Scheme rows must be Role | #RRGGBB | Purpose"
840 )
841 break
842
843 fallback_typography = _markdown_subsection(body, "Fallback Typography")
844 if fallback_typography is not None:
845 if "### Fallback Typography" not in visual_section:
846 errors.append("Fallback Typography must appear under section V")
847 typography_rows = [
848 row
849 for row in _markdown_table_rows(fallback_typography)
850 if row and row[0].casefold() != "role"
851 ]
852 if not any(
853 len(row) >= 4
854 and all(_style_value_is_substantive(cell) for cell in row[:4])
855 for row in typography_rows
856 ):
857 errors.append(
858 "Fallback Typography must contain at least one complete "
859 "four-column row"
860 )
861
862 review_section = _numbered_section(body, "Review Focus") or ""
863 if review_section.count(_STYLE_REVIEW_TRIGGER_MARKER) != 1:
864 errors.append(
865 "VII. Review Focus must contain exactly one "
866 f"{_STYLE_REVIEW_TRIGGER_MARKER} marker"
867 )
868 review_items = re.findall(r"^[-*]\s+(.+?)\s*$", review_section, re.MULTILINE)
869 if not any(_style_value_is_substantive(item) for item in review_items):
870 errors.append("VII. Review Focus must contain at least one check")
871
872 for label in _STYLE_FORBIDDEN_FIELDS:
873 if _style_field_is_declared(body, label):
874 errors.append(f"style scope must not declare field: {label}")
875
876 if errors:
877 details = "\n".join(f" - {error}" for error in errors)
878 raise SpecParseError(f"invalid style specification:\n{details}")
879
880
881 def _validate_svg_template_spec(
882 kind: str,
883 template_id: str,
884 template_dir: Path,
885 frontmatter: dict,
886 pages: list[str],
887 ) -> None:
888 """Reject Layout/Deck workspaces whose registry facts drift from SVGs."""
889 errors: list[str] = []
890 id_key = KIND_CONFIG[kind]["id_key"]
891 declared_id = str(frontmatter.get(id_key) or "").strip()
892 if declared_id != template_id:
893 errors.append(
894 f"frontmatter {id_key} must match directory {template_id!r}, "
895 f"got {declared_id!r}"
896 )
897 declared_kind = str(frontmatter.get("kind") or "").strip()
898 if declared_kind != kind:
899 errors.append(
900 f"frontmatter kind must be {kind!r}, got {declared_kind!r}"
901 )
902 if not str(frontmatter.get("summary") or "").strip():
903 errors.append("frontmatter summary must be non-empty")
904 if not pages:
905 errors.append(f"{kind} workspace must contain at least one template SVG")
906
907 raw_page_count = frontmatter.get("page_count")
908 if isinstance(raw_page_count, bool) or not isinstance(raw_page_count, int):
909 errors.append("frontmatter page_count must be an integer")
910 elif raw_page_count != len(pages):
911 errors.append(
912 f"frontmatter page_count is {raw_page_count}, but templates/ "
913 f"contains {len(pages)} SVG files"
914 )
915
916 canvas_format = str(frontmatter.get("canvas_format") or "").strip()
917 canvas = CANVAS_FORMATS.get(canvas_format)
918 if canvas is None:
919 errors.append(
920 "frontmatter canvas_format must be one of: "
921 + ", ".join(sorted(CANVAS_FORMATS))
922 )
923 else:
924 expected_canvas_fields = {
925 "canvas_width": canvas["width"],
926 "canvas_height": canvas["height"],
927 "canvas_viewbox": canvas["viewbox"],
928 }
929 for field, expected in expected_canvas_fields.items():
930 actual = frontmatter.get(field)
931 if str(actual) != str(expected):
932 errors.append(
933 f"frontmatter {field} must be {expected!r} for "
934 f"{canvas_format}, got {actual!r}"
935 )
936
937 if frontmatter.get("native_structure_mode") != "structured":
938 errors.append(
939 "frontmatter native_structure_mode must be 'structured'"
940 )
941 if frontmatter.get("replication_mode") not in {
942 "standard",
943 "fidelity",
944 "mirror",
945 }:
946 errors.append(
947 "frontmatter replication_mode must be standard, fidelity, or mirror"
948 )
949
950 if kind == "layout":
951 raw_page_types = frontmatter.get("page_types")
952 expected_page_types = _derive_page_types(pages)
953 if not isinstance(raw_page_types, list) or not all(
954 isinstance(item, str) and item.strip()
955 for item in raw_page_types
956 ):
957 errors.append("frontmatter page_types must be a non-empty string list")
958 elif raw_page_types != expected_page_types:
959 errors.append(
960 "frontmatter page_types must exactly match the SVG filename "
961 f"roster: expected {expected_page_types!r}, got {raw_page_types!r}"
962 )
963 else:
964 primary_color = str(frontmatter.get("primary_color") or "").strip()
965 if _HEX_COLOR_RE.fullmatch(primary_color) is None:
966 errors.append(
967 "frontmatter primary_color must use #RRGGBB, "
968 f"got {primary_color!r}"
969 )
970
971 svg_paths = [template_dir / f"{page}.svg" for page in pages]
972 if canvas is not None:
973 expected_viewbox = str(canvas["viewbox"])
974 for svg_path in svg_paths:
975 try:
976 root = ET.parse(svg_path).getroot()
977 except (OSError, ET.ParseError) as exc:
978 errors.append(f"{svg_path.name} is not valid SVG XML: {exc}")
979 continue
980 actual_canvas = (
981 root.get("width"),
982 root.get("height"),
983 root.get("viewBox"),
984 )
985 expected_canvas = (
986 str(canvas["width"]),
987 str(canvas["height"]),
988 expected_viewbox,
989 )
990 if actual_canvas != expected_canvas:
991 errors.append(
992 f"{svg_path.name} canvas is {actual_canvas!r}, expected "
993 f"{expected_canvas!r}"
994 )
995
996 if svg_paths:
997 try:
998 from svg_to_pptx.pptx_package.template_structure import (
999 TemplateStructureError,
1000 parse_template_slides,
1001 )
1002 except ImportError as exc:
1003 errors.append(f"structured SVG roster is invalid: {exc}")
1004 else:
1005 try:
1006 parse_template_slides(svg_paths)
1007 except TemplateStructureError as exc:
1008 errors.append(f"structured SVG roster is invalid: {exc}")
1009
1010 if errors:
1011 details = "\n".join(f" - {error}" for error in errors)
1012 raise SpecParseError(f"invalid {kind} specification:\n{details}")
1013
1014
1015 # ---------------------------------------------------------------------------
1016 # Per-kind extraction
1017 # ---------------------------------------------------------------------------
1018
1019 def _extract_entry(
1020 kind: str,
1021 template_id: str | None,
1022 template_dir: Path,
1023 ) -> dict:
1024 """Build the index entry + extras for a single template."""
1025 template_root = template_dir
1026 template_dir = _template_content_dir(template_root)
1027 if kind == "style" and template_dir == template_root:
1028 raise SpecParseError(
1029 "Style workspaces require templates/design_spec.md; "
1030 "legacy-flat design_spec.md is not supported"
1031 )
1032 spec_path = template_dir / "design_spec.md"
1033
1034 frontmatter, body = _read_spec(spec_path)
1035 fm = frontmatter or {}
1036
1037 declared_kind = fm.get("kind")
1038 if declared_kind not in (None, kind):
1039 raise SpecParseError(
1040 f"design_spec.md frontmatter declares kind={declared_kind!r}; "
1041 f"expected kind={kind!r} — use --kind {declared_kind} instead"
1042 )
1043
1044 raw_summary = fm.get("summary")
1045 summary = raw_summary.strip() if isinstance(raw_summary, str) else ""
1046 if not summary:
1047 section_title = (
1048 "I. Brand Overview" if kind == "brand" else "I. Template Overview"
1049 )
1050 summary = (_summary_from_use_cases(
1051 _extract_section_field(body, section_title, ["Use Cases", "Use cases"])
1052 ) or "").strip()
1053
1054 pages = _list_pages(template_dir)
1055 primary_color = fm.get("primary_color") or _extract_primary_color(body) or ""
1056 resolved_template_id = (
1057 template_id
1058 or str(fm.get(KIND_CONFIG[kind]["id_key"]) or "").strip()
1059 or template_root.name
1060 )
1061
1062 if kind == "brand":
1063 _validate_brand_spec(
1064 template_id,
1065 template_root,
1066 template_dir,
1067 fm,
1068 body,
1069 pages,
1070 )
1071 entry = OrderedDict(
1072 summary=summary,
1073 primary_color=str(primary_color),
1074 )
1075 elif kind == "style":
1076 _validate_style_spec(
1077 template_id,
1078 template_root,
1079 template_dir,
1080 fm,
1081 body,
1082 )
1083 entry = OrderedDict(
1084 summary=summary,
1085 keywords=[item.strip() for item in fm["keywords"]],
1086 )
1087 elif kind == "layout":
1088 if template_id is None:
1089 raise SpecParseError("layout validation requires an expected layout_id")
1090 _validate_svg_template_spec(
1091 kind,
1092 template_id,
1093 template_dir,
1094 fm,
1095 pages,
1096 )
1097 page_types = fm["page_types"]
1098 entry = OrderedDict(
1099 summary=summary,
1100 canvas_format=str(fm["canvas_format"]),
1101 page_count=int(fm["page_count"]),
1102 page_types=list(page_types),
1103 )
1104 elif kind == "deck":
1105 if template_id is None:
1106 raise SpecParseError("deck validation requires an expected deck_id")
1107 _validate_svg_template_spec(
1108 kind,
1109 template_id,
1110 template_dir,
1111 fm,
1112 pages,
1113 )
1114 entry = OrderedDict(
1115 summary=summary,
1116 canvas_format=str(fm["canvas_format"]),
1117 page_count=int(fm["page_count"]),
1118 primary_color=str(primary_color),
1119 )
1120 else:
1121 raise SpecParseError(f"unknown kind {kind!r}")
1122
1123 extras = OrderedDict(
1124 pages=pages,
1125 primary_color=str(primary_color),
1126 page_prefix="templates/" if template_dir != template_root else "",
1127 preview=(
1128 f"exports/{resolved_template_id}_template_preview.pptx"
1129 if (
1130 template_root
1131 / "exports"
1132 / f"{resolved_template_id}_template_preview.pptx"
1133 ).is_file()
1134 else ""
1135 ),
1136 )
1137 return {"entry": entry, "extras": extras}
1138
1139
1140 def validate_brand_workspace(template_root: str | Path) -> dict:
1141 """Validate a portable Brand workspace without registering it.
1142
1143 This is the project-scope entry used by ``svg_quality_checker.py
1144 --template-mode``. Library registration calls the same extraction path with
1145 an expected directory id, so both scopes share one Brand schema authority.
1146 """
1147 return _extract_entry("brand", None, Path(template_root))
1148
1149
1150 def validate_style_workspace(template_root: str | Path) -> dict:
1151 """Validate a portable Style workspace without registering it.
1152
1153 Global library roots also enforce directory identity and the one-file
1154 package boundary. Project roots keep their unrelated initialized-project
1155 scaffolding out of the Style contract.
1156 """
1157 root = Path(template_root)
1158 expected_id = (
1159 root.name
1160 if root.resolve().parent == KIND_CONFIG["style"]["dir"].resolve()
1161 else None
1162 )
1163 return _extract_entry("style", expected_id, root)
1164
1165
1166 # ---------------------------------------------------------------------------
1167 # Index / README writers
1168 # ---------------------------------------------------------------------------
1169
1170 def _load_index(path: Path) -> "OrderedDict[str, dict]":
1171 if not path.exists():
1172 return OrderedDict()
1173 raw_text = path.read_text(encoding="utf-8").strip() or "{}"
1174 raw = json.loads(raw_text)
1175 return OrderedDict(sorted(raw.items()))
1176
1177
1178 def _write_index(path: Path, data: "OrderedDict[str, dict]", *, dry_run: bool) -> None:
1179 payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1180 if dry_run:
1181 print(f"--- {path.name} (dry-run) ---")
1182 print(payload)
1183 return
1184 path.parent.mkdir(parents=True, exist_ok=True)
1185 path.write_text(payload, encoding="utf-8")
1186
1187
1188 def _enumerate_ids(kind: str) -> list[str]:
1189 base = KIND_CONFIG[kind]["dir"]
1190 if not base.exists():
1191 return []
1192 return sorted(
1193 p.name for p in base.iterdir()
1194 if p.is_dir()
1195 and (
1196 (p / "templates" / "design_spec.md").is_file()
1197 or (p / "design_spec.md").is_file()
1198 )
1199 )
1200
1201
1202 def _print_completion_card(kind: str, template_id: str, entry: dict, extras: dict) -> None:
1203 pretty_kind = {
1204 "layout": "Layout",
1205 "deck": "Deck",
1206 "brand": "Brand",
1207 "style": "Style",
1208 }[kind]
1209 dir_name = {
1210 "layout": "layouts",
1211 "deck": "decks",
1212 "brand": "brands",
1213 "style": "styles",
1214 }[kind]
1215 print()
1216 print(f"## {pretty_kind} Registration Complete")
1217 print()
1218 print(f"**{pretty_kind} ID**: {template_id}")
1219 print(f"**Path**: `templates/{dir_name}/{template_id}/`")
1220 if kind in ("brand", "deck"):
1221 primary = entry.get("primary_color") or "—"
1222 print(f"**Primary Color**: {primary}")
1223 if kind in ("layout", "deck"):
1224 canvas = entry.get("canvas_format") or "—"
1225 pc = entry.get("page_count") or "—"
1226 print(f"**Canvas**: {canvas}")
1227 print(f"**Pages**: {pc}")
1228 print(f"**Summary**: {entry.get('summary') or '—'}")
1229 print("**Index Registration**: Done")
1230 print()
1231 if KIND_CONFIG[kind]["needs_svg_roster"]:
1232 pages = extras.get("pages") or []
1233 page_prefix = extras.get("page_prefix") or ""
1234 preview = extras.get("preview") or ""
1235 if preview:
1236 print(f"**Review PPTX**: `{preview}`")
1237 print()
1238 if pages:
1239 print("### Files Included")
1240 print()
1241 print("| File | Status |")
1242 print("|------|--------|")
1243 for page in pages:
1244 print(f"| `{page_prefix}{page}.svg` | Done |")
1245 if preview:
1246 print(f"| `{preview}` | Verified |")
1247 print()
1248
1249
1250 # ---------------------------------------------------------------------------
1251 # Main
1252 # ---------------------------------------------------------------------------
1253
1254 def main() -> int:
1255 require_skill_integrity()
1256 parser = argparse.ArgumentParser(
1257 description=(
1258 "Register / refresh templates (brand / style / layout / deck) "
1259 "in the index."
1260 )
1261 )
1262 parser.add_argument(
1263 "template_id", nargs="?",
1264 help="Template directory id (under templates/<kind_dir>/). Omit with --rebuild-all.",
1265 )
1266 parser.add_argument(
1267 "--kind", choices=list(KIND_CONFIG.keys()), default="deck",
1268 help="Template kind (default: deck).",
1269 )
1270 parser.add_argument("--rebuild-all", action="store_true",
1271 help="Rebuild every index entry within the chosen kind.")
1272 parser.add_argument("--dry-run", action="store_true",
1273 help="Show what would be written without modifying any files.")
1274 args = parser.parse_args()
1275
1276 if not args.template_id and not args.rebuild_all:
1277 parser.error("provide a template_id or use --rebuild-all")
1278
1279 cfg = KIND_CONFIG[args.kind]
1280 base = cfg["dir"]
1281
1282 if args.rebuild_all:
1283 ids = _enumerate_ids(args.kind)
1284 if not ids:
1285 print(f"[OK] No {args.kind} directories found; index left empty.")
1286 _write_index(cfg["index"], OrderedDict(), dry_run=args.dry_run)
1287 return 0
1288 else:
1289 ids = [args.template_id]
1290 spec_dir = base / args.template_id
1291 if not spec_dir.is_dir():
1292 print(f"Error: {args.kind} directory not found: {spec_dir}", file=sys.stderr)
1293 return 1
1294
1295 extracted: dict[str, dict] = {}
1296 for tid in ids:
1297 try:
1298 extracted[tid] = _extract_entry(args.kind, tid, base / tid)
1299 except SpecParseError as exc:
1300 print(f"Error: {tid}: {exc}", file=sys.stderr)
1301 return 1
1302
1303 if args.rebuild_all:
1304 index = OrderedDict((tid, extracted[tid]["entry"]) for tid in sorted(extracted))
1305 else:
1306 index = _load_index(cfg["index"])
1307 for tid, payload in extracted.items():
1308 index[tid] = payload["entry"]
1309 index = OrderedDict(sorted(index.items()))
1310
1311 _write_index(cfg["index"], index, dry_run=args.dry_run)
1312
1313 if not args.dry_run and not args.rebuild_all:
1314 tid = args.template_id
1315 _print_completion_card(
1316 args.kind, tid, extracted[tid]["entry"], extracted[tid]["extras"]
1317 )
1318 return 0
1319
1320 print()
1321 print(
1322 f"[OK] {'Dry-run preview' if args.dry_run else 'Updated'}: "
1323 f"{len(extracted)} {args.kind}(s) processed; index now lists {len(index)} entries."
1324 )
1325 return 0
1326
1327
1328 if __name__ == "__main__":
1329 sys.exit(main())
1330
1330 lines PYTHON