| 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 |