Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
hyperlink: add Hyperlink.add_run()
  • Loading branch information
pseudosavant committed Sep 16, 2026
commit 530ed3c58900dfd2137509ec4158533f6b1d51b9
3 changes: 0 additions & 3 deletions features/hlk-add-run.feature
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,16 @@ Feature: Append runs to a hyperlink
As a developer using python-docx
I need to append runs using the existing text and character style APIs

@wip
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

@wip
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

@wip
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
Expand Down
10 changes: 6 additions & 4 deletions features/steps/hyperlink_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
from behave.runner import Context

from docx import Document
from docx.enum.style import WD_STYLE_TYPE

from helpers import test_docx
from helpers import test_docx, test_file


@given("an existing hyperlink for authoring")
def given_an_existing_hyperlink_for_authoring(context: Context):
context.document = Document(test_docx("par-hyperlinks"))
context.document.styles.add_style("Link emphasis", WD_STYLE_TYPE.CHARACTER)
context.hyperlink = context.document.paragraphs[1].hyperlinks[0]
context.original_text = context.hyperlink.text
context.original_run_count = len(context.hyperlink.runs)
Expand All @@ -21,7 +23,7 @@ def given_an_existing_hyperlink_for_authoring(context: Context):
@when("I append formatted runs to the hyperlink")
def when_I_append_formatted_runs_to_the_hyperlink(context: Context):
context.hyperlink.add_run(" Café\t").bold = True
context.hyperlink.add_run("code\n", "Emphasis").font.name = "Consolas"
context.hyperlink.add_run("code\n", "Link emphasis").font.name = "Consolas"


@then("the appended runs retain their text and formatting after saving")
Expand All @@ -31,13 +33,13 @@ def then_the_appended_runs_retain_their_text_and_formatting(context: Context):
hyperlink = Document(stream).paragraphs[1].hyperlinks[0]
assert hyperlink.text == context.original_text + " Café\tcode\n"
assert hyperlink.runs[-2].bold is True
assert hyperlink.runs[-1].style.name == "Emphasis"
assert hyperlink.runs[-1].style.name == "Link emphasis"
assert hyperlink.runs[-1].font.name == "Consolas"


@when("I append a picture run to the hyperlink")
def when_I_append_a_picture_run_to_the_hyperlink(context: Context):
context.picture = context.hyperlink.add_run().add_picture("features/steps/test_files/python-icon.jpeg")
context.picture = context.hyperlink.add_run().add_picture(test_file("python-icon.jpeg"))


@then("the hyperlink contains the picture after saving")
Expand Down
3 changes: 2 additions & 1 deletion src/docx/oxml/text/hyperlink.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING, Callable, List

from docx.oxml.simpletypes import ST_OnOff, ST_String, XsdString
from docx.oxml.text.run import CT_R
Expand All @@ -20,6 +20,7 @@ class CT_Hyperlink(BaseOxmlElement):
"""`<w:hyperlink>` element, containing the text and address for a hyperlink."""

r_lst: List[CT_R]
_new_r: Callable[[], CT_R]

rId: str | None = OptionalAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType]
anchor: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType]
Expand Down
28 changes: 25 additions & 3 deletions src/docx/text/hyperlink.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@

from typing import TYPE_CHECKING

from docx.shared import Parented
from docx.oxml.simpletypes import XsdString
from docx.shared import StoryChild
from docx.text.run import Run

if TYPE_CHECKING:
import docx.types as t
from docx.oxml.text.hyperlink import CT_Hyperlink
from docx.styles.style import CharacterStyle


class Hyperlink(Parented):
class Hyperlink(StoryChild):
"""Proxy object wrapping a `<w:hyperlink>` element.

A hyperlink occurs as a child of a paragraph, at the same level as a Run. A
Expand All @@ -30,6 +32,26 @@ def __init__(self, hyperlink: CT_Hyperlink, parent: t.ProvidesStoryPart):
self._parent = parent
self._hyperlink = self._element = hyperlink

def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run:
"""Append a run containing `text` and having character-style `style`.

Tabs (``\\t``), newlines (``\\n``), and carriage returns (``\\r``) have the
same behavior as in :meth:`Paragraph.add_run`. Omit `text` for an empty
run, which can also contain a picture added using :meth:`Run.add_picture`.

No character style is applied by default. Pass a style name or a
|CharacterStyle| object to apply one. A missing style raises :exc:`KeyError`.
"""
r = self._hyperlink._new_r() # pyright: ignore[reportPrivateUsage]
run = Run(r, self)
if text is not None:
XsdString.validate(text)
run.text = text
if style is not None:
run.style = style
self._hyperlink.append(r)
return run

@property
def address(self) -> str:
"""The "URL" of the hyperlink (but not necessarily a web link).
Expand Down Expand Up @@ -88,7 +110,7 @@ def runs(self) -> list[Run]:
example part of the hyperlink is bold or the text was changed after the document
was saved.
"""
return [Run(r, self._parent) for r in self._hyperlink.r_lst]
return [Run(r, self) for r in self._hyperlink.r_lst]

@property
def text(self) -> str:
Expand Down
78 changes: 76 additions & 2 deletions tests/text/test_hyperlink.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Test suite for the docx.text.hyperlink module."""

from __future__ import annotations

from typing import cast

import pytest
Expand All @@ -9,14 +11,86 @@
from docx.oxml.text.hyperlink import CT_Hyperlink
from docx.parts.story import StoryPart
from docx.text.hyperlink import Hyperlink
from docx.text.run import Run

from ..unitutil.cxml import element
from ..unitutil.mock import FixtureRequest, Mock, instance_mock
from ..unitutil.cxml import element, xml
from ..unitutil.mock import FixtureRequest, Mock, instance_mock, property_mock


class DescribeHyperlink:
"""Unit-test suite for the docx.text.hyperlink.Hyperlink object."""

@pytest.mark.parametrize(
("text", "expected_cxml"),
[
(None, "w:hyperlink/w:r"),
("", "w:hyperlink/w:r"),
("label", 'w:hyperlink/w:r/w:t"label"'),
(
" a\tb\nc\rd ",
'w:hyperlink/w:r/(w:t{xml:space=preserve}" a",w:tab,w:t"b",'
'w:br,w:t"c",w:br,w:t{xml:space=preserve}"d ")',
),
],
)
def it_can_append_a_run(
self, text: str | None, expected_cxml: str, fake_parent: t.ProvidesStoryPart
):
hlink = cast(CT_Hyperlink, element("w:hyperlink"))
hyperlink = Hyperlink(hlink, fake_parent)

run = hyperlink.add_run(text)

assert isinstance(run, Run)
assert run.part is fake_parent.part
assert hlink.xml == xml(expected_cxml)

def it_can_apply_a_character_style_to_a_new_run(
self, request: FixtureRequest, fake_parent: t.ProvidesStoryPart
):
style_prop = property_mock(request, Run, "style")
hyperlink = Hyperlink(cast(CT_Hyperlink, element("w:hyperlink")), fake_parent)

hyperlink.add_run("label", "Emphasis")

style_prop.assert_called_once_with("Emphasis")

def it_appends_a_run_after_existing_content(self, fake_parent: t.ProvidesStoryPart):
hlink = cast(CT_Hyperlink, element('w:hyperlink/w:r/w:t"before"'))
hyperlink = Hyperlink(hlink, fake_parent)

run = hyperlink.add_run("after")

assert hlink.xml == xml('w:hyperlink/(w:r/w:t"before",w:r/w:t"after")')
assert run.text == "after"
assert all(run.part is fake_parent.part for run in hyperlink.runs)

@pytest.mark.parametrize(
("value", "exception"),
[(0, TypeError), (b"label", TypeError), ("bad\x00text", ValueError)],
)
def it_rejects_invalid_run_text_before_appending(
self, value: object, exception: type[Exception], fake_parent: t.ProvidesStoryPart
):
hlink = cast(CT_Hyperlink, element('w:hyperlink/w:r/w:t"before"'))
hyperlink = Hyperlink(hlink, fake_parent)

with pytest.raises(exception):
hyperlink.add_run(cast(str, value))

assert hlink.xml == xml('w:hyperlink/w:r/w:t"before"')

def it_does_not_append_a_run_when_its_style_cannot_be_applied(
self, request: FixtureRequest, fake_parent: t.ProvidesStoryPart
):
property_mock(request, Run, "style", side_effect=KeyError("Missing style"))
hlink = cast(CT_Hyperlink, element("w:hyperlink"))

with pytest.raises(KeyError, match="Missing style"):
Hyperlink(hlink, fake_parent).add_run("label", "Missing style")

assert hlink.xml == xml("w:hyperlink")

@pytest.mark.parametrize(
("hlink_cxml", "expected_value"),
[
Expand Down