| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Semantic SVG Markers |
| 4 | |
| 5 | Owns the minimal page and structural-role marker vocabulary used by SVG |
| 6 | authoring, validation, conversion traces, and native PPTX structure |
| 7 | reconstruction. These markers are compiler hints, not a parallel content model. |
| 8 | |
| 9 | Usage: |
| 10 | Import from svg_quality_checker.py or svg_to_pptx internals. |
| 11 | |
| 12 | Examples: |
| 13 | validate_semantic_markers(root, require_page_role=True) |
| 14 | |
| 15 | Dependencies: |
| 16 | None (standard library only) |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import re |
| 22 | from dataclasses import dataclass |
| 23 | from pathlib import Path |
| 24 | from xml.etree import ElementTree as ET |
| 25 | |
| 26 | |
| 27 | PAGE_ROLE_TO_LAYOUT = { |
| 28 | "cover": "Cover", |
| 29 | "toc": "Agenda", |
| 30 | "section": "Section", |
| 31 | "content": "Content", |
| 32 | "ending": "Closing", |
| 33 | } |
| 34 | |
| 35 | STRUCTURAL_ROLES = frozenset({ |
| 36 | "background", |
| 37 | "chrome", |
| 38 | "decoration", |
| 39 | "footer", |
| 40 | "header", |
| 41 | "logo", |
| 42 | "page-number", |
| 43 | "watermark", |
| 44 | }) |
| 45 | |
| 46 | CHROME_ROLE_TO_TOKEN = { |
| 47 | "chrome": "chrome", |
| 48 | "footer": "footer", |
| 49 | "header": "header", |
| 50 | "logo": "logo", |
| 51 | "page-number": "pagenumber", |
| 52 | "watermark": "watermark", |
| 53 | } |
| 54 | |
| 55 | ANIMATION_CHROME_ROLES = STRUCTURAL_ROLES |
| 56 | |
| 57 | SEMANTIC_ATTRS = frozenset({ |
| 58 | "data-pptx-page-role", |
| 59 | "data-pptx-role", |
| 60 | }) |
| 61 | |
| 62 | _NON_VISUAL_TAGS = frozenset({"defs", "desc", "metadata", "style", "title"}) |
| 63 | _MARKER_TOKEN_RE = re.compile(r"^[a-z][a-z0-9-]*$") |
| 64 | |
| 65 | |
| 66 | @dataclass(frozen=True) |
| 67 | class SemanticMarkerIssue: |
| 68 | """One semantic-marker validation issue.""" |
| 69 | |
| 70 | severity: str |
| 71 | message: str |
| 72 | |
| 73 | |
| 74 | def _local_tag(elem: ET.Element) -> str: |
| 75 | """Return one element's local tag name.""" |
| 76 | return elem.tag.rsplit("}", 1)[-1] if "}" in str(elem.tag) else str(elem.tag) |
| 77 | |
| 78 | |
| 79 | def _normalized_marker(raw: str | None) -> str | None: |
| 80 | """Normalize one marker value, preserving absence.""" |
| 81 | if raw is None: |
| 82 | return None |
| 83 | return raw.strip().lower() |
| 84 | |
| 85 | |
| 86 | def chrome_token_from_role(role: str | None) -> str | None: |
| 87 | """Return the baseline chrome token represented by a semantic role.""" |
| 88 | normalized = _normalized_marker(role) |
| 89 | return CHROME_ROLE_TO_TOKEN.get(normalized or "") |
| 90 | |
| 91 | |
| 92 | def chrome_token_from_markers( |
| 93 | role: str | None, |
| 94 | placeholder: str | None, |
| 95 | ) -> str | None: |
| 96 | """Return chrome behavior from specialized markers before generic roles.""" |
| 97 | if _normalized_marker(placeholder) == "slide-number": |
| 98 | return "pagenumber" |
| 99 | return chrome_token_from_role(role) |
| 100 | |
| 101 | |
| 102 | def is_chrome_role(role: str | None) -> bool: |
| 103 | """Return whether a semantic role represents non-content page chrome.""" |
| 104 | normalized = _normalized_marker(role) |
| 105 | return normalized in ANIMATION_CHROME_ROLES |
| 106 | |
| 107 | |
| 108 | def is_static_page_frame( |
| 109 | role: str | None, |
| 110 | placeholder: str | None, |
| 111 | ) -> bool: |
| 112 | """Return whether explicit markers identify a static page-frame object.""" |
| 113 | return ( |
| 114 | _normalized_marker(placeholder) is not None |
| 115 | or is_chrome_role(role) |
| 116 | ) |
| 117 | |
| 118 | |
| 119 | def page_layout_name_from_svg(svg_path: Path) -> str | None: |
| 120 | """Return the native baseline Layout name declared by the SVG page role.""" |
| 121 | root = ET.parse(svg_path).getroot() |
| 122 | role = _normalized_marker(root.get("data-pptx-page-role")) |
| 123 | return PAGE_ROLE_TO_LAYOUT.get(role or "") |
| 124 | |
| 125 | |
| 126 | def validate_semantic_markers( |
| 127 | root: ET.Element, |
| 128 | *, |
| 129 | require_page_role: bool = False, |
| 130 | ) -> list[SemanticMarkerIssue]: |
| 131 | """Validate semantic markers without changing SVG rendering semantics.""" |
| 132 | issues: list[SemanticMarkerIssue] = [] |
| 133 | page_role_raw = root.get("data-pptx-page-role") |
| 134 | page_role = _normalized_marker(page_role_raw) |
| 135 | if page_role_raw is None: |
| 136 | if require_page_role: |
| 137 | issues.append(SemanticMarkerIssue( |
| 138 | "warning", |
| 139 | "page SVG is missing root data-pptx-page-role", |
| 140 | )) |
| 141 | elif not page_role: |
| 142 | issues.append(SemanticMarkerIssue( |
| 143 | "error", |
| 144 | "root data-pptx-page-role must not be empty", |
| 145 | )) |
| 146 | elif not _MARKER_TOKEN_RE.fullmatch(page_role): |
| 147 | issues.append(SemanticMarkerIssue( |
| 148 | "error", |
| 149 | f"invalid data-pptx-page-role={page_role_raw!r}; use lowercase kebab-case", |
| 150 | )) |
| 151 | elif page_role not in PAGE_ROLE_TO_LAYOUT: |
| 152 | issues.append(SemanticMarkerIssue( |
| 153 | "warning", |
| 154 | f"unknown data-pptx-page-role={page_role_raw!r}", |
| 155 | )) |
| 156 | elif page_role_raw != page_role: |
| 157 | issues.append(SemanticMarkerIssue( |
| 158 | "warning", |
| 159 | f"data-pptx-page-role should use canonical lowercase value {page_role!r}", |
| 160 | )) |
| 161 | |
| 162 | id_counts: dict[str, int] = {} |
| 163 | for elem in root.iter(): |
| 164 | elem_id = (elem.get("id") or "").strip() |
| 165 | if elem_id: |
| 166 | id_counts[elem_id] = id_counts.get(elem_id, 0) + 1 |
| 167 | |
| 168 | marked_ids: set[str] = set() |
| 169 | for elem in root.iter(): |
| 170 | tag = _local_tag(elem) |
| 171 | elem_id = (elem.get("id") or "").strip() |
| 172 | if elem is not root and elem.get("data-pptx-page-role") is not None: |
| 173 | issues.append(SemanticMarkerIssue( |
| 174 | "error", |
| 175 | f"{elem_id or tag}: data-pptx-page-role belongs on the root <svg> only", |
| 176 | )) |
| 177 | |
| 178 | role_raw = elem.get("data-pptx-role") |
| 179 | role = _normalized_marker(role_raw) |
| 180 | if role_raw is not None: |
| 181 | if elem is root or tag in _NON_VISUAL_TAGS: |
| 182 | issues.append(SemanticMarkerIssue( |
| 183 | "error", |
| 184 | f"{elem_id or tag}: data-pptx-role belongs on a visual SVG element", |
| 185 | )) |
| 186 | if not role: |
| 187 | issues.append(SemanticMarkerIssue( |
| 188 | "error", |
| 189 | f"{elem_id or tag}: data-pptx-role must not be empty", |
| 190 | )) |
| 191 | elif not _MARKER_TOKEN_RE.fullmatch(role): |
| 192 | issues.append(SemanticMarkerIssue( |
| 193 | "error", |
| 194 | f"{elem_id or tag}: invalid data-pptx-role={role_raw!r}; " |
| 195 | "use lowercase kebab-case", |
| 196 | )) |
| 197 | elif role not in STRUCTURAL_ROLES: |
| 198 | issues.append(SemanticMarkerIssue( |
| 199 | "warning", |
| 200 | f"{elem_id or tag}: unknown data-pptx-role={role_raw!r}", |
| 201 | )) |
| 202 | elif role_raw != role: |
| 203 | issues.append(SemanticMarkerIssue( |
| 204 | "warning", |
| 205 | f"{elem_id or tag}: data-pptx-role should use canonical " |
| 206 | f"lowercase value {role!r}", |
| 207 | )) |
| 208 | if not elem_id: |
| 209 | issues.append(SemanticMarkerIssue( |
| 210 | "error", |
| 211 | f"<{tag}> with data-pptx-role requires a stable id", |
| 212 | )) |
| 213 | else: |
| 214 | marked_ids.add(elem_id) |
| 215 | |
| 216 | layer = _normalized_marker(elem.get("data-pptx-layer")) |
| 217 | placeholder = _normalized_marker(elem.get("data-pptx-placeholder")) |
| 218 | if layer: |
| 219 | issues.append(SemanticMarkerIssue( |
| 220 | "warning", |
| 221 | f"{elem_id or tag}: data-pptx-role is redundant when " |
| 222 | "data-pptx-layer already owns structure/animation behavior", |
| 223 | )) |
| 224 | if role == "page-number" and placeholder == "slide-number": |
| 225 | issues.append(SemanticMarkerIssue( |
| 226 | "warning", |
| 227 | f"{elem_id or tag}: data-pptx-role='page-number' is redundant; " |
| 228 | "data-pptx-placeholder='slide-number' already owns the behavior", |
| 229 | )) |
| 230 | |
| 231 | duplicates = sorted( |
| 232 | elem_id for elem_id in marked_ids |
| 233 | if id_counts.get(elem_id, 0) > 1 |
| 234 | ) |
| 235 | if duplicates: |
| 236 | issues.append(SemanticMarkerIssue( |
| 237 | "error", |
| 238 | "semantic markers require unique ids; duplicate: " + ", ".join(duplicates), |
| 239 | )) |
| 240 | return issues |
| 241 |