Skip to content

Commit 4a53782

Browse files
committed
feat(action): Hyperlinks 2.0 & Click Actions — issue #21 epic
Closes #21. Authors a unified, accessible API for hyperlinks, ScreenTips, click/hover actions, and accessibility surfaces on shapes, runs, and pictures. Lands eight sub-features as a single coherent surface so the "hyperlink on a run" and "click action on a shape" stories share one Hyperlink/ActionSetting/CT_Hyperlink core. Sub-features: 1. ScreenTip on a run hyperlink — Run.hyperlink.tooltip getter/setter, read/write the `tooltip` attribute on the run's a:hlinkClick. 2. Color override on a hyperlink run — Run.hyperlink.color delegates to the same lazy Font.color machinery; closes scanny/python-pptx scanny#940 and scanny#821 (theme-hyperlink-color override without materialising a:solidFill on read). 3. Click actions for shapes — - ActionSetting.run_macro(macro_name) — emits ppaction://macro?name=<urlquoted> - ActionSetting.target_program(file_path) — emits ppaction://program with an external HYPERLINK relationship - ActionSetting.play_sound(audio_file) — embeds a WAV via the AUDIO relationship under a:snd, with endSnd=True; uses RT.AUDIO (relationships/audio), NOT relationships/media — the latter triggers PowerPoint's Repair dialog on load. 4. Independent hover action — Shape.hover_action exposes an ActionSetting bound to a:hlinkHover so click and hover behaviors can co-exist without overwriting each other. 5. Jump to slide from a text run — Run.click_action gives runs the same ActionSetting surface shapes have; target_slide=other_slide emits ppaction://hlinksldjump with a SLIDE relationship. 6. Picture hyperlinks — Picture.hyperlink exposes the same Hyperlink surface (address, tooltip, color), backed by the picture's cNvPr. 7. Accessibility surface on every shape — - BaseShape.alt_text (cNvPr/@Descr) - BaseShape.alt_title (cNvPr/@title) - BaseShape.is_decorative (adec:decorative ext, Office 2019+) - BaseShape.is_hidden_from_accessibility (alias of is_decorative) 8. Unified click/hover API — runs and shapes share the same ActionSetting/Hyperlink objects so target_slide/run_macro/ target_program/play_sound/tooltip/address work the same on either surface. PowerPoint compatibility notes (load-bearing — verified by Repair-dialog testing): - Every a:hlinkClick / a:hlinkHover we create gets r:id="" as a default even when no relationship is needed. PowerPoint's load-time validator strips a:hlinkClick elements that omit @r:id, even though ECMA-376 marks it optional. Real PowerPoint output always carries r:id, empty when there is no relationship — mirroring the precedent in oxml/shapes/picture.py for ppaction://media. - For an otherwise-empty (tooltip-only or inert) a:hlinkClick, we add action="ppaction://noaction". This matches real PowerPoint's own emission for inert hlinks. The marker is added by _ensure_noaction_if_inert and pruned in lockstep with the rest of the hlink's contents by _prune_hlink_if_empty. - play_sound uses RT.AUDIO, not RT.MEDIA. The Microsoft-2007 relationships/media rel under CT_EmbeddedWAVAudioFile/@r:embed triggers PowerPoint's Repair dialog and strips the entire hlinkClick plus the WAV part. relationships/audio (ECMA-376) is the rel PowerPoint actually expects for embedded WAV under a:snd. KNOWN LIMITATION — no-click-action hover ScreenTip not rendered: PowerPoint does not surface a hover ScreenTip in slideshow mode for shapes that have no click action. This applies to BOTH paths: - cNvPr/a:hlinkClick/@ToolTip without a navigation target - cNvPr/@Descr (alt_text) Both round-trip correctly through save/reload at the XML layer, but PowerPoint's slideshow runtime only activates hover-ScreenTip rendering when the hyperlink carries a real navigation target — URL, slide jump, macro, or program. There is no known fully-supported OOXML workaround for a pure no-click-action hover ScreenTip. ScreenTips on hyperlinks WITH a navigation target work correctly. alt_text remains useful for accessibility / screen reader text. The relevant docstrings (Hyperlink.tooltip, ActionSetting.tooltip, Run.hyperlink.tooltip) all document this limitation. Surface: - src/pptx/action.py: +229 lines — target_program, run_macro, play_sound, tooltip (read/write) on both ActionSetting and Hyperlink; _get_or_add_hlink default r:id=""; helpers _prune_hlink_if_empty, _ensure_noaction_if_inert, _ensure_noaction_pruned - src/pptx/oxml/action.py: +63 lines — CT_Hyperlink expanded with a:snd (CT_EmbeddedWAVAudioFile) child, endSnd attribute, tooltip attribute, tgtFrame attribute; CT_EmbeddedWAVAudioFile element class with @r:embed and @name - src/pptx/oxml/__init__.py: registration for a:snd - src/pptx/shapes/base.py: alt_text, alt_title, is_decorative, is_hidden_from_accessibility properties on BaseShape - src/pptx/shapes/picture.py: hyperlink property - src/pptx/text/text.py: Run.hyperlink.tooltip, Run.hyperlink.color, Run.click_action (parallel to Shape.click_action) - tests/test_issue21_hyperlinks.py: 38 new unit tests covering every sub-feature - features/iss-21-hyperlinks-clickactions.feature: 9 acceptance scenarios (run ScreenTip, alt_text round-trip, run hyperlink color, run-macro, play-sound, hover action, run jump-to-slide, picture hyperlink, tooltip-only XML round-trip) - features/steps/iss21.py: behave step implementations - .gitignore: .DS_Store (macOS Finder noise) Tests: 4021 passed (was 3983 before #21; +38 new). Lint: ruff format + check clean. Behave: 1139 scenarios, 0 failed.
1 parent ee53fd0 commit 4a53782

10 files changed

Lines changed: 1227 additions & 9 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ tags
1414
/tests/debug.py
1515
/uat/
1616
/AGENTS.md
17+
.DS_Store
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Feature: Hyperlinks 2.0 & Click Actions (issue #21)
2+
In order to fully author hyperlink and click/hover behaviors
3+
As a developer using python-pptx
4+
I need ScreenTips, colors, run/macro/program/sound actions, hover, and jumps
5+
6+
Scenario: Set a ScreenTip on a run hyperlink
7+
Given a run with an external hyperlink
8+
When I set the run hyperlink tooltip to "Click for details"
9+
Then the reopened run hyperlink tooltip is "Click for details"
10+
11+
Scenario: Shape alt-text round-trips through save and reopen
12+
Given a shape
13+
When I set the shape alt-text to "Accessible description"
14+
Then the reopened shape alt-text is "Accessible description"
15+
16+
Scenario: A tooltip-only run hyperlink round-trips at the XML layer
17+
Given a run with no hyperlink
18+
When I set the run hyperlink tooltip to "Just a tip"
19+
Then the reopened run hyperlink tooltip is "Just a tip"
20+
And the reopened run hyperlink address is None
21+
22+
Scenario: Override a hyperlink run text color
23+
Given a run with an external hyperlink
24+
When I set the run hyperlink color to C00000
25+
Then the reopened run hyperlink color is C00000
26+
27+
Scenario: Author a run-macro click action on a shape
28+
Given a shape
29+
When I set the shape click action to run macro "Recalc"
30+
Then the reopened shape click action is RUN_MACRO
31+
32+
Scenario: Author a play-sound click action on a shape
33+
Given a shape
34+
When I attach a click sound to the shape
35+
Then the reopened shape click action has an embedded sound
36+
37+
Scenario: Set an independent hover action on a shape
38+
Given a shape
39+
When I set the shape hover action address to "https://hover.example"
40+
Then the reopened shape hover action address is "https://hover.example"
41+
42+
Scenario: Make a text run jump to another slide
43+
Given a run with no hyperlink
44+
When I make the run jump to a new slide
45+
Then the reopened run click action is NAMED_SLIDE
46+
47+
Scenario: Add a hyperlink to a picture
48+
Given a picture on a slide
49+
When I set the picture hyperlink address to "https://pic.example"
50+
Then the reopened picture hyperlink address is "https://pic.example"

features/steps/iss21.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Step implementations for features/iss-21-hyperlinks-clickactions.feature.
2+
3+
Self-contained: every scenario builds an in-memory blank presentation,
4+
round-trips it through a BytesIO save/reopen, and asserts the reopened state.
5+
"""
6+
7+
import io
8+
import os
9+
import struct
10+
import tempfile
11+
import wave
12+
13+
from behave import given, then, when
14+
15+
from pptx import Presentation
16+
from pptx.dml.color import RGBColor
17+
from pptx.enum.action import PP_ACTION
18+
from pptx.util import Inches
19+
20+
TEST_IMAGE = os.path.abspath(
21+
os.path.join(
22+
os.path.dirname(os.path.dirname(__file__)),
23+
"..",
24+
"tests",
25+
"test_files",
26+
"monty-truth.png",
27+
)
28+
)
29+
30+
31+
def _wav():
32+
fd, path = tempfile.mkstemp(suffix=".wav")
33+
os.close(fd)
34+
w = wave.open(path, "w")
35+
w.setnchannels(1)
36+
w.setsampwidth(2)
37+
w.setframerate(8000)
38+
w.writeframes(struct.pack("<h", 0) * 800)
39+
w.close()
40+
return path
41+
42+
43+
def _new_prs(context):
44+
context.prs = Presentation()
45+
context.slide = context.prs.slides.add_slide(context.prs.slide_layouts[6])
46+
47+
48+
def _new_run(context):
49+
tb = context.slide.shapes.add_textbox(
50+
Inches(1), Inches(1), Inches(4), Inches(1)
51+
)
52+
run = tb.text_frame.paragraphs[0].add_run()
53+
run.text = "x"
54+
return run
55+
56+
57+
def _reopen(context):
58+
buf = io.BytesIO()
59+
context.prs.save(buf)
60+
buf.seek(0)
61+
context.prs2 = Presentation(buf)
62+
context.shape2 = context.prs2.slides[0].shapes[-1]
63+
64+
65+
def _first_run(context):
66+
return context.shape2.text_frame.paragraphs[0].runs[0]
67+
68+
69+
@given("a run with an external hyperlink")
70+
def given_run_with_hyperlink(context):
71+
_new_prs(context)
72+
context.run = _new_run(context)
73+
context.run.hyperlink.address = "https://example.com"
74+
75+
76+
@given("a run with no hyperlink")
77+
def given_run_no_hyperlink(context):
78+
_new_prs(context)
79+
context.run = _new_run(context)
80+
81+
82+
@given("a shape")
83+
def given_a_shape(context):
84+
_new_prs(context)
85+
sp = context.slide.shapes.add_textbox(
86+
Inches(1), Inches(3), Inches(3), Inches(1)
87+
)
88+
sp.text_frame.text = "btn"
89+
context.shape = sp
90+
91+
92+
@given("a picture on a slide")
93+
def given_a_picture(context):
94+
_new_prs(context)
95+
context.picture = context.slide.shapes.add_picture(
96+
TEST_IMAGE, Inches(1), Inches(1), Inches(1), Inches(1)
97+
)
98+
99+
100+
@when('I set the run hyperlink tooltip to "{text}"')
101+
def when_set_run_tooltip(context, text):
102+
context.run.hyperlink.tooltip = text
103+
104+
105+
@when('I set the shape alt-text to "{text}"')
106+
def when_set_shape_alt_text(context, text):
107+
context.shape.alt_text = text
108+
109+
110+
@then('the reopened shape alt-text is "{text}"')
111+
def then_shape_alt_text(context, text):
112+
_reopen(context)
113+
assert context.shape2.alt_text == text
114+
115+
116+
@when("I set the run hyperlink color to {hexval}")
117+
def when_set_run_color(context, hexval):
118+
context.run.hyperlink.color.rgb = RGBColor.from_string(hexval)
119+
120+
121+
@when('I set the shape click action to run macro "{name}"')
122+
def when_set_run_macro(context, name):
123+
context.shape.click_action.run_macro(name)
124+
125+
126+
@when("I attach a click sound to the shape")
127+
def when_attach_sound(context):
128+
context.shape.click_action.play_sound(_wav())
129+
130+
131+
@when('I set the shape hover action address to "{url}"')
132+
def when_set_hover(context, url):
133+
context.shape.hover_action.hyperlink.address = url
134+
135+
136+
@when("I make the run jump to a new slide")
137+
def when_run_jump(context):
138+
target = context.prs.slides.add_slide(context.prs.slide_layouts[6])
139+
context.run.click_action.target_slide = target
140+
141+
142+
@when('I set the picture hyperlink address to "{url}"')
143+
def when_set_picture_hyperlink(context, url):
144+
context.picture.hyperlink.address = url
145+
146+
147+
@then('the reopened run hyperlink tooltip is "{text}"')
148+
def then_run_tooltip(context, text):
149+
_reopen(context)
150+
assert _first_run(context).hyperlink.tooltip == text
151+
152+
153+
@then("the reopened run hyperlink address is None")
154+
def then_run_address_none(context):
155+
assert _first_run(context).hyperlink.address is None
156+
157+
158+
@then("the reopened run hyperlink color is {hexval}")
159+
def then_run_color(context, hexval):
160+
_reopen(context)
161+
assert _first_run(context).hyperlink.color.rgb == RGBColor.from_string(hexval)
162+
163+
164+
@then("the reopened shape click action is RUN_MACRO")
165+
def then_shape_macro(context):
166+
_reopen(context)
167+
assert context.shape2.click_action.action == PP_ACTION.RUN_MACRO
168+
169+
170+
@then("the reopened shape click action has an embedded sound")
171+
def then_shape_sound(context):
172+
_reopen(context)
173+
hlink = context.shape2.click_action._hlink
174+
assert hlink is not None and hlink.snd is not None
175+
assert hlink.snd.embed is not None
176+
177+
178+
@then('the reopened shape hover action address is "{url}"')
179+
def then_shape_hover(context, url):
180+
_reopen(context)
181+
assert context.shape2.hover_action.hyperlink.address == url
182+
183+
184+
@then("the reopened run click action is NAMED_SLIDE")
185+
def then_run_jump(context):
186+
_reopen(context)
187+
assert _first_run(context).click_action.action == PP_ACTION.NAMED_SLIDE
188+
189+
190+
@then('the reopened picture hyperlink address is "{url}"')
191+
def then_picture_hyperlink(context, url):
192+
_reopen(context)
193+
pic = next(sh for sh in context.prs2.slides[0].shapes if sh.shape_type == 13)
194+
assert pic.hyperlink.address == url

0 commit comments

Comments
 (0)