Skip to content

feat(action): Hyperlinks 2.0 & Click Actions — issue #21 epic#61

Merged
MHoroszowski merged 1 commit into
masterfrom
feature/issue21-hyperlinks-clickactions
May 21, 2026
Merged

feat(action): Hyperlinks 2.0 & Click Actions — issue #21 epic#61
MHoroszowski merged 1 commit into
masterfrom
feature/issue21-hyperlinks-clickactions

Conversation

@MHoroszowski
Copy link
Copy Markdown
Owner

Closes #21.

Unified hyperlinks + click/hover actions + accessibility surface, across runs / shapes / pictures.

Sub-features

  1. ScreenTip on a run hyperlinkRun.hyperlink.tooltip
  2. Color override on a hyperlink runRun.hyperlink.color (closes Set/change font color when working with Hyperlinks is impossible scanny/python-pptx#940, How to change the color of hyperlink text? scanny/python-pptx#821)
  3. Click actions on shapesActionSetting.run_macro, target_program, play_sound
  4. Independent hover actionShape.hover_action parallel to click_action
  5. Jump-to-slide from a text runRun.click_action.target_slide = ...
  6. Picture hyperlinksPicture.hyperlink (address, tooltip, color)
  7. Accessibility on every shapealt_text, alt_title, is_decorative, is_hidden_from_accessibility
  8. Unified API — runs and shapes share the same ActionSetting/Hyperlink core

PowerPoint-compatibility load-bearing notes

These were each verified by Repair-dialog testing in PowerPoint:

  • Every a:hlinkClick / a:hlinkHover we create gets r:id="" by default. Without it, PowerPoint's load-time validator strips the element even though ECMA-376 marks @r:id optional. Real PowerPoint output always carries r:id (empty when no relationship), matching the precedent already in oxml/shapes/picture.py.
  • Inert (tooltip-only / no-target) hlinks carry action=\"ppaction://noaction\" to match real PowerPoint's own emission.
  • play_sound uses RT.AUDIO (ECMA-376 relationships/audio), NOT RT.MEDIA — the Microsoft-2007 relationships/media rel under a:snd/@r:embed triggers a Repair dialog and strips the entire hlinkClick.

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:

Path Round-trip at XML layer Rendered on slideshow hover
cNvPr/a:hlinkClick/@tooltip (no nav target)
cNvPr/@descr (shape.alt_text)

PowerPoint's slideshow runtime only activates hover-ScreenTip rendering when the hyperlink carries a real navigation target (URL, slide jump, macro, program). There is no known fully-supported OOXML workaround.

Mitigations:

  • ScreenTips on hyperlinks WITH a navigation target work correctly (PASS in UAT).
  • alt_text remains useful for accessibility/screen-reader text, which is its primary purpose.
  • All three relevant docstrings (Hyperlink.tooltip, ActionSetting.tooltip, Run.hyperlink.tooltip) document this limitation.

This limitation is shipped as-is rather than hidden — the API is honest about what PowerPoint will and won't render.

Verification

$ python3 -m pytest tests/ -q | tail -3
4021 passed in 7.46s

$ python3 -m ruff check src tests | tail -3
All checks passed!

$ python3 -m behave features/ --no-color | tail -3
1139 scenarios passed, 0 failed, 0 skipped
3522 steps passed, 0 failed, 0 skipped

Baseline before #21: 3983 unit tests, 1130 behave scenarios. Net add: +38 unit tests, +9 behave scenarios.

UAT (per CLAUDE.md §6a — maintainer signoff)

uat/uat_issue21_hyperlinks.py builds a 2-slide deck exercising every sub-feature with byte-level round-trip assertions (17/17 PASS). Visual sign-off done by maintainer:

  • ✓ run-macro Action dialog correct
  • ✓ run-program Action dialog correct
  • ✓ hover action works
  • ✓ run hyperlinks + color override render
  • ✓ picture hyperlink works
  • ✗ no-click-action hover ScreenTip — confirmed not rendered by PowerPoint (known limitation, see above)

Closes #21.

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.
@MHoroszowski MHoroszowski merged commit 73d31e7 into master May 21, 2026
16 checks passed
@MHoroszowski MHoroszowski deleted the feature/issue21-hyperlinks-clickactions branch May 21, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant