返回 ppt-master
hyperlink_contract.py
根目录 / skills / ppt-master / scripts / hyperlink_contract.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Hyperlink Contract
4
5 Parse and validate the shared SVG hyperlink surface used by quality checks,
6 SVG-to-PPTX export, and PPTX-to-SVG round-trip conversion.
7
8 Usage:
9 from hyperlink_contract import parse_hyperlink_target
10
11 Examples:
12 parse_hyperlink_target("https://example.com")
13 parse_hyperlink_target("#slide-3", slide_count=8)
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 xml.etree import ElementTree as ET
24
25
26 SVG_NS = "http://www.w3.org/2000/svg"
27 XLINK_NS = "http://www.w3.org/1999/xlink"
28 HYPERLINK_REL_TYPE = (
29 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"
30 "hyperlink"
31 )
32 SLIDE_REL_TYPE = (
33 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
34 )
35 SLIDE_JUMP_ACTION = "ppaction://hlinksldjump"
36 SHAPE_HYPERLINK_ATTR = "data-pptx-shape-hyperlink"
37
38 _SLIDE_TARGET_RE = re.compile(r"#slide-([1-9][0-9]*)")
39 _URI_SCHEME_RE = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*):")
40 _FORBIDDEN_EXTERNAL_SCHEMES = frozenset({
41 "data",
42 "file",
43 "javascript",
44 "vbscript",
45 })
46 _NON_OUTPUT_ANCESTORS = frozenset({
47 "defs",
48 "desc",
49 "metadata",
50 "style",
51 "symbol",
52 "title",
53 })
54 _INLINE_CONTAINER_TAGS = frozenset({"text", "tspan"})
55 _INLINE_CONTENT_TAGS = frozenset({"tspan"})
56 _UNSUPPORTED_LINK_BEHAVIOR_ATTRIBUTES = frozenset({
57 "download",
58 "hreflang",
59 "ping",
60 "referrerpolicy",
61 "rel",
62 "target",
63 "type",
64 })
65
66
67 class HyperlinkContractError(ValueError):
68 """Raised when one hyperlink cannot be represented faithfully in PPTX."""
69
70
71 @dataclass(frozen=True)
72 class HyperlinkTarget:
73 """One normalized hyperlink destination."""
74
75 raw: str
76 kind: str
77 slide_number: int | None = None
78
79
80 def _local_name(elem: ET.Element) -> str:
81 if not isinstance(elem.tag, str):
82 return str(elem.tag)
83 return elem.tag.rsplit("}", 1)[-1]
84
85
86 def parse_hyperlink_target(
87 raw: str,
88 *,
89 slide_count: int | None = None,
90 ) -> HyperlinkTarget:
91 """Parse one canonical SVG hyperlink target.
92
93 Same-deck jumps use ``#slide-N``. External destinations must be absolute
94 URIs with an explicit scheme. Local files, script/data URLs, relative
95 paths, arbitrary SVG fragments, whitespace, and control characters fail
96 closed instead of being silently dropped during export.
97 """
98 if not isinstance(raw, str):
99 raise HyperlinkContractError("hyperlink target must be a string")
100 if not raw:
101 raise HyperlinkContractError("hyperlink target must not be empty")
102 if raw != raw.strip():
103 raise HyperlinkContractError(
104 "hyperlink target must not contain leading or trailing whitespace"
105 )
106 if any(ord(char) < 0x21 or ord(char) == 0x7F for char in raw):
107 raise HyperlinkContractError(
108 "hyperlink target must not contain whitespace or control characters; "
109 "percent-encode URI spaces"
110 )
111 if "\\" in raw:
112 raise HyperlinkContractError(
113 "hyperlink target must use URI syntax, not a filesystem path"
114 )
115
116 slide_match = _SLIDE_TARGET_RE.fullmatch(raw)
117 if slide_match is not None:
118 slide_number = int(slide_match.group(1))
119 if slide_count is not None and slide_number > slide_count:
120 raise HyperlinkContractError(
121 f"slide jump {raw!r} exceeds the {slide_count}-slide deck"
122 )
123 return HyperlinkTarget(
124 raw=f"#slide-{slide_number}",
125 kind="slide",
126 slide_number=slide_number,
127 )
128
129 if raw.startswith("#"):
130 raise HyperlinkContractError(
131 "same-deck hyperlinks must use the exact #slide-N form"
132 )
133 scheme_match = _URI_SCHEME_RE.match(raw)
134 if scheme_match is None:
135 raise HyperlinkContractError(
136 "external hyperlinks must be absolute URIs with an explicit scheme"
137 )
138 scheme = scheme_match.group(1).lower()
139 if scheme in _FORBIDDEN_EXTERNAL_SCHEMES:
140 raise HyperlinkContractError(
141 f"external hyperlink scheme {scheme!r} is not allowed"
142 )
143 return HyperlinkTarget(raw=raw, kind="external")
144
145
146 def svg_hyperlink_href(elem: ET.Element) -> str:
147 """Return the sole href value from one SVG ``<a>`` element."""
148 if _local_name(elem) != "a" or elem.tag != f"{{{SVG_NS}}}a":
149 raise HyperlinkContractError("hyperlink carrier must be an SVG <a>")
150 href = elem.get("href")
151 xlink_href = elem.get(f"{{{XLINK_NS}}}href")
152 if href is not None and xlink_href is not None:
153 raise HyperlinkContractError(
154 "SVG <a> must not declare both href and xlink:href"
155 )
156 value = href if href is not None else xlink_href
157 if value is None:
158 raise HyperlinkContractError("SVG <a> must declare href")
159 return value
160
161
162 def project_hyperlink_errors(
163 root: ET.Element,
164 *,
165 slide_count: int | None = None,
166 ) -> list[str]:
167 """Return fail-closed diagnostics for every SVG hyperlink carrier."""
168 errors: list[str] = []
169 parent_by_id = {
170 id(child): parent
171 for parent in root.iter()
172 for child in list(parent)
173 }
174 anchors = [
175 elem for elem in root.iter()
176 if _local_name(elem) == "a"
177 ]
178 for index, anchor in enumerate(anchors, 1):
179 label = anchor.get("id") or f"anchor {index}"
180 if anchor.tag != f"{{{SVG_NS}}}a":
181 errors.append(f"{label}: hyperlink carrier must be an SVG <a>")
182 continue
183 try:
184 href = svg_hyperlink_href(anchor)
185 parse_hyperlink_target(href, slide_count=slide_count)
186 except HyperlinkContractError as exc:
187 errors.append(f"{label}: {exc}")
188
189 unsupported_attrs = sorted(
190 name
191 for name in anchor.attrib
192 if name.rsplit("}", 1)[-1].lower()
193 in _UNSUPPORTED_LINK_BEHAVIOR_ATTRIBUTES
194 )
195 if unsupported_attrs:
196 errors.append(
197 f"{label}: unsupported link behavior attribute(s): "
198 + ", ".join(unsupported_attrs)
199 )
200
201 ancestors: list[ET.Element] = []
202 current = parent_by_id.get(id(anchor))
203 while current is not None:
204 ancestors.append(current)
205 current = parent_by_id.get(id(current))
206 ancestor_tags = [_local_name(elem) for elem in ancestors]
207 if "a" in ancestor_tags:
208 errors.append(f"{label}: nested SVG <a> elements are not supported")
209 hidden_ancestor = next(
210 (tag for tag in ancestor_tags if tag in _NON_OUTPUT_ANCESTORS),
211 None,
212 )
213 if hidden_ancestor is not None:
214 errors.append(
215 f"{label}: hyperlink cannot appear inside non-output "
216 f"<{hidden_ancestor}> content"
217 )
218 replacement_ancestor = next(
219 (
220 elem
221 for elem in ancestors
222 if elem.get("data-pptx-replace-with") is not None
223 or elem.get("data-pptx-part") == "geometry-detail"
224 ),
225 None,
226 )
227 if replacement_ancestor is not None:
228 errors.append(
229 f"{label}: hyperlink cannot appear inside content replaced "
230 "or skipped during native export"
231 )
232
233 inline = any(tag in _INLINE_CONTAINER_TAGS for tag in ancestor_tags)
234 children = [
235 child for child in list(anchor)
236 if _local_name(child) not in _NON_OUTPUT_ANCESTORS
237 ]
238 if inline:
239 if any(_local_name(child) not in _INLINE_CONTENT_TAGS for child in children):
240 errors.append(
241 f"{label}: inline hyperlink content may contain only <tspan>"
242 )
243 positioned = [
244 elem
245 for elem in anchor.iter()
246 if any(elem.get(name) is not None for name in ("x", "y", "dx", "dy"))
247 ]
248 if positioned:
249 errors.append(
250 f"{label}: inline hyperlink cannot own x/y/dx/dy; "
251 "put line positioning on an enclosing <tspan>"
252 )
253 direct_parent = parent_by_id.get(id(anchor))
254 if (
255 direct_parent is not None
256 and _local_name(direct_parent) == "text"
257 and (
258 direct_parent.get("data-paragraph-line-height") is not None
259 or any(
260 _local_name(sibling) == "tspan"
261 and any(
262 sibling.get(name) is not None
263 for name in ("x", "y", "dx", "dy")
264 )
265 for sibling in list(direct_parent)
266 )
267 )
268 ):
269 errors.append(
270 f"{label}: multi-line hyperlinks must be nested inside "
271 "the owning line <tspan>"
272 )
273 visible_text = "".join(anchor.itertext())
274 if not visible_text.strip():
275 errors.append(f"{label}: inline hyperlink must contain visible text")
276 else:
277 if anchor.text and anchor.text.strip():
278 errors.append(
279 f"{label}: shape hyperlink cannot contain direct text; "
280 "wrap text in <text>"
281 )
282 visible_children = [
283 child for child in children
284 if child.get("data-pptx-part") != "geometry-detail"
285 ]
286 if not visible_children:
287 errors.append(
288 f"{label}: shape hyperlink must wrap at least one visual element"
289 )
290 if any(_local_name(child) == "tspan" for child in children):
291 errors.append(
292 f"{label}: shape hyperlink cannot contain a bare <tspan>; "
293 "wrap inline runs in <text>"
294 )
295
296 for index, carrier in enumerate(
297 (
298 elem
299 for elem in root.iter()
300 if elem.get(SHAPE_HYPERLINK_ATTR) is not None
301 ),
302 1,
303 ):
304 label = carrier.get("id") or f"shape hyperlink transport {index}"
305 raw_target = carrier.get(SHAPE_HYPERLINK_ATTR) or ""
306 try:
307 parse_hyperlink_target(raw_target, slide_count=slide_count)
308 except HyperlinkContractError as exc:
309 errors.append(f"{label}: {exc}")
310 if _local_name(carrier) != "g":
311 errors.append(
312 f"{label}: {SHAPE_HYPERLINK_ATTR} is allowed only on <g>"
313 )
314 ancestors: list[ET.Element] = []
315 current = parent_by_id.get(id(carrier))
316 while current is not None:
317 ancestors.append(current)
318 current = parent_by_id.get(id(current))
319 if any(_local_name(elem) == "a" for elem in ancestors):
320 errors.append(
321 f"{label}: {SHAPE_HYPERLINK_ATTR} cannot be nested in <a>"
322 )
323 inline_anchors = [
324 elem
325 for elem in carrier.iter(f"{{{SVG_NS}}}a")
326 if any(
327 _local_name(ancestor) in _INLINE_CONTAINER_TAGS
328 for ancestor in _ancestors_of(elem, parent_by_id)
329 )
330 ]
331 if not inline_anchors:
332 errors.append(
333 f"{label}: {SHAPE_HYPERLINK_ATTR} is reserved for PPTX "
334 "round-trip groups that also contain inline hyperlinks"
335 )
336 return errors
337
338
339 def _ancestors_of(
340 elem: ET.Element,
341 parent_by_id: dict[int, ET.Element],
342 ) -> list[ET.Element]:
343 """Return ancestors from nearest parent to the SVG root."""
344 ancestors: list[ET.Element] = []
345 current = parent_by_id.get(id(elem))
346 while current is not None:
347 ancestors.append(current)
348 current = parent_by_id.get(id(current))
349 return ancestors
350
351
352 def trigger_shape_hyperlink_errors(
353 root: ET.Element,
354 trigger_group_ids: set[str] | frozenset[str],
355 ) -> list[str]:
356 """Reject navigation links on click-trigger animation groups."""
357 if not trigger_group_ids:
358 return []
359 parent_by_id = {
360 id(child): parent
361 for parent in root.iter()
362 for child in list(parent)
363 }
364 errors: list[str] = []
365 for elem in root.iter():
366 group_id = elem.get("id")
367 if _local_name(elem) != "g" or group_id not in trigger_group_ids:
368 continue
369 has_link = elem.get(SHAPE_HYPERLINK_ATTR) is not None or any(
370 _local_name(descendant) == "a"
371 or descendant.get(SHAPE_HYPERLINK_ATTR) is not None
372 for descendant in elem.iter()
373 )
374 if not has_link:
375 has_link = any(
376 _local_name(ancestor) == "a"
377 or ancestor.get(SHAPE_HYPERLINK_ATTR) is not None
378 for ancestor in _ancestors_of(elem, parent_by_id)
379 )
380 if has_link:
381 errors.append(
382 f"animation trigger group {group_id!r} cannot also carry a "
383 "hyperlink; use an ordinary animation or a separate trigger"
384 )
385 return errors
386
387
388 __all__ = [
389 "HYPERLINK_REL_TYPE",
390 "HyperlinkContractError",
391 "HyperlinkTarget",
392 "SHAPE_HYPERLINK_ATTR",
393 "SLIDE_JUMP_ACTION",
394 "SLIDE_REL_TYPE",
395 "parse_hyperlink_target",
396 "project_hyperlink_errors",
397 "svg_hyperlink_href",
398 "trigger_shape_hyperlink_errors",
399 ]
400
400 lines PYTHON