diff --git a/HISTORY.rst b/HISTORY.rst index 69bba4161..091b01d97 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +Unreleased +++++++++++ + +- Add external hyperlink creation, formatted label runs, and tooltip support. + + 1.2.0 (2025-06-16) ++++++++++++++++++ diff --git a/docs/dev/analysis/features/text/hyperlink.rst b/docs/dev/analysis/features/text/hyperlink.rst index cfd451fe1..9f28aebc5 100644 --- a/docs/dev/analysis/features/text/hyperlink.rst +++ b/docs/dev/analysis/features/text/hyperlink.rst @@ -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 +`_. 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 `_ +was incorporated upstream before authoring was implemented. The current reader +provides the foundation for this proposal. `PR #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 @@ -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' diff --git a/docs/user/text.rst b/docs/user/text.rst index f2e54f3b4..eef9b785f 100644 --- a/docs/user/text.rst +++ b/docs/user/text.rst @@ -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 ----------------------------------- diff --git a/features/hlk-add-run.feature b/features/hlk-add-run.feature new file mode 100644 index 000000000..63baa5b60 --- /dev/null +++ b/features/hlk-add-run.feature @@ -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 diff --git a/features/hlk-tooltip.feature b/features/hlk-tooltip.feature new file mode 100644 index 000000000..3ff3c2e73 --- /dev/null +++ b/features/hlk-tooltip.feature @@ -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 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 diff --git a/features/par-add-hyperlink.feature b/features/par-add-hyperlink.feature new file mode 100644 index 000000000..febd68dc4 --- /dev/null +++ b/features/par-add-hyperlink.feature @@ -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 + 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
+ Then the supplied hyperlink destination survives saving unchanged + + Examples: + | address | + | https://example.com/a%20b?q=one&lang=en#intro | + | mailto:hello@example.com?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 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 diff --git a/features/steps/hyperlink_authoring.py b/features/steps/hyperlink_authoring.py new file mode 100644 index 000000000..cd3a61986 --- /dev/null +++ b/features/steps/hyperlink_authoring.py @@ -0,0 +1,203 @@ +"""Acceptance steps for hyperlink authoring.""" + +from io import BytesIO + +from behave import given, then, when +from behave.runner import Context + +from docx import Document +from docx.document import Document as DocumentObject +from docx.enum.style import WD_STYLE_TYPE +from docx.text.paragraph import Paragraph + +from helpers import test_docx, test_file + + +def authoring_paragraph(document: DocumentObject, story: str) -> Paragraph: + if story == "body": + return document.paragraphs[0] + if story == "cell": + return document.tables[0].cell(0, 0).paragraphs[0] + if story == "header": + return document.sections[0].header.paragraphs[0] + if story == "footer": + return document.sections[0].footer.paragraphs[0] + raise ValueError(f"Unknown story: {story}") + + +@given("a hyperlink authoring paragraph in a {story}") +def given_a_hyperlink_authoring_paragraph(context: Context, story: str): + context.document = Document() + context.document.add_paragraph() + context.document.add_table(1, 1) + context.story = story + context.paragraph = authoring_paragraph(context.document, story) + context.paragraph.add_run("Before ") + + +@when("I append an external hyperlink between ordinary runs") +def when_I_append_an_external_hyperlink(context: Context): + context.paragraph.add_hyperlink("the guide", address="https://example.com/guide") + context.paragraph.add_run(" after") + + +@then("the new hyperlink and surrounding text survive saving") +def then_the_new_hyperlink_survives_saving(context: Context): + stream = BytesIO() + context.document.save(stream) + paragraph = authoring_paragraph(Document(stream), context.story) + assert paragraph.text == "Before the guide after" + assert [run.text for run in paragraph.runs] == ["Before ", " after"] + assert paragraph.hyperlinks[0].url == "https://example.com/guide" + + +@when("I append a hyperlink to {address}") +def when_I_append_a_hyperlink_to_an_address(context: Context, address: str): + context.address = address + context.paragraph.add_hyperlink("label", address=address) + + +@then("the supplied hyperlink destination survives saving unchanged") +def then_the_destination_survives_saving(context: Context): + stream = BytesIO() + context.document.save(stream) + hyperlink = Document(stream).paragraphs[0].hyperlinks[0] + assert hyperlink.address == hyperlink.url == context.address + assert hyperlink.fragment == "" + + +@when("I create a hyperlink without an initial label") +def when_I_create_a_hyperlink_without_a_label(context: Context): + context.hyperlink = context.paragraph.add_hyperlink(address="guide.pdf") + assert context.hyperlink.runs == [] + + +@then("I can build its label by appending runs") +def then_I_can_build_its_label_by_appending_runs(context: Context): + context.hyperlink.add_run("the ") + context.hyperlink.add_run("guide").bold = True + assert context.hyperlink.text == "the guide" + + +@when("I try to create a hyperlink with an empty address") +def when_I_try_to_create_a_hyperlink_with_an_empty_address(context: Context): + context.original_relationships = dict(context.paragraph.part.rels) + try: + context.paragraph.add_hyperlink("label", address="") + except ValueError: + return + raise AssertionError("Expected a ValueError for the empty address") + + +@then("no hyperlink or relationship has been added") +def then_no_hyperlink_or_relationship_has_been_added(context: Context): + assert context.paragraph.text == "Before " + assert context.paragraph.hyperlinks == [] + assert dict(context.paragraph.part.rels) == context.original_relationships + + +@then("the hyperlink has no tooltip") +def then_the_hyperlink_has_no_tooltip(context: Context): + assert context.hyperlink.tooltip is None + + +@when("I assign a {value} tooltip to the hyperlink") +def when_I_assign_a_tooltip(context: Context, value: str): + context.tooltip = {"populated": 'Café "tips" & details', "empty": "", "absent": None}[value] + context.hyperlink.tooltip = "old" + context.hyperlink.tooltip = context.tooltip + + +@then("the assigned tooltip survives saving") +def then_the_assigned_tooltip_survives_saving(context: Context): + stream = BytesIO() + context.document.save(stream) + assert Document(stream).paragraphs[0].hyperlinks[0].tooltip == context.tooltip + + +@when("I try to assign a tooltip containing invalid XML characters") +def when_I_try_to_assign_an_invalid_tooltip(context: Context): + context.hyperlink.tooltip = "old" + try: + context.hyperlink.tooltip = "invalid\x00tooltip" + except ValueError: + return + raise AssertionError("Expected a ValueError for the invalid XML characters") + + +@then("the previous tooltip is preserved") +def then_the_previous_tooltip_is_preserved(context: Context): + assert context.hyperlink.tooltip == "old" + + +@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) + + +@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", "Link emphasis").font.name = "Consolas" + + +@then("the appended runs retain their text and formatting after saving") +def then_the_appended_runs_retain_their_text_and_formatting(context: Context): + stream = BytesIO() + context.document.save(stream) + 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 == "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(test_file("python-icon.jpeg")) + + +@then("the hyperlink contains the picture after saving") +def then_the_hyperlink_contains_the_picture_after_saving(context: Context): + stream = BytesIO() + context.document.save(stream) + hyperlink = Document(stream).paragraphs[1].hyperlinks[0] + assert hyperlink.text == context.original_text + assert len(hyperlink.runs) == context.original_run_count + 1 + assert len(list(hyperlink.runs[-1].iter_inner_content())) == 1 + assert context.picture.width > 0 + + +@when("I try to append a run with a missing character style") +def when_I_try_to_append_a_run_with_a_missing_character_style(context: Context): + try: + context.hyperlink.add_run("label", "Missing hyperlink style") + except KeyError: + return + raise AssertionError("Expected a KeyError for the missing style") + + +@then("the hyperlink's runs remain unchanged") +def then_the_hyperlinks_runs_remain_unchanged(context: Context): + assert context.hyperlink.text == context.original_text + assert len(context.hyperlink.runs) == context.original_run_count + + +@when("I create a hyperlink with a {value} tooltip") +def when_I_create_a_hyperlink_with_a_tooltip(context: Context, value: str): + context.tooltip = {"populated": 'Café "tips" & details', "empty": "", "absent": None}[value] + context.paragraph.add_hyperlink("label", address="guide.pdf", tooltip=context.tooltip) + + +@when("I try to create a hyperlink with invalid XML in its tooltip") +def when_I_try_to_create_a_hyperlink_with_an_invalid_tooltip(context: Context): + context.original_relationships = dict(context.paragraph.part.rels) + try: + context.paragraph.add_hyperlink("label", address="guide.pdf", tooltip="bad\x00tooltip") + except ValueError: + return + raise AssertionError("Expected a ValueError for the invalid XML characters") diff --git a/src/docx/oxml/text/hyperlink.py b/src/docx/oxml/text/hyperlink.py index 38a33ff15..7a389e9b0 100644 --- a/src/docx/oxml/text/hyperlink.py +++ b/src/docx/oxml/text/hyperlink.py @@ -2,8 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Callable, List, cast +from docx.oxml.parser import OxmlElement from docx.oxml.simpletypes import ST_OnOff, ST_String, XsdString from docx.oxml.text.run import CT_R from docx.oxml.xmlchemy import ( @@ -20,6 +21,7 @@ class CT_Hyperlink(BaseOxmlElement): """`` 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] @@ -29,8 +31,17 @@ class CT_Hyperlink(BaseOxmlElement): "w:history", ST_OnOff, default=True ) + tooltip: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:tooltip", ST_String + ) + r = ZeroOrMore("w:r") + @staticmethod + def new() -> CT_Hyperlink: + """Return a new, detached `w:hyperlink` element.""" + return cast(CT_Hyperlink, OxmlElement("w:hyperlink")) + @property def lastRenderedPageBreaks(self) -> List[CT_LastRenderedPageBreak]: """All `w:lastRenderedPageBreak` descendants of this hyperlink.""" diff --git a/src/docx/text/hyperlink.py b/src/docx/text/hyperlink.py index a23df1c74..eedf1a79c 100644 --- a/src/docx/text/hyperlink.py +++ b/src/docx/text/hyperlink.py @@ -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 `` element. A hyperlink occurs as a child of a paragraph, at the same level as a Run. A @@ -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). @@ -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: @@ -100,6 +122,19 @@ def text(self) -> str: """ return self._hyperlink.text + @property + def tooltip(self) -> str | None: + """Text displayed when the pointer rests over this hyperlink. + + |None| means no tooltip is specified. Assigning |None| removes the + tooltip. An empty string specifies an empty tooltip. + """ + return self._hyperlink.tooltip + + @tooltip.setter + def tooltip(self, value: str | None): + self._hyperlink.tooltip = value + @property def url(self) -> str: """Convenience property to get web URLs from hyperlinks that contain them. diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..d4d6c756c 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -5,6 +5,10 @@ from typing import TYPE_CHECKING, Iterator, List, cast from docx.enum.style import WD_STYLE_TYPE +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.opc.oxml import CT_Relationship +from docx.oxml.simpletypes import XsdString +from docx.oxml.text.hyperlink import CT_Hyperlink from docx.oxml.text.run import CT_R from docx.shared import StoryChild from docx.styles.style import ParagraphStyle @@ -27,6 +31,40 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p + def add_hyperlink( + self, text: str | None = None, *, address: str, tooltip: str | None = None + ) -> Hyperlink: + """Append an external hyperlink containing `text` and return its proxy. + + `address` is a non-empty external destination, such as a web URL, mailto + URI, or relative file path. It is stored unchanged, including any URI + fragment. No URL encoding or target lookup is performed. Empty addresses + and fragment-only addresses like ``#bookmark`` raise |ValueError|. + + Omit `text` or pass an empty string to create a hyperlink without runs. + Use :meth:`Hyperlink.add_run` to add individually formatted label runs. + No character style is applied automatically. + + `tooltip` is optional hover text. |None| omits it and an empty string + specifies an empty tooltip. Invalid argument types raise :exc:`TypeError`. + Strings must contain only characters allowed in XML. + """ + XsdString.validate(address) + if not address or address.startswith("#"): + raise ValueError("address must be a non-empty external destination") + # -- validate the XML target before adding a relationship to the part -- + CT_Relationship.new("rId0", RT.HYPERLINK, address) + hyperlink_elm = CT_Hyperlink.new() + hyperlink = Hyperlink(hyperlink_elm, self) + hyperlink.tooltip = tooltip + if text is not None: + XsdString.validate(text) + if text: + hyperlink.add_run(text) + hyperlink_elm.rId = self.part.relate_to(address, RT.HYPERLINK, is_external=True) + self._p.append(hyperlink_elm) + return hyperlink + def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run: """Append run containing `text` and having character-style `style`. diff --git a/tests/oxml/text/test_hyperlink.py b/tests/oxml/text/test_hyperlink.py index f5cec4761..653447d1c 100644 --- a/tests/oxml/text/test_hyperlink.py +++ b/tests/oxml/text/test_hyperlink.py @@ -1,5 +1,7 @@ """Test suite for the docx.oxml.text.hyperlink module.""" +from __future__ import annotations + from typing import cast import pytest @@ -7,12 +9,48 @@ from docx.oxml.text.hyperlink import CT_Hyperlink from docx.oxml.text.run import CT_R -from ...unitutil.cxml import element +from ...unitutil.cxml import element, xml class DescribeCT_Hyperlink: """Unit-test suite for the CT_Hyperlink () element.""" + def it_can_create_a_detached_hyperlink(self): + hyperlink = CT_Hyperlink.new() + + assert isinstance(hyperlink, CT_Hyperlink) + assert hyperlink.xml == xml("w:hyperlink") + assert hyperlink.getparent() is None + + @pytest.mark.parametrize( + ("cxml", "expected"), [("w:hyperlink", None), ("w:hyperlink{w:tooltip=tip}", "tip")] + ) + def it_reads_the_tooltip_attribute(self, cxml: str, expected: str | None): + assert cast(CT_Hyperlink, element(cxml)).tooltip == expected + + @pytest.mark.parametrize("value", [None, "", 'A "tip" & more']) + def it_sets_or_removes_the_tooltip_attribute(self, value: str | None): + hyperlink = cast(CT_Hyperlink, element("w:hyperlink{r:id=rId7,w:tooltip=old}")) + + hyperlink.tooltip = value + + assert ( + hyperlink.get("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tooltip") + == value + ) + assert hyperlink.rId == "rId7" + + @pytest.mark.parametrize(("value", "exception"), [(0, TypeError), ("bad\x00tip", ValueError)]) + def it_rejects_an_invalid_tooltip_without_changing_the_attribute( + self, value: object, exception: type[Exception] + ): + hyperlink = cast(CT_Hyperlink, element("w:hyperlink{w:tooltip=old}")) + + with pytest.raises(exception): + hyperlink.tooltip = cast(str, value) + + assert hyperlink.xml == xml("w:hyperlink{w:tooltip=old}") + def it_has_a_relationship_that_contains_the_hyperlink_address(self): cxml = 'w:hyperlink{r:id=rId6}/w:r/w:t"post"' hyperlink = cast(CT_Hyperlink, element(cxml)) diff --git a/tests/text/test_hyperlink.py b/tests/text/test_hyperlink.py index 0cb977156..908367cc2 100644 --- a/tests/text/test_hyperlink.py +++ b/tests/text/test_hyperlink.py @@ -1,5 +1,7 @@ """Test suite for the docx.text.hyperlink module.""" +from __future__ import annotations + from typing import cast import pytest @@ -9,14 +11,107 @@ 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("value", [None, "", "tip"]) + def it_reads_its_tooltip_from_the_xml_element( + self, request: FixtureRequest, value: str | None, fake_parent: t.ProvidesStoryPart + ): + tooltip_prop = property_mock(request, CT_Hyperlink, "tooltip", return_value=value) + hyperlink = Hyperlink(cast(CT_Hyperlink, element("w:hyperlink")), fake_parent) + + assert hyperlink.tooltip == value + tooltip_prop.assert_called_once_with() + + @pytest.mark.parametrize("value", [None, "", "tip"]) + def it_delegates_tooltip_assignment_to_the_xml_element( + self, request: FixtureRequest, value: str | None, fake_parent: t.ProvidesStoryPart + ): + tooltip_prop = property_mock(request, CT_Hyperlink, "tooltip") + hyperlink = Hyperlink(cast(CT_Hyperlink, element("w:hyperlink")), fake_parent) + + hyperlink.tooltip = value + + tooltip_prop.assert_called_once_with(value) + @pytest.mark.parametrize( ("hlink_cxml", "expected_value"), [ diff --git a/tests/text/test_hyperlink_roundtrip.py b/tests/text/test_hyperlink_roundtrip.py new file mode 100644 index 000000000..38baabc46 --- /dev/null +++ b/tests/text/test_hyperlink_roundtrip.py @@ -0,0 +1,160 @@ +"""Saved-document tests for the public hyperlink authoring API.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path + +import pytest + +from docx import Document +from docx.document import Document as DocumentObject +from docx.enum.dml import MSO_THEME_COLOR_INDEX +from docx.enum.style import WD_STYLE_TYPE +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.styles.style import CharacterStyle +from docx.text.hyperlink import Hyperlink +from docx.text.paragraph import Paragraph +from docx.text.run import Run + + +def paragraph_in(document: DocumentObject, story: str) -> Paragraph: + if story == "body": + return document.paragraphs[0] + if story == "cell": + return document.tables[0].cell(0, 0).paragraphs[0] + if story == "header": + return document.sections[0].header.paragraphs[0] + if story == "footer": + return document.sections[0].footer.paragraphs[0] + raise ValueError(f"Unknown story: {story}") + + +class DescribeHyperlinkAuthoring: + @pytest.mark.parametrize("story", ["body", "cell", "header", "footer"]) + @pytest.mark.parametrize( + "address", + [ + "https://example.com/a%20b?q=one&lang=en#intro", + "mailto:hello@example.com?subject=Hello%20there", + "../guide with spaces.docx", + "custom:resource", + ], + ) + def it_round_trips_formatted_links_in_their_own_story(self, story: str, address: str): + document = Document() + document.add_paragraph() + document.add_table(1, 1) + paragraph = paragraph_in(document, story) + paragraph.style = "Heading 1" + paragraph.add_run("Before ").italic = True + hyperlink = paragraph.add_hyperlink(address=address, tooltip='Café "tips" & details') + assert hyperlink.runs == [] + hyperlink.add_run(" Café\t").bold = True + hyperlink.add_run("code\n", "Emphasis").font.name = "Consolas" + hyperlink.add_run("tail\r ").italic = True + paragraph.add_hyperlink("second", address=address) + paragraph.add_run(" after").bold = True + + stream = BytesIO() + document.save(stream) + reopened = Document(stream) + paragraph = paragraph_in(reopened, story) + hyperlink, adjacent = paragraph.hyperlinks + + assert paragraph.style is not None + assert paragraph.style.name == "Heading 1" + assert paragraph.text == "Before Café\tcode\ntail\n second after" + assert [type(item) for item in paragraph.iter_inner_content()] == [ + Run, + Hyperlink, + Hyperlink, + Run, + ] + assert [run.text for run in paragraph.runs] == ["Before ", " after"] + assert paragraph.runs[0].italic is True + assert paragraph.runs[1].bold is True + assert hyperlink.url == hyperlink.address == adjacent.url == address + assert hyperlink.fragment == "" + assert hyperlink.tooltip == 'Café "tips" & details' + assert adjacent.tooltip is None + assert hyperlink.runs[0].bold is True + assert hyperlink.runs[1].style.name == "Emphasis" + assert hyperlink.runs[1].font.name == "Consolas" + assert hyperlink.runs[2].italic is True + assert all(run.part is paragraph.part for run in hyperlink.runs) + + ids = paragraph.part.element.xpath(".//w:hyperlink/@r:id") + assert ids[0] == ids[1] + rel = paragraph.part.rels[ids[0]] + assert rel.reltype == RT.HYPERLINK + assert rel.is_external + assert rel.target_ref == address + if story in ("header", "footer"): + assert not any(rel.reltype == RT.HYPERLINK for rel in reopened.part.rels.values()) + + @pytest.mark.parametrize("story", ["body", "cell", "header", "footer"]) + def it_can_add_a_picture_to_a_hyperlink_run(self, story: str): + document = Document() + document.add_paragraph() + document.add_table(1, 1) + paragraph = paragraph_in(document, story) + hyperlink = paragraph.add_hyperlink(address="https://example.com") + image_path = Path(__file__).parents[1] / "test_files" / "python-icon.jpeg" + picture = hyperlink.add_run().add_picture(str(image_path)) + assert picture.width > 0 + + stream = BytesIO() + document.save(stream) + paragraph = paragraph_in(Document(stream), story) + assert paragraph.hyperlinks[0].url == "https://example.com" + embeds = paragraph.part.element.xpath(".//w:hyperlink/w:r/w:drawing//a:blip/@r:embed") + assert len(embeds) == 1 + rel = paragraph.part.rels[embeds[0]] + assert rel.reltype == RT.IMAGE + assert not rel.is_external + assert rel.target_part.blob == image_path.read_bytes() + + def it_leaves_style_definitions_under_caller_control(self): + document = Document() + assert "Hyperlink" not in document.styles + link = document.add_paragraph().add_hyperlink("plain", address="guide.pdf") + assert "Hyperlink" not in document.styles + assert link.runs[0].style.name == "Default Paragraph Font" + style = document.styles.add_style( # pyright: ignore[reportUnknownMemberType] + "Hyperlink", WD_STYLE_TYPE.CHARACTER + ) + assert isinstance(style, CharacterStyle) + style.font.color.theme_color = MSO_THEME_COLOR_INDEX.ACCENT_2 + style.font.underline = False + link.add_run("styled", style) + link.add_run("also styled", "Hyperlink") + link.add_run("override", "Emphasis") + + stream = BytesIO() + document.save(stream) + reopened = Document(stream) + + style = reopened.styles["Hyperlink"] + assert isinstance(style, CharacterStyle) + assert style.font.color.theme_color == MSO_THEME_COLOR_INDEX.ACCENT_2 + assert style.font.underline is False + assert [run.style.name for run in reopened.paragraphs[0].hyperlinks[0].runs] == [ + "Default Paragraph Font", + "Hyperlink", + "Hyperlink", + "Emphasis", + ] + + @pytest.mark.parametrize("value", [None, "", 'Café "tips" & details']) + def it_round_trips_tooltip_replacement(self, value: str | None): + document = Document() + hyperlink = document.add_paragraph().add_hyperlink( + "label", address="guide.pdf", tooltip="old" + ) + hyperlink.tooltip = value + + stream = BytesIO() + document.save(stream) + + assert Document(stream).paragraphs[0].hyperlinks[0].tooltip == value diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 0329b1dd3..48481c1a6 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -1,26 +1,135 @@ """Unit test suite for the docx.text.paragraph module.""" -from typing import List, cast +from __future__ import annotations + +from typing import Any, List, cast import pytest from docx import types as t from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart +from docx.parts.story import StoryPart +from docx.text.hyperlink import Hyperlink from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run from ..unitutil.cxml import element, xml -from ..unitutil.mock import call, class_mock, instance_mock, method_mock, property_mock +from ..unitutil.mock import ( + FixtureRequest, + Mock, + call, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeParagraph: """Unit-test suite for `docx.text.run.Paragraph`.""" + @pytest.mark.parametrize("text", [None, "", "label"]) + def it_appends_a_hyperlink_and_delegates_its_label( + self, + request: FixtureRequest, + text: str | None, + hyperlink_story_part_: Mock, + fake_parent: t.ProvidesStoryPart, + ): + add_run_ = method_mock(request, Hyperlink, "add_run") + p = cast(CT_P, element('w:p/(w:pPr,w:r/w:t"before",w:hyperlink{r:id=rId4})')) + paragraph = Paragraph(p, fake_parent) + + hyperlink = paragraph.add_hyperlink(text, address="https://example.com") + + hyperlink_story_part_.relate_to.assert_called_once_with( + "https://example.com", RT.HYPERLINK, is_external=True + ) + assert isinstance(hyperlink, Hyperlink) + assert hyperlink.part is hyperlink_story_part_ + assert p.xml == xml( + 'w:p/(w:pPr,w:r/w:t"before",w:hyperlink{r:id=rId4},w:hyperlink{r:id=rId7})' + ) + if text: + add_run_.assert_called_once_with(hyperlink, text) + else: + add_run_.assert_not_called() + + @pytest.mark.parametrize("tooltip", [None, "", "Link details"]) + def it_delegates_the_initial_tooltip_to_the_hyperlink( + self, + request: FixtureRequest, + tooltip: str | None, + hyperlink_story_part_: Mock, + fake_parent: t.ProvidesStoryPart, + ): + tooltip_ = property_mock(request, Hyperlink, "tooltip") + paragraph = Paragraph(cast(CT_P, element("w:p")), fake_parent) + + paragraph.add_hyperlink(address="guide.pdf", tooltip=tooltip) + + tooltip_.assert_called_once_with(tooltip) + + @pytest.mark.parametrize( + "address", + ["https://example.com/a%20b?q=1&b=2#part", "mailto:a@example.com", "../a b.docx"], + ) + def it_preserves_the_external_target_when_registering_its_relationship( + self, address: str, hyperlink_story_part_: Mock, fake_parent: t.ProvidesStoryPart + ): + paragraph = Paragraph(cast(CT_P, element("w:p")), fake_parent) + + paragraph.add_hyperlink(address=address) + + hyperlink_story_part_.relate_to.assert_called_once_with( + address, RT.HYPERLINK, is_external=True + ) + + @pytest.mark.parametrize( + ("kwargs", "exception"), + [ + ({}, TypeError), + ({"address": None}, TypeError), + ({"address": 0}, TypeError), + ({"address": b"guide.pdf"}, TypeError), + ({"address": ""}, ValueError), + ({"address": "#bookmark"}, ValueError), + ({"address": "bad\x00target"}, ValueError), + ({"address": "guide.pdf", "tooltip": 0}, TypeError), + ({"address": "guide.pdf", "tooltip": "bad\x00tooltip"}, ValueError), + ({"address": "guide.pdf", "text": 0}, TypeError), + ({"address": "guide.pdf", "text": "bad\x00text"}, ValueError), + ], + ) + def it_rejects_invalid_hyperlink_input_before_changing_the_document( + self, + kwargs: dict[str, Any], + exception: type[Exception], + hyperlink_story_part_: Mock, + fake_parent: t.ProvidesStoryPart, + ): + p = cast(CT_P, element('w:p/(w:r/w:t"before",w:hyperlink{r:id=rId4})')) + paragraph = Paragraph(p, fake_parent) + + with pytest.raises(exception): + paragraph.add_hyperlink(**kwargs) + + hyperlink_story_part_.relate_to.assert_not_called() + assert p.xml == xml('w:p/(w:r/w:t"before",w:hyperlink{r:id=rId4})') + + @pytest.fixture + def hyperlink_story_part_(self, request: FixtureRequest) -> Mock: + story_part = instance_mock(request, StoryPart) + story_part.relate_to.return_value = "rId7" + property_mock(request, Paragraph, "part", return_value=story_part) + return story_part + @pytest.mark.parametrize( ("p_cxml", "expected_value"), [