Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Release History
---------------

Unreleased
++++++++++

- Add external hyperlink creation, formatted label runs, and tooltip support.


1.2.0 (2025-06-16)
++++++++++++++++++

Expand Down
95 changes: 93 additions & 2 deletions docs/dev/analysis/features/text/hyperlink.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,103 @@ Note that rendered page-breaks can occur in the middle of a hyperlink.
A |Hyperlink| is a child of |Paragraph|, a peer of |Run|.


TODO: What about URL-encoding/decoding (like %20) behaviors, if any?
External authoring proposal
---------------------------

This contribution proposes a first authoring increment for `issue #74
<https://github.com/python-openxml/python-docx/issues/74>`_. It extends the existing
hyperlink reader with external link creation, append-only label runs, and tooltips.
These API choices are proposed for maintainer review, not previously approved.

The analysis in `PR #278 <https://github.com/python-openxml/python-docx/pull/278>`_
was incorporated upstream before authoring was implemented. The current reader
provides the foundation for this proposal. `PR #784
<https://github.com/python-openxml/python-docx/pull/784>`_ also proposes authoring,
but predates the current proxy structure and includes unrelated changes. This
increment builds on current upstream and does not copy either implementation.

The proposed signatures are::

paragraph.add_hyperlink(text=None, *, address, tooltip=None) -> Hyperlink
hyperlink.add_run(text=None, style=None) -> Run
hyperlink.tooltip -> str | None # read/write

For example::

>>> paragraph = document.add_paragraph('Read ')
>>> hyperlink = paragraph.add_hyperlink(
... address='https://example.com/docs?lang=en#intro',
... tooltip='Project documentation',
... )
>>> hyperlink.add_run('the ')
>>> hyperlink.add_run('documentation').bold = True
>>> paragraph.add_run(' for details.')

The design decisions for this increment are:

.. list-table::
:header-rows: 1
:widths: 25 75

* - Concern
- Proposed behavior and rationale
* - Address naming
- Use ``address``, as in the current reader and the candidate protocol below.
The original issue's ``url`` argument was illustrative. Require the address
by keyword to avoid confusing a label with its destination.
* - Fragments and escaping
- Store the full external destination unchanged in its relationship. Preserve
query strings, percent escapes, and URI fragments. Do not perform URL
encoding or target lookup. XML serialization supplies attribute escaping.
Defer a separate ``fragment`` argument so there is only one source of truth.
* - Label runs
- Use ``add_run(text, style)`` to match ``Paragraph.add_run``. The earlier
``insert_run`` proposal also addresses arbitrary insertion, which is outside
this increment. Do not introduce both methods for append-only authoring.
Existing Run APIs supply formatting, whitespace handling, and pictures.
* - Styling
- Apply no character style automatically, matching ordinary run creation.
Callers can assign an existing Hyperlink character style or create one using
the public style API. Preserve template definitions and avoid hard-coded
colors. This choice differs from Word's automatic styling and needs review.
* - Tooltip
- Use a read/write optional string. ``None`` means absent and removes the
attribute when assigned. An empty string is stored explicitly. Offer the
same value as a creation keyword for labels with hover text.
* - Relationship ownership
- Register the external relationship on the paragraph's story part. Links in
headers and footers must not place their relationships on the document part.
Adjacent links may share a relationship while retaining separate elements.
* - Invalid input
- Require a non-empty external address. Reject fragment-only destinations.
Missing or wrongly typed arguments raise ``TypeError``. Empty destinations
and invalid XML characters raise ``ValueError``. Prepare content before
attaching a new link or adding its relationship. Failed run creation must
not append a partial run.

An absent or empty initial label creates a hyperlink without runs. Callers can
populate it incrementally. The existing text, address, fragment, URL, run, and
inline-iteration getters retain their behavior.

Internal bookmark links, bookmark creation, address editing, link removal, run
insertion, and visited-state management are deferred. In particular, the unresolved
bookmark behavior discussed in PR #278 does not need to be decided for this external
link increment. The history attribute is not exposed or changed.

Acceptance scenarios specify each public operation before its implementation.
XML and proxy unit tests isolate each new helper, method, or property. Saved-document
tests then cover relationship ownership, adjacent links, formatted labels, picture
runs, input failures, and tooltip states. Word inspection complements these tests
because successfully reopening a package does not establish click behavior.


Candidate protocol
------------------

The following is the broader historical design. Examples for internal links,
separate fragments, property mutation, and arbitrary insertion remain proposals
beyond the external-authoring increment above.

An external hyperlink has an address and an optional anchor. An internal hyperlink has
only an anchor. An anchor is more precisely known as a *URI fragment* in a web URL and
follows a hash mark ("#"). The fragment-separator hash character is not stored in the
Expand Down Expand Up @@ -97,7 +188,7 @@ and addresses typed into the document directly don't, based on my limited experi
>>> hyperlink.text
'an excellent Wikipedia article on ferrets'

**Add an external hyperlink** (not yet implemented)::
**Add an external hyperlink with a separate fragment** (broader proposal)::

>>> hyperlink = paragraph.add_hyperlink(
... 'About', address='http://us.com', fragment='about'
Expand Down
59 changes: 59 additions & 0 deletions docs/user/text.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,65 @@ about block-level elements like paragraphs and inline-level objects like
runs.


Adding hyperlinks
-----------------

Use :meth:`Paragraph.add_hyperlink` to append a link to a web page, email
address, or file. It returns a |Hyperlink| containing the label text::

>>> paragraph = document.add_paragraph('Read ')
>>> hyperlink = paragraph.add_hyperlink(
... 'the documentation',
... address='https://example.com/docs?lang=en#intro',
... tooltip='Project documentation',
... )
>>> paragraph.add_run(' for details.')
>>> hyperlink.url
'https://example.com/docs?lang=en#intro'

The destination is stored exactly as supplied, including query strings,
percent escapes, and fragments. The library does not fetch the destination,
check whether a file exists, or encode the address. Relative file paths and
``mailto:`` URIs are supported. An address is required. Empty addresses and
fragment-only addresses such as ``#heading`` raise :exc:`ValueError`.
Creating links to bookmarks within the same document is not supported by
this method.

For a formatted label, omit the initial text and append runs to the hyperlink::

>>> hyperlink = paragraph.add_hyperlink(address='https://example.com')
>>> hyperlink.add_run('An ')
>>> hyperlink.add_run('important').bold = True
>>> hyperlink.add_run(' example').italic = True

Each returned |Run| supports the usual font, style, and picture operations.
Tabs and line breaks behave as they do in paragraph runs. Text outside the
link remains in separate paragraph runs. Hyperlinks can also be added to
paragraphs in table cells, headers, and footers.

No character style is applied automatically. To use a template's Hyperlink
character style, pass ``style='Hyperlink'`` to :meth:`Hyperlink.add_run` or
assign it to a run's ``style`` property. A missing style raises
:exc:`KeyError`. If needed, create a theme-aware style through the public
style API, preserving any existing definition::

>>> from docx.enum.dml import MSO_THEME_COLOR_INDEX
>>> from docx.enum.style import WD_STYLE_TYPE
>>> if 'Hyperlink' not in document.styles:
... style = document.styles.add_style('Hyperlink', WD_STYLE_TYPE.CHARACTER)
... style.font.color.theme_color = MSO_THEME_COLOR_INDEX.HYPERLINK
... style.font.underline = True
>>> hyperlink.add_run(' styled label', style='Hyperlink')

The ``tooltip`` property can be read or changed later. Assign |None| to
remove a tooltip. An empty string is stored as an explicitly empty tooltip.
The library does not manage visited-link colors or history.

Label text, destinations, and tooltips must contain valid XML characters.
Invalid types raise :exc:`TypeError`. Invalid XML characters raise
:exc:`ValueError` before a new link is attached to the paragraph.


Block-level vs. inline text objects
-----------------------------------

Expand Down
19 changes: 19 additions & 0 deletions features/hlk-add-run.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feature: Append runs to a hyperlink
In order to create a formatted hyperlink label
As a developer using python-docx
I need to append runs using the existing text and character style APIs

Scenario: Append individually formatted runs
Given an existing hyperlink for authoring
When I append formatted runs to the hyperlink
Then the appended runs retain their text and formatting after saving

Scenario: Append an empty run for a picture
Given an existing hyperlink for authoring
When I append a picture run to the hyperlink
Then the hyperlink contains the picture after saving

Scenario: Reject an invalid run without changing the hyperlink
Given an existing hyperlink for authoring
When I try to append a run with a missing character style
Then the hyperlink's runs remain unchanged
27 changes: 27 additions & 0 deletions features/hlk-tooltip.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Feature: Read and change hyperlink tooltips
In order to provide hover text for a hyperlink
As a developer using python-docx
I need to distinguish absent, empty, and populated tooltips

Scenario: Read an absent tooltip
Given a hyperlink authoring paragraph in a body
When I create a hyperlink without an initial label
Then the hyperlink has no tooltip

Scenario Outline: Set or clear a tooltip
Given a hyperlink authoring paragraph in a body
When I create a hyperlink without an initial label
And I assign a <value> tooltip to the hyperlink
Then the assigned tooltip survives saving

Examples:
| value |
| populated |
| empty |
| absent |

Scenario: Reject invalid tooltip characters
Given a hyperlink authoring paragraph in a body
When I create a hyperlink without an initial label
And I try to assign a tooltip containing invalid XML characters
Then the previous tooltip is preserved
54 changes: 54 additions & 0 deletions features/par-add-hyperlink.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
Feature: Append an external hyperlink to a paragraph
In order to link document text to external resources
As a developer using python-docx
I need to create a hyperlink on the paragraph's owning story part

Scenario Outline: Create a hyperlink in a story
Given a hyperlink authoring paragraph in a <story>
When I append an external hyperlink between ordinary runs
Then the new hyperlink and surrounding text survive saving

Examples:
| story |
| body |
| cell |
| header |
| footer |

Scenario Outline: Preserve the supplied destination
Given a hyperlink authoring paragraph in a body
When I append a hyperlink to <address>
Then the supplied hyperlink destination survives saving unchanged

Examples:
| address |
| https://example.com/a%20b?q=one&lang=en#intro |
| mailto:[email protected]?subject=Hello%20there |
| ../guide with spaces.docx |
| custom:resource |

Scenario: Populate an empty hyperlink
Given a hyperlink authoring paragraph in a body
When I create a hyperlink without an initial label
Then I can build its label by appending runs

Scenario: Reject an empty address without changing the document
Given a hyperlink authoring paragraph in a body
When I try to create a hyperlink with an empty address
Then no hyperlink or relationship has been added

Scenario Outline: Supply a tooltip when creating a hyperlink
Given a hyperlink authoring paragraph in a body
When I create a hyperlink with a <value> tooltip
Then the assigned tooltip survives saving

Examples:
| value |
| populated |
| empty |
| absent |

Scenario: Reject an invalid creation tooltip without changing the document
Given a hyperlink authoring paragraph in a body
When I try to create a hyperlink with invalid XML in its tooltip
Then no hyperlink or relationship has been added
Loading