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