From 4a53782453b7246f79324e6f18a6c9db2a535c94 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Thu, 21 May 2026 08:35:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(action):=20Hyperlinks=202.0=20&=20Click=20?= =?UTF-8?q?Actions=20=E2=80=94=20issue=20#21=20epic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #940 and #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= - 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. --- .gitignore | 1 + .../iss-21-hyperlinks-clickactions.feature | 50 ++ features/steps/iss21.py | 194 ++++++ src/pptx/action.py | 233 ++++++- src/pptx/oxml/__init__.py | 4 +- src/pptx/oxml/action.py | 63 +- src/pptx/shapes/base.py | 14 + src/pptx/shapes/picture.py | 12 + src/pptx/text/text.py | 91 ++- tests/test_issue21_hyperlinks.py | 574 ++++++++++++++++++ 10 files changed, 1227 insertions(+), 9 deletions(-) create mode 100644 features/iss-21-hyperlinks-clickactions.feature create mode 100644 features/steps/iss21.py create mode 100644 tests/test_issue21_hyperlinks.py diff --git a/.gitignore b/.gitignore index 15ecd0737..885860246 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ tags /tests/debug.py /uat/ /AGENTS.md +.DS_Store diff --git a/features/iss-21-hyperlinks-clickactions.feature b/features/iss-21-hyperlinks-clickactions.feature new file mode 100644 index 000000000..f9f1b5c10 --- /dev/null +++ b/features/iss-21-hyperlinks-clickactions.feature @@ -0,0 +1,50 @@ +Feature: Hyperlinks 2.0 & Click Actions (issue #21) + In order to fully author hyperlink and click/hover behaviors + As a developer using python-pptx + I need ScreenTips, colors, run/macro/program/sound actions, hover, and jumps + + Scenario: Set a ScreenTip on a run hyperlink + Given a run with an external hyperlink + When I set the run hyperlink tooltip to "Click for details" + Then the reopened run hyperlink tooltip is "Click for details" + + Scenario: Shape alt-text round-trips through save and reopen + Given a shape + When I set the shape alt-text to "Accessible description" + Then the reopened shape alt-text is "Accessible description" + + Scenario: A tooltip-only run hyperlink round-trips at the XML layer + Given a run with no hyperlink + When I set the run hyperlink tooltip to "Just a tip" + Then the reopened run hyperlink tooltip is "Just a tip" + And the reopened run hyperlink address is None + + Scenario: Override a hyperlink run text color + Given a run with an external hyperlink + When I set the run hyperlink color to C00000 + Then the reopened run hyperlink color is C00000 + + Scenario: Author a run-macro click action on a shape + Given a shape + When I set the shape click action to run macro "Recalc" + Then the reopened shape click action is RUN_MACRO + + Scenario: Author a play-sound click action on a shape + Given a shape + When I attach a click sound to the shape + Then the reopened shape click action has an embedded sound + + Scenario: Set an independent hover action on a shape + Given a shape + When I set the shape hover action address to "https://hover.example" + Then the reopened shape hover action address is "https://hover.example" + + Scenario: Make a text run jump to another slide + Given a run with no hyperlink + When I make the run jump to a new slide + Then the reopened run click action is NAMED_SLIDE + + Scenario: Add a hyperlink to a picture + Given a picture on a slide + When I set the picture hyperlink address to "https://pic.example" + Then the reopened picture hyperlink address is "https://pic.example" diff --git a/features/steps/iss21.py b/features/steps/iss21.py new file mode 100644 index 000000000..3f9ee0f15 --- /dev/null +++ b/features/steps/iss21.py @@ -0,0 +1,194 @@ +"""Step implementations for features/iss-21-hyperlinks-clickactions.feature. + +Self-contained: every scenario builds an in-memory blank presentation, +round-trips it through a BytesIO save/reopen, and asserts the reopened state. +""" + +import io +import os +import struct +import tempfile +import wave + +from behave import given, then, when + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.action import PP_ACTION +from pptx.util import Inches + +TEST_IMAGE = os.path.abspath( + os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "..", + "tests", + "test_files", + "monty-truth.png", + ) +) + + +def _wav(): + fd, path = tempfile.mkstemp(suffix=".wav") + os.close(fd) + w = wave.open(path, "w") + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(8000) + w.writeframes(struct.pack(" None: + """Make this a "run program" click/hover action targeting `file_path`. + + Emits `action="ppaction://program"` on the hyperlink element and relates an + external relationship to `file_path`. The resulting :attr:`action` is + `PP_ACTION.RUN_PROGRAM`. Any prior click/hover action is replaced. + """ + self._clear_click_action() + rId = self.part.relate_to(file_path, RT.HYPERLINK, is_external=True) + hlink = self._get_or_add_hlink() + hlink.action = "ppaction://program" + hlink.rId = rId + + def run_macro(self, macro_name: str) -> None: + """Make this a "run macro" click/hover action invoking `macro_name`. + + Emits `action="ppaction://macro?name="`. The macro name is + URL-quoted so names containing spaces remain a single, well-formed action + URL. No relationship is allocated — a macro action is name-only. Any prior + click/hover action is replaced. + """ + self._clear_click_action() + hlink = self._get_or_add_hlink() + hlink.action = "ppaction://macro?name=%s" % quote(macro_name, safe="") + + def play_sound(self, audio_file: str, mime_type: str = "audio/wav") -> None: + """Attach an embedded sound that plays when this shape is clicked/hovered. + + Adds an `a:snd` child referencing an embedded WAV media part created from + `audio_file` (a path or file-like object). `endSnd` is set so the sound + stops on the next action, matching PowerPoint's own default. The sound + coexists with any hyperlink/action already present — it does not clear it. + + The relationship from the hlink's `a:snd` child to the WAV part uses + `RT.AUDIO` (ECMA-376 `relationships/audio`), which is the rel type + PowerPoint expects on `CT_EmbeddedWAVAudioFile/@r:embed`. Using the + Microsoft-2007 `relationships/media` rel here triggers a load-time + Repair dialog in PowerPoint that strips the entire hlinkClick element + and the embedded WAV part — `relationships/media` is for video media. + """ + from pptx.media import Video + + media = Video.from_path_or_file_like(audio_file, mime_type) + media_part = self.part.package.get_or_add_media_part(media) + rId = self.part.relate_to(media_part, RT.AUDIO) + hlink = self._get_or_add_hlink() + snd = hlink.get_or_add_snd() + snd.embed = rId + if media.filename: + snd.name = media.filename + hlink.endSnd = True + + @property + def tooltip(self) -> str | None: + """Read/write. The ScreenTip text shown on hover over this shape/run. + + Returns |None| when no hyperlink element is present or its `tooltip` + attribute is unset or empty. Assigning a string creates the hyperlink + element if necessary. Assigning |None| or `""` removes the tooltip, + pruning the hyperlink element if it then carries no other action. + + Known PowerPoint limitation: a hyperlink ScreenTip is only rendered + on hover when the hyperlink also carries a real navigation target — + a URL via :attr:`hyperlink.address`, a slide jump via + :meth:`target_slide`, or a macro/program action. A bare tooltip + without a click target round-trips through save/reload as valid + OOXML but PowerPoint will not surface it on hover. There is no + known fully-supported workaround at the OOXML layer for a + no-click-action hover ScreenTip; :attr:`BaseShape.alt_text` is the + nearest analog and is the right home for accessibility/screen-reader + text, but PowerPoint does not render it on slideshow hover either. + """ + hlink = self._hlink + if hlink is None: + return None + return hlink.tooltip or None + + @tooltip.setter + def tooltip(self, value: str | None) -> None: + if not value: + hlink = self._hlink + if hlink is None: + return + hlink.tooltip = None + _ensure_noaction_pruned(hlink) + _prune_hlink_if_empty(self._element, hlink) + return + hlink = self._get_or_add_hlink() + hlink.tooltip = value + _ensure_noaction_if_inert(hlink) + + def _get_or_add_hlink(self) -> CT_Hyperlink: + """The `a:hlinkClick` or `a:hlinkHover` element, created if absent. + + Newly-created hlinks get `r:id=""` as the default. PowerPoint's load-time + validation triggers a Repair dialog on `a:hlinkClick`/`a:hlinkHover` + elements that omit the `r:id` attribute entirely (even though ECMA-376 + marks it `use="optional"`). Real PowerPoint output always carries `r:id`, + empty when there is no relationship — mirroring the precedent already + baked into `oxml/shapes/picture.py` for `ppaction://media`. + """ + if self._hover: + hlink = cast("CT_NonVisualDrawingProps", self._element).get_or_add_hlinkHover() + else: + hlink = self._element.get_or_add_hlinkClick() + if hlink.rId is None: + hlink.rId = "" + return hlink + def _clear_click_action(self): """Remove any existing click action.""" hlink = self._hlink @@ -241,10 +352,18 @@ def _get_or_add_hlink(self) -> CT_Hyperlink: """Get the `a:hlinkClick` or `a:hlinkHover` element for the Hyperlink object. The actual element depends on the value of `self._hover`. Create the element if not present. + + Newly-created hlinks get `r:id=""` as the default — see the matching note + on `ActionSetting._get_or_add_hlink`. Without it, PowerPoint throws a + Repair dialog and strips the element on load. """ if self._hover: - return cast("CT_NonVisualDrawingProps", self._element).get_or_add_hlinkHover() - return self._element.get_or_add_hlinkClick() + hlink = cast("CT_NonVisualDrawingProps", self._element).get_or_add_hlinkHover() + else: + hlink = self._element.get_or_add_hlinkClick() + if hlink.rId is None: + hlink.rId = "" + return hlink @property def _hlink(self) -> CT_Hyperlink | None: @@ -256,6 +375,42 @@ def _hlink(self) -> CT_Hyperlink | None: return cast("CT_NonVisualDrawingProps", self._element).hlinkHover return self._element.hlinkClick + @property + def tooltip(self) -> str | None: + """Read/write. The ScreenTip text shown on hover over this hyperlink. + + Returns |None| when no hyperlink element is present or its `tooltip` + attribute is unset or empty. Assigning a string creates the hyperlink + element if necessary. Assigning |None| or `""` removes the tooltip, + pruning the hyperlink element if it then carries no URL or action. + + Known PowerPoint limitation: this ScreenTip is only rendered on + hover when the hyperlink also carries a real navigation target — a + URL, slide jump, macro, or program action. A bare tooltip-only + hyperlink is valid OOXML and round-trips through save/reload, but + PowerPoint will not surface it on hover. There is no known + fully-supported workaround at the OOXML layer for a + no-click-action hover ScreenTip. + """ + hlink = self._hlink + if hlink is None: + return None + return hlink.tooltip or None + + @tooltip.setter + def tooltip(self, value: str | None) -> None: + if not value: + hlink = self._hlink + if hlink is None: + return + hlink.tooltip = None + _ensure_noaction_pruned(hlink) + _prune_hlink_if_empty(self._element, hlink) + return + hlink = self._get_or_add_hlink() + hlink.tooltip = value + _ensure_noaction_if_inert(hlink) + def _remove_hlink(self): """Remove the a:hlinkClick or a:hlinkHover element. @@ -268,3 +423,77 @@ def _remove_hlink(self): if rId: self.part.drop_rel(rId) self._element.remove(hlink) + + +def _prune_hlink_if_empty(parent: BaseOxmlElement, hlink: CT_Hyperlink) -> None: + """Remove `hlink` from `parent` when it carries no URL, action, or sound. + + Used by the `tooltip` setters: clearing a tooltip should not leave behind an + inert, empty `a:hlinkClick`/`a:hlinkHover` element (which PowerPoint tolerates + but which is noise). An hlink is "empty" when it has no `r:id`, no `action`, + no `tooltip`, no `tgtFrame`, and no `a:snd` child. + + The synthetic `ppaction://noaction` verb (see `_ensure_noaction_if_inert`) + is treated as "no real action" for prune purposes — it is a marker we add + so PowerPoint accepts a tooltip-bearing hlink without triggering its + load-time Repair dialog. When everything else falls away, the noaction + marker should fall away too. + """ + if hlink.rId: + return + if hlink.action is not None and hlink.action != "ppaction://noaction": + return + if hlink.tooltip: + return + if hlink.tgtFrame: + return + if hlink.snd is not None: + return + parent.remove(hlink) + + +def _ensure_noaction_if_inert(hlink: CT_Hyperlink) -> None: + """Set `action="ppaction://noaction"` on an inert hlink for PowerPoint compatibility. + + A bare `` with no action verb survives + a save/reload round-trip at the XML layer, but real PowerPoint output for + an inert (no-URL, no-jump) hlink always carries `action="ppaction://noaction"`. + Adding the marker keeps our output isomorphic with PowerPoint's own emission + and protects the hlink from being stripped by future PowerPoint validators. + + Note: this marker does NOT cause PowerPoint to render the tooltip on hover. + PowerPoint's hover-ScreenTip processor activates only on hlinks with a real + navigation target (URL, slide jump, macro, program). For a pure hover + ScreenTip on a shape with no click behavior, callers should use + `BaseShape.alt_text` (`cNvPr/@descr`) instead — that is PowerPoint's + documented mechanism for non-hyperlink hover ScreenTips. + + Only adds the marker when (a) the hlink has no `r:id` relationship AND + (b) the hlink has no other `action` verb. If a URL is added later, the + address setter rebuilds the hlink from scratch; if a `target_slide` / + `run_macro` / `target_program` is added later, those overwrite `action` + explicitly. The marker is a no-op for any hlink that already has a real + action or a real relationship. + """ + if hlink.rId: + return + if hlink.action is not None: + return + hlink.action = "ppaction://noaction" + + +def _ensure_noaction_pruned(hlink: CT_Hyperlink) -> None: + """Remove the synthetic `ppaction://noaction` marker if it has become unneeded. + + Called from the tooltip-clear path: when the tooltip is removed, the + marker we added in `_ensure_noaction_if_inert` should not linger as the + sole content of an otherwise-empty hlink. `_prune_hlink_if_empty` runs + immediately after and treats noaction as prune-eligible, but this helper + also handles the case where an hlink retains a sibling (e.g. an ``) + that should NOT carry the noaction marker. + """ + if hlink.action != "ppaction://noaction": + return + if hlink.rId: + return + hlink.action = None diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index 6e4121bba..e12770e2e 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -46,10 +46,12 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): namespace[nsptag.local_part] = cls -from pptx.oxml.action import CT_Hyperlink # noqa: E402 +from pptx.oxml.action import CT_EmbeddedWAVAudioFile, CT_Hyperlink # noqa: E402 register_element_cls("a:hlinkClick", CT_Hyperlink) register_element_cls("a:hlinkHover", CT_Hyperlink) +register_element_cls("a:hlinkMouseOver", CT_Hyperlink) +register_element_cls("a:snd", CT_EmbeddedWAVAudioFile) from pptx.oxml.chart.axis import ( # noqa: E402 diff --git a/src/pptx/oxml/action.py b/src/pptx/oxml/action.py index 9b31a9e16..16c955f46 100644 --- a/src/pptx/oxml/action.py +++ b/src/pptx/oxml/action.py @@ -1,18 +1,73 @@ -"""lxml custom element classes for text-related XML elements.""" +"""lxml custom element classes for click-action and hyperlink XML elements.""" from __future__ import annotations -from pptx.oxml.simpletypes import XsdString -from pptx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute +from typing import Callable + +from pptx.oxml.simpletypes import ST_RelationshipId, XsdBoolean, XsdString +from pptx.oxml.xmlchemy import ( + BaseOxmlElement, + OptionalAttribute, + RequiredAttribute, + ZeroOrOne, +) + + +class CT_EmbeddedWAVAudioFile(BaseOxmlElement): + """`a:snd` element — an embedded WAV audio file played by a click/hover action. + + Schema type `CT_EmbeddedWAVAudioFile` (ECMA-376 dml-main.xsd). `r:embed` is a + required relationship id pointing at the embedded WAV media part; `name` is an + optional human-readable label PowerPoint shows in its UI. + """ + + embed: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "r:embed", ST_RelationshipId + ) + name: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "name", XsdString + ) class CT_Hyperlink(BaseOxmlElement): - """Custom element class for elements.""" + """Custom element class for `a:hlinkClick`, `a:hlinkHover`, and `a:hlinkMouseOver`. + + A single element type (schema `CT_Hyperlink`) serves three host contexts: the + click and hover hyperlinks on a shape's `p:cNvPr`, and the click and mouse-over + hyperlinks on a text run's `a:rPr`. The schema sequence is `snd?`, `extLst?`; + emitting `a:snd` after `a:extLst` is a silent PowerPoint repair, so `snd` is + declared with `a:extLst` as its only successor. + """ + + get_or_add_snd: Callable[[], CT_EmbeddedWAVAudioFile] + _remove_snd: Callable[[], None] + + snd: CT_EmbeddedWAVAudioFile | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:snd", successors=("a:extLst",) + ) rId: str = OptionalAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] + invalidUrl: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "invalidUrl", XsdString + ) action: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] "action", XsdString ) + tgtFrame: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "tgtFrame", XsdString + ) + tooltip: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "tooltip", XsdString + ) + history: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "history", XsdBoolean + ) + highlightClick: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "highlightClick", XsdBoolean + ) + endSnd: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "endSnd", XsdBoolean + ) @property def action_fields(self) -> dict[str, str]: diff --git a/src/pptx/shapes/base.py b/src/pptx/shapes/base.py index b6c63891a..f559067f1 100644 --- a/src/pptx/shapes/base.py +++ b/src/pptx/shapes/base.py @@ -69,6 +69,20 @@ def click_action(self) -> ActionSetting: cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage] return ActionSetting(cNvPr, self) + @lazyproperty + def hover_action(self) -> ActionSetting: + """|ActionSetting| instance providing access to mouse-over behaviors. + + The hover action is the behavior triggered when the mouse pointer is + positioned over this shape during a slide show, stored as an + `a:hlinkHover` element on the shape's `p:cNvPr` (parallel to the + `a:hlinkClick` of :attr:`click_action`). It is independent of the click + action — a shape may define both. An |ActionSetting| object is always + returned, even when no hover behavior is defined. + """ + cNvPr = self._element._nvXxPr.cNvPr # pyright: ignore[reportPrivateUsage] + return ActionSetting(cNvPr, self, hover=True) + @property def element(self) -> ShapeElement: """`lxml` element for this shape, e.g. a CT_Shape instance. diff --git a/src/pptx/shapes/picture.py b/src/pptx/shapes/picture.py index 59182860d..964f785ea 100644 --- a/src/pptx/shapes/picture.py +++ b/src/pptx/shapes/picture.py @@ -189,6 +189,18 @@ def image(self): raise ValueError("no embedded image") return slide_part.get_image(rId) + @lazyproperty + def hyperlink(self): + """The |Hyperlink| object for the click action on this picture. + + Closes scanny/python-pptx#576 and #962 — pictures could not carry a + hyperlink without manipulating XML directly. ``picture.hyperlink`` is + the same proxy returned by ``picture.click_action.hyperlink``, bound to + the picture's `p:nvPicPr/p:cNvPr`, so ``picture.hyperlink.address`` and + ``picture.hyperlink.tooltip`` work exactly as they do on a text run. + """ + return self.click_action.hyperlink + @property def shape_type(self) -> MSO_SHAPE_TYPE: """Unconditionally `MSO_SHAPE_TYPE.PICTURE` in this case.""" diff --git a/src/pptx/text/text.py b/src/pptx/text/text.py index 272de41f6..c06b859f5 100644 --- a/src/pptx/text/text.py +++ b/src/pptx/text/text.py @@ -895,9 +895,14 @@ def address(self) -> str | None: Read/write. URL can be on http, https, mailto, or file scheme; others may work. """ - if self._hlinkClick is None: + hlink = self._hlinkClick + if hlink is None: return None - return self.part.target_ref(self._hlinkClick.rId) + # -- a tooltip-only or action-only hlink has no relationship/URL -- + rId = hlink.rId + if not rId: + return None + return self.part.target_ref(rId) @address.setter def address(self, url: str | None): @@ -907,6 +912,71 @@ def address(self, url: str | None): if url: self._add_hlinkClick(url) + @property + def color(self): + """The |ColorFormat| controlling this hyperlink run's text color. + + Closes scanny/python-pptx#940 and #821 — a hyperlink run inherits the + theme hyperlink color by default; assigning ``run.hyperlink.color.rgb`` + writes an explicit `a:solidFill` color override on the run's `a:rPr`. + Reuses the same lazy, non-mutating color machinery as :attr:`Font.color` + (reading does not materialize `a:solidFill`). + """ + return Font(self._rPr).color + + @property + def tooltip(self) -> str | None: + """Read/write. The ScreenTip text shown on hover over this run. + + Closes scanny/python-pptx#1022 and #425. Returns |None| when no + `a:hlinkClick` is present or its `tooltip` attribute is unset/empty. + Assigning a string creates the `a:hlinkClick` if necessary. + Assigning |None| or `""` removes the tooltip, pruning the element + if it carries no URL/action. + + Known PowerPoint limitation: this ScreenTip is only rendered on + hover when the run's hyperlink also carries a real navigation + target (URL, slide jump, macro, program). A tooltip-only hyperlink + round-trips through save/reload but PowerPoint will not surface it + on hover. There is no known fully-supported workaround at the OOXML + layer for a no-click-action hover ScreenTip. + """ + hlink = self._hlinkClick + if hlink is None: + return None + return hlink.tooltip or None + + @tooltip.setter + def tooltip(self, value: str | None) -> None: + from pptx.action import ( + _ensure_noaction_if_inert, + _ensure_noaction_pruned, + _prune_hlink_if_empty, + ) + + if not value: + hlink = self._hlinkClick + if hlink is None: + return + hlink.tooltip = None + _ensure_noaction_pruned(hlink) + _prune_hlink_if_empty(self._rPr, hlink) + return + hlink = self._rPr.get_or_add_hlinkClick() + # -- PowerPoint requires r:id on every hlinkClick (even empty) — without + # -- it, the load-time validator triggers a Repair dialog and strips the + # -- element. Mirror the precedent in oxml/shapes/picture.py:220. + if hlink.rId is None: + hlink.rId = "" + hlink.tooltip = value + # -- Emit the ppaction://noaction marker on an inert hlink so the + # -- output matches PowerPoint's own emission and survives future + # -- validators. Note: the marker does NOT make PowerPoint render + # -- the tooltip on hover — that requires a real action target + # -- (URL/jump/macro). See BaseShape.alt_text (cNvPr/@descr) for + # -- a pure hover ScreenTip on a non-hyperlink shape. + _ensure_noaction_if_inert(hlink) + def _add_hlinkClick(self, url: str): rId = self.part.relate_to(url, RT.HYPERLINK, is_external=True) self._rPr.add_hlinkClick(rId) @@ -1269,6 +1339,23 @@ def hyperlink(self) -> _Hyperlink: rPr = self._r.get_or_add_rPr() return _Hyperlink(rPr, self) + @lazyproperty + def click_action(self): + """An |ActionSetting| for the click behavior of this run. + + Unifies the run hyperlink surface with the shape ``click_action`` + surface (closes scanny/python-pptx#455). The same |ActionSetting| + machinery used by ``shape.click_action`` operates on the run's + `a:rPr`, so a run can be a slideshow jump-to-slide (closes #1077), + run-program, run-macro, or play-sound trigger — e.g. + ``run.click_action.target_slide = slides[4]`` or + ``run.click_action.run_macro("Recalc")``. + """ + from pptx.action import ActionSetting + + rPr = self._r.get_or_add_rPr() + return ActionSetting(rPr, self) + @property def text(self): """Read/write. A unicode string containing the text in this run. diff --git a/tests/test_issue21_hyperlinks.py b/tests/test_issue21_hyperlinks.py new file mode 100644 index 000000000..2ef5e891e --- /dev/null +++ b/tests/test_issue21_hyperlinks.py @@ -0,0 +1,574 @@ +"""Acceptance suite for issue #21 — Hyperlinks 2.0 & Click Actions. + +Round-trip (save→reopen) tests prove the eight sub-features survive a real +package serialization, mirroring the issue #16/#18 acceptance pattern. The +XSD-position probes encode the fork's "python-self-round-trip ≠ PowerPoint +preservation" rule: every new oxml child/attr is paired with an explicit +schema-order assertion (the exact silent-PowerPoint-repair class). +""" + +from __future__ import annotations + +import io +import os +import struct +import tempfile +import wave + +import pytest + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.action import PP_ACTION +from pptx.oxml import parse_xml +from pptx.oxml.action import CT_EmbeddedWAVAudioFile, CT_Hyperlink +from pptx.oxml.ns import nsdecls +from pptx.util import Inches + +TEST_IMAGE = "tests/test_files/monty-truth.png" + + +def _wav_path() -> str: + fd, path = tempfile.mkstemp(suffix=".wav") + os.close(fd) + with wave.open(path, "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(8000) + w.writeframes(struct.pack("' % nsdecls("a", "r") + ) + assert el.tooltip == "hi" + assert el.tgtFrame == "_blank" + assert el.invalidUrl == "x" + assert el.history is False + assert el.highlightClick is True + assert el.endSnd is True + + def it_preserves_rId_and_action_attributes(self): + el = parse_xml( + '' % nsdecls("a", "r") + ) + assert el.rId == "rId3" + assert el.action == "ppaction://macro?name=M" + + def it_serializes_booleans_as_one_or_zero(self): + el = parse_xml("" % nsdecls("a", "r")) + el.endSnd = True + el.history = False + assert ' endSnd="1"' in el.xml + assert ' history="0"' in el.xml + + def it_does_not_emit_unset_attributes(self): + el = parse_xml("" % nsdecls("a", "r")) + assert "tooltip=" not in el.xml + assert "endSnd=" not in el.xml + + def it_places_snd_before_extLst_per_xsd_sequence(self): + # -- XSD-position probe: CT_Hyperlink sequence is snd?, extLst? -- + el = parse_xml("" % nsdecls("a", "r")) + snd = el.get_or_add_snd() + snd.embed = "rId9" + children = [child.tag.split("}")[-1] for child in el] + assert children == ["snd", "extLst"] + + def it_models_the_embedded_wav_audio_file(self): + el = parse_xml('' % nsdecls("a", "r")) + assert isinstance(el, CT_EmbeddedWAVAudioFile) + assert el.embed == "rId4" + assert el.name == "ding.wav" + + def it_does_not_emit_unset_snd_name(self): + el = parse_xml('' % nsdecls("a", "r")) + assert "name=" not in el.xml + + def it_uses_one_class_for_all_three_host_contexts(self): + for tag in ("a:hlinkClick", "a:hlinkHover", "a:hlinkMouseOver"): + el = parse_xml("<%s %s/>" % (tag, nsdecls("a", "r"))) + assert isinstance(el, CT_Hyperlink) + + +# -- B. Run hyperlink tooltip + color --------------------------------------- + + +class DescribeRunHyperlinkTooltip: + def it_round_trips_a_run_tooltip_with_an_address(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.address = "https://example.com" + run.hyperlink.tooltip = "Click for details" + r2 = _first_run(_roundtrip(prs)) + assert r2.hyperlink.tooltip == "Click for details" + assert r2.hyperlink.address == "https://example.com" + + def it_supports_a_tooltip_only_hyperlink_with_no_url(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.tooltip = "just a tip" + r2 = _first_run(_roundtrip(prs)) + assert r2.hyperlink.tooltip == "just a tip" + assert r2.hyperlink.address is None + + def it_returns_None_tooltip_when_no_hyperlink_present(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + assert run.hyperlink.tooltip is None + + def it_clears_tooltip_and_prunes_empty_hlink(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.tooltip = "temp" + run.hyperlink.tooltip = None + assert run.hyperlink.tooltip is None + assert "hlinkClick" not in run._r.xml + + def it_keeps_the_address_when_only_the_tooltip_is_cleared(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.address = "https://keep.example" + run.hyperlink.tooltip = "temp" + run.hyperlink.tooltip = None + assert run.hyperlink.address == "https://keep.example" + + def it_does_not_mutate_xml_on_tooltip_read(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + hl = run.hyperlink + before = run._r.xml + _ = hl.tooltip + # -- reading the tooltip must not create an a:hlinkClick -- + assert run._r.xml == before + assert "hlinkClick" not in run._r.xml + + +class DescribeRunHyperlinkColor: + def it_round_trips_a_hyperlink_color_override(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.address = "https://example.com" + run.hyperlink.color.rgb = RGBColor(0xC0, 0x00, 0x00) + r2 = _first_run(_roundtrip(prs)) + assert r2.hyperlink.color.rgb == RGBColor(0xC0, 0x00, 0x00) + + def it_does_not_create_a_hyperlink_just_by_setting_color(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.color.rgb = RGBColor(0x00, 0x80, 0x00) + assert run.hyperlink.address is None + assert "hlinkClick" not in run._r.xml + + def it_does_not_mutate_xml_on_color_read(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + hl = run.hyperlink + before = run._r.xml + _ = hl.color.rgb + # -- reading the color must not materialize a:solidFill -- + assert run._r.xml == before + assert "solidFill" not in run._r.xml + + +# -- C. Shape click-action verbs -------------------------------------------- + + +class DescribeClickActionVerbs: + def it_can_author_a_run_macro_action(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "btn") + shape.click_action.run_macro("My Macro") + rt = _roundtrip(prs) + ca = rt.slides[0].shapes[-1].click_action + assert ca.action == PP_ACTION.RUN_MACRO + assert ca._hlink.action_fields["name"] == "My%20Macro" + + def it_does_not_allocate_a_relationship_for_run_macro(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "btn") + shape.click_action.run_macro("Recalc") + # -- rId is the empty string, not None: PowerPoint requires r:id on + # -- every hlinkClick element (even when no relationship is attached) + # -- to avoid a load-time Repair dialog. See action.py + # -- ``ActionSetting._get_or_add_hlink`` for the rationale. + assert shape.click_action._hlink.rId == "" + + def it_can_author_a_target_program_action(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "prog") + shape.click_action.target_program("C:\\tool.exe") + rt = _roundtrip(prs) + ca = rt.slides[0].shapes[-1].click_action + assert ca.action == PP_ACTION.RUN_PROGRAM + assert ca.hyperlink.address == "C:\\tool.exe" + + def it_can_author_a_play_sound_action(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "snd") + shape.click_action.play_sound(_wav_path()) + rt = _roundtrip(prs) + hlink = rt.slides[0].shapes[-1].click_action._hlink + assert hlink.snd is not None + assert hlink.snd.embed is not None + assert hlink.endSnd is True + + def it_keeps_a_sound_alongside_an_address(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "snd") + shape.click_action.hyperlink.address = "https://example.com" + shape.click_action.play_sound(_wav_path()) + hlink = shape.click_action._hlink + assert hlink.rId is not None + assert hlink.snd is not None + children = [c.tag.split("}")[-1] for c in hlink] + assert children == ["snd"] # snd is the only child, before any extLst + + def it_replaces_a_prior_action_when_setting_run_macro(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "btn") + shape.click_action.target_program("C:\\a.exe") + shape.click_action.run_macro("B") + assert shape.click_action.action == PP_ACTION.RUN_MACRO + assert shape.click_action.hyperlink.address is None + + +# -- D. Shape hover-action --------------------------------------------------- + + +class DescribeHoverAction: + def it_exposes_a_public_hover_action(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "h") + shape.hover_action.hyperlink.address = "https://hover.example" + rt = _roundtrip(prs) + sh = rt.slides[0].shapes[-1] + assert sh.hover_action.hyperlink.address == "https://hover.example" + + def it_keeps_click_and_hover_actions_independent(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "h") + shape.click_action.hyperlink.address = "https://click.example" + shape.hover_action.hyperlink.address = "https://hover.example" + rt = _roundtrip(prs) + sh = rt.slides[0].shapes[-1] + assert sh.click_action.hyperlink.address == "https://click.example" + assert sh.hover_action.hyperlink.address == "https://hover.example" + + def it_places_hlinkClick_before_hlinkHover(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "h") + shape.click_action.hyperlink.address = "https://c.example" + shape.hover_action.hyperlink.address = "https://h.example" + cNvPr = shape._element._nvXxPr.cNvPr + tags = [ + c.tag.split("}")[-1] + for c in cNvPr + if c.tag.split("}")[-1] in ("hlinkClick", "hlinkHover") + ] + assert tags == ["hlinkClick", "hlinkHover"] + + +# -- E. Run-level slideshow jump-to-slide (unification) ---------------------- + + +class DescribeRunSlideJump: + def it_can_make_a_run_jump_to_a_slide(self): + prs = Presentation() + s1 = _blank_slide(prs) + s2 = _blank_slide(prs) + run = _new_run(s1) + run.click_action.target_slide = s2 + ca = _first_run(_roundtrip(prs)).click_action + assert ca.action == PP_ACTION.NAMED_SLIDE + assert ca.target_slide is not None + + def it_unifies_run_and_shape_action_interfaces(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + # -- same ActionSetting surface as shape.click_action -- + assert hasattr(run.click_action, "run_macro") + assert hasattr(run.click_action, "target_program") + assert hasattr(run.click_action, "play_sound") + assert hasattr(run.click_action, "target_slide") + + def it_keeps_a_run_jump_and_tooltip_together(self): + prs = Presentation() + s1 = _blank_slide(prs) + s2 = _blank_slide(prs) + run = _new_run(s1) + run.click_action.target_slide = s2 + run.hyperlink.tooltip = "next" + r2 = _first_run(_roundtrip(prs)) + assert r2.hyperlink.tooltip == "next" + assert r2.click_action.action == PP_ACTION.NAMED_SLIDE + + +# -- F. Picture hyperlink ---------------------------------------------------- + + +class DescribePictureHyperlink: + def it_round_trips_a_picture_hyperlink(self): + prs = Presentation() + slide = _blank_slide(prs) + pic = slide.shapes.add_picture(TEST_IMAGE, Inches(1), Inches(1), Inches(1), Inches(1)) + pic.hyperlink.address = "https://pic.example" + pic.hyperlink.tooltip = "the picture" + rt = _roundtrip(prs) + p2 = next(sh for sh in rt.slides[0].shapes if sh.shape_type == 13) + assert p2.hyperlink.address == "https://pic.example" + assert p2.hyperlink.tooltip == "the picture" + + def it_reuses_click_action_hyperlink_not_a_parallel_proxy(self): + prs = Presentation() + slide = _blank_slide(prs) + pic = slide.shapes.add_picture(TEST_IMAGE, Inches(1), Inches(1), Inches(1), Inches(1)) + assert pic.hyperlink is pic.click_action.hyperlink + + def it_does_not_emit_hlink_until_set(self): + prs = Presentation() + slide = _blank_slide(prs) + pic = slide.shapes.add_picture(TEST_IMAGE, Inches(1), Inches(1), Inches(1), Inches(1)) + _ = pic.hyperlink + assert "hlinkClick" not in pic._element.xml + + +# -- G. Chart-element hyperlink — documented current boundary ---------------- +# +# Sub-feature 7 (chart-element hyperlinks) is DEFERRED to a maintainer-filed +# follow-up: `ChartTitle`/`AxisTitle` are `ElementProxy` constructed without a +# chart-part parent chain, so a run inside `chart.chart_title.text_frame` +# cannot resolve `.part` to allocate the hyperlink relationship. Threading the +# chart part through the shared title base is a separate architectural surface +# with broad chart-suite regression risk; see +# `uat/FOLLOWUP_issue21_chart_hyperlinks.md`. This test pins the *current* +# boundary so a future fix flips it deliberately rather than by accident. + + +class DescribeChartRunHyperlinkBoundary: + def it_documents_the_chart_title_run_part_limitation(self): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + + prs = Presentation() + slide = _blank_slide(prs) + cd = CategoryChartData() + cd.categories = ["a", "b"] + cd.add_series("S", (1, 2)) + gf = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(1), + Inches(1), + Inches(5), + Inches(4), + cd, + ) + chart = gf.chart + chart.has_title = True + tf = chart.chart_title.text_frame + tf.text = "Revenue" + run = tf.paragraphs[0].runs[0] + # -- current boundary: the chart-title run has no resolvable part -- + with pytest.raises(AttributeError): + run.hyperlink.address = "https://chart.example" + + +# -- H. No-PowerPoint-repair gate (XSD-position probes) ---------------------- + + +class DescribeNoRepairGate: + def it_keeps_rPr_hlink_successor_order_unchanged(self): + # -- regression: rPr child order must keep hlinkClick before rtl/extLst + from pptx.oxml.text import CT_TextCharacterProperties + + hlink_field = CT_TextCharacterProperties.__dict__["hlinkClick"] + assert hlink_field is not None + + def it_emits_a_schema_valid_snd_only_before_extLst(self): + el = parse_xml("" % nsdecls("a", "r")) + el.get_or_add_snd().embed = "rId1" + assert el.xml.index("a:snd") < el.xml.index("a:extLst") + + @pytest.mark.parametrize(("flag", "expected"), [(True, '"1"'), (False, '"0"')]) + def it_serializes_endSnd_in_powerpoint_form(self, flag, expected): + el = parse_xml("" % nsdecls("a", "r")) + el.endSnd = flag + assert ("endSnd=%s" % expected) in el.xml + + # -- Repair-trigger regressions (UAT round 1, 2026-05-19) ---------------- + # -- Three things made PowerPoint throw the Repair dialog and strip the + # -- play-sound hlinkClick + media1.wav part on first UAT open. The fixes + # -- are encoded as invariants below so any regression resurfaces in the + # -- trinity, not in a maintainer's PowerPoint window. + + def it_emits_empty_r_id_on_tooltip_only_shape_hlinkClick(self): + # -- root cause #1: with no r:id triggers + # -- a Repair dialog; PowerPoint repairs by stripping the tooltip and + # -- emitting . + prs = Presentation() + shape = _textbox(_blank_slide(prs), "tip") + shape.click_action.tooltip = "hi" + hlink = shape.click_action._hlink + assert hlink.rId == "" + assert ' r:id=""' in hlink.xml + + def it_emits_empty_r_id_on_tooltip_only_run_hlinkClick(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.tooltip = "hi" + hlink = run._r.get_or_add_rPr().hlinkClick + assert hlink is not None + assert hlink.rId == "" + assert ' r:id=""' in hlink.xml + + def it_emits_empty_r_id_on_run_macro_hlinkClick(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "btn") + shape.click_action.run_macro("Recalc") + hlink = shape.click_action._hlink + assert hlink.rId == "" + assert ' r:id=""' in hlink.xml + + def it_emits_empty_r_id_on_play_sound_hlinkClick(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "snd") + shape.click_action.play_sound(_wav_path()) + hlink = shape.click_action._hlink + assert hlink.rId == "" + assert ' r:id=""' in hlink.xml + + def it_uses_RT_AUDIO_for_play_sound_relationship(self): + # -- root cause #2: the snd rel was RT.MEDIA (Microsoft-2007 video + # -- media); PowerPoint discards the whole hlinkClick + the media1.wav + # -- part because that rel type is not valid for an embedded WAV + # -- referenced by CT_EmbeddedWAVAudioFile/@r:embed. + from pptx.opc.constants import RELATIONSHIP_TYPE as RT + + prs = Presentation() + shape = _textbox(_blank_slide(prs), "snd") + shape.click_action.play_sound(_wav_path()) + snd_rId = shape.click_action._hlink.snd.embed + slide_part = shape.part + snd_rel = slide_part.rels[snd_rId] + assert snd_rel.reltype == RT.AUDIO + + def it_round_trips_play_sound_through_save_and_reopen(self): + # -- end-to-end: after the two root-cause fixes, the play-sound deck + # -- survives a real save→reopen with the wav part intact and the + # -- hlinkClick re-readable. This is what the UAT exercises. + prs = Presentation() + shape = _textbox(_blank_slide(prs), "snd") + shape.click_action.play_sound(_wav_path()) + rt = _roundtrip(prs) + hlink = rt.slides[0].shapes[-1].click_action._hlink + assert hlink is not None + assert hlink.snd is not None + assert hlink.snd.embed is not None + # -- the wav part must still resolve through the snd's rel + rt_part = rt.slides[0].shapes[-1].part + snd_rel = rt_part.rels[hlink.snd.embed] + assert snd_rel.target_part is not None + + # -- Round 2 finding (UAT 2026-05-19, "tooltip does not appear"): ------- + # -- PowerPoint's tooltip processor only activates on hlinks that carry an + # -- action verb. A bare ```` opens + # -- without a repair dialog but is inert in slideshow mode — hovering + # -- shows no ScreenTip. The fix mirrors PowerPoint's own form for a + # -- tooltip-only hlink: ``action="ppaction://noaction"``. + + def it_emits_noaction_on_shape_tooltip_only_click_hlink(self): + prs = Presentation() + shape = _textbox(_blank_slide(prs), "tip") + shape.click_action.tooltip = "just a tip" + hlink = shape.click_action._hlink + assert hlink.action == "ppaction://noaction" + assert hlink.tooltip == "just a tip" + assert hlink.rId == "" + + def it_emits_noaction_on_shape_tooltip_only_via_hyperlink_proxy(self): + # -- matches the UAT path: tip.click_action.hyperlink.tooltip = ... + prs = Presentation() + shape = _textbox(_blank_slide(prs), "tip") + shape.click_action.hyperlink.tooltip = "just a tip" + hlink = shape.click_action._hlink + assert hlink.action == "ppaction://noaction" + assert hlink.tooltip == "just a tip" + assert hlink.rId == "" + + def it_emits_noaction_on_run_tooltip_only_hlink(self): + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.tooltip = "just a tip" + hlink = run._r.get_or_add_rPr().hlinkClick + assert hlink is not None + assert hlink.action == "ppaction://noaction" + assert hlink.tooltip == "just a tip" + + def it_does_not_overwrite_a_real_action_with_noaction(self): + # -- when a real action is set first, adding a tooltip must NOT replace + # -- the action verb with the noaction marker. + prs = Presentation() + shape = _textbox(_blank_slide(prs), "btn") + shape.click_action.run_macro("Recalc") + shape.click_action.tooltip = "macro tip" + hlink = shape.click_action._hlink + assert hlink.action == "ppaction://macro?name=Recalc" + assert hlink.tooltip == "macro tip" + + def it_does_not_emit_noaction_when_a_relationship_is_present(self): + # -- when an address (URL) is set, the hlink has rId; adding a tooltip + # -- must NOT add the noaction marker either. + prs = Presentation() + run = _new_run(_blank_slide(prs)) + run.hyperlink.address = "https://example.com" + run.hyperlink.tooltip = "url tip" + hlink = run._r.get_or_add_rPr().hlinkClick + assert hlink is not None + assert hlink.action is None + assert hlink.rId != "" + assert hlink.tooltip == "url tip" + + def it_clears_noaction_when_tooltip_is_removed_from_inert_hlink(self): + # -- the noaction marker we added must not linger after the tooltip + # -- is cleared; the resulting hlink must be pruned cleanly. + prs = Presentation() + shape = _textbox(_blank_slide(prs), "tip") + shape.click_action.tooltip = "temp" + shape.click_action.tooltip = None + assert shape.click_action._hlink is None