返回 ppt-master
hyperlinks.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - DrawingML Hyperlink Lowering
4
5 Register native PowerPoint hyperlink relationships and attach click actions to
6 text runs or clickable leaf shapes produced by the SVG converter.
7
8 Usage:
9 from .hyperlinks import apply_shape_hyperlink, hyperlink_run_metadata
10
11 Examples:
12 metadata = hyperlink_run_metadata(ctx, "https://example.com")
13 linked = apply_shape_hyperlink(result, ctx, "#slide-2")
14
15 Dependencies:
16 PPT Master hyperlink contract and DrawingML conversion context.
17 """
18
19 from __future__ import annotations
20
21 import re
22
23 from hyperlink_contract import (
24 HYPERLINK_REL_TYPE,
25 SLIDE_JUMP_ACTION,
26 SLIDE_REL_TYPE,
27 parse_hyperlink_target,
28 )
29
30 from .context import ConvertContext, ShapeResult
31
32
33 HYPERLINK_RID_KEY = "_hyperlink_rid"
34 HYPERLINK_ACTION_KEY = "_hyperlink_action"
35
36 _CNVPR_RE = re.compile(
37 r"<p:cNvPr\b[^<>]*(?:/>|>.*?</p:cNvPr>)",
38 re.DOTALL,
39 )
40 _GROUP_NONVISUAL_RE = re.compile(
41 r"<p:nvGrpSpPr>.*?</p:nvGrpSpPr>",
42 re.DOTALL,
43 )
44
45
46 def register_hyperlink(
47 ctx: ConvertContext,
48 raw_target: str,
49 ) -> tuple[str, str | None]:
50 """Register one slide relationship and return ``(rId, action)``."""
51 target = parse_hyperlink_target(
52 raw_target,
53 slide_count=ctx.slide_count,
54 )
55 if target.kind == "slide":
56 relationship_type = SLIDE_REL_TYPE
57 relationship_target = f"slide{target.slide_number}.xml"
58 target_mode = None
59 action = SLIDE_JUMP_ACTION
60 else:
61 relationship_type = HYPERLINK_REL_TYPE
62 relationship_target = target.raw
63 target_mode = "External"
64 action = None
65
66 for relationship in ctx.rel_entries:
67 if (
68 relationship.get("type") == relationship_type
69 and relationship.get("target") == relationship_target
70 and relationship.get("target_mode") == target_mode
71 ):
72 return relationship["id"], action
73
74 relationship_id = ctx.next_rel_id()
75 relationship = {
76 "id": relationship_id,
77 "type": relationship_type,
78 "target": relationship_target,
79 }
80 if target_mode is not None:
81 relationship["target_mode"] = target_mode
82 ctx.rel_entries.append(relationship)
83 return relationship_id, action
84
85
86 def hyperlink_click_xml(
87 relationship_id: str,
88 action: str | None = None,
89 ) -> str:
90 """Build one DrawingML click hyperlink child."""
91 action_attr = f' action="{action}"' if action else ""
92 return f'<a:hlinkClick r:id="{relationship_id}"{action_attr}/>'
93
94
95 def hyperlink_run_metadata(
96 ctx: ConvertContext,
97 raw_target: str,
98 ) -> dict[str, str]:
99 """Return the internal run fields for one SVG inline anchor."""
100 relationship_id, action = register_hyperlink(ctx, raw_target)
101 metadata = {HYPERLINK_RID_KEY: relationship_id}
102 if action is not None:
103 metadata[HYPERLINK_ACTION_KEY] = action
104 return metadata
105
106
107 def apply_shape_hyperlink(
108 result: ShapeResult,
109 ctx: ConvertContext,
110 raw_target: str,
111 ) -> ShapeResult:
112 """Attach one click hyperlink to every clickable leaf object.
113
114 DrawingML group containers are selection/animation structures, not a
115 reliable click-action carrier across PowerPoint APIs. A multi-object SVG
116 anchor therefore shares one relationship across each leaf ``p:cNvPr``.
117 Authors use an explicit background shape when the whole rectangular button
118 area, including gaps between descendants, must be clickable.
119 """
120 relationship_id, action = register_hyperlink(ctx, raw_target)
121 hlink_xml = hyperlink_click_xml(relationship_id, action)
122 group_ranges = [
123 (match.start(), match.end())
124 for match in _GROUP_NONVISUAL_RE.finditer(result.xml)
125 ]
126 replacements: list[tuple[int, int, str]] = []
127 for match in _CNVPR_RE.finditer(result.xml):
128 if any(start <= match.start() < end for start, end in group_ranges):
129 continue
130 original = match.group(0)
131 if "<a:hlinkClick" in original:
132 raise ValueError(
133 "hyperlink carrier already contains a click hyperlink"
134 )
135 if original.endswith("/>"):
136 replacement = f"{original[:-2]}>{hlink_xml}</p:cNvPr>"
137 else:
138 replacement = original.replace(
139 "</p:cNvPr>",
140 f"{hlink_xml}</p:cNvPr>",
141 1,
142 )
143 replacements.append((match.start(), match.end(), replacement))
144
145 if not replacements:
146 raise ValueError(
147 "hyperlink carrier did not produce a clickable DrawingML leaf"
148 )
149 linked_xml = result.xml
150 for start, end, replacement in reversed(replacements):
151 linked_xml = linked_xml[:start] + replacement + linked_xml[end:]
152 return ShapeResult(xml=linked_xml, bounds_emu=result.bounds_emu)
153
154
155 __all__ = [
156 "HYPERLINK_ACTION_KEY",
157 "HYPERLINK_RID_KEY",
158 "apply_shape_hyperlink",
159 "hyperlink_click_xml",
160 "hyperlink_run_metadata",
161 "register_hyperlink",
162 ]
163
163 lines PYTHON