From 24802243e931bc6c3a9211d12e3a296fd58ed3fe Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Mon, 12 Oct 2026 20:39:49 +0200 Subject: [PATCH] fix(pptx): keep a hyperlink attached to a whole shape A link stored as a:hlinkClick under the shape's p:cNvPr was dropped. The shape's text items now carry it unless they already have a link of their own. Click actions that are not links, such as a jump to another slide, are ignored. Signed-off-by: Praveen Mittal --- docling/backend/mspowerpoint_backend.py | 57 ++++++++++++- tests/test_backend_pptx_shape_hyperlink.py | 95 ++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/test_backend_pptx_shape_hyperlink.py diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index cf666edad4..32d11fa612 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -13,7 +13,18 @@ from io import BytesIO from pathlib import Path from tempfile import mkdtemp -from typing import Any, Callable, Final, Iterable, Iterator, Literal, Optional, Union +from typing import ( + Any, + Callable, + Final, + Iterable, + Iterator, + Literal, + Optional, + Sequence, + Union, +) +from urllib.parse import urlparse from docling_core.types.doc import ( BoundingBox, @@ -35,10 +46,12 @@ TableCell, TableData, TabularChartMetaField, + TextItem, ) from docling_core.types.doc.document import ContentLayer from lxml import etree from PIL import Image, UnidentifiedImageError +from pydantic import AnyUrl, ValidationError from typing_extensions import override from docling.backend.abstract_backend import ( @@ -64,6 +77,7 @@ try: # pragma: no cover - import-time guard from pptx import Presentation, presentation from pptx.chart.plot import PlotFactory, PlotTypeInspector + from pptx.enum.action import PP_ACTION from pptx.enum.shapes import MSO_SHAPE_TYPE, PP_PLACEHOLDER from pptx.exc import InvalidXmlError from pptx.oxml.ns import qn @@ -1939,9 +1953,11 @@ def handle_shape_content(shape, parent_slide, slide_ind, doc, slide_size): return # Handle other text elements, including lists (bullet lists, numbered # lists) + first_new_text = len(doc.texts) self._handle_text_elements( shape, parent_slide, slide_ind, doc, slide_size ) + self._apply_shape_hyperlink(shape, doc.texts[first_new_text:]) return def handle_groups(shape, parent_slide, slide_ind, doc, slide_size): @@ -1996,6 +2012,45 @@ def _is_hidden_shape(shape) -> bool: hidden = shape._element.xpath("./*/p:cNvPr/@hidden") return bool(hidden) and hidden[0] in ("1", "true") + @staticmethod + def _shape_hyperlink(shape) -> Optional[Union[AnyUrl, Path]]: + """Return the link attached to the whole shape, if it has one. + + PowerPoint stores such a link as ``a:hlinkClick`` under the shape's + ``p:cNvPr``. Only a link to a URL or a file is returned: other click + actions, such as a jump to another slide, also carry a relationship + target (``slide2.xml``), but it is not something a reader can follow. + """ + try: + click_action = shape.click_action + if click_action.action not in (PP_ACTION.HYPERLINK, PP_ACTION.OPEN_FILE): + return None + address = click_action.hyperlink.address + except (AttributeError, KeyError): + # No click action on this shape kind, or its relationship is gone. + return None + if not address: + return None + if not urlparse(address).scheme: + return Path(address) + try: + return AnyUrl(address) + except ValidationError: + _log.warning("Skipping malformed hyperlink address: %r", address) + return None + + @classmethod + def _apply_shape_hyperlink(cls, shape, items: Sequence[TextItem]) -> None: + """Give the shape's link to its text items that have no link of their own.""" + if not items: + return + hyperlink = cls._shape_hyperlink(shape) + if hyperlink is None: + return + for item in items: + if item.hyperlink is None: + item.hyperlink = hyperlink + @staticmethod def _body_item_refs(doc: DoclingDocument, parent_slide: GroupItem) -> set[str]: return { diff --git a/tests/test_backend_pptx_shape_hyperlink.py b/tests/test_backend_pptx_shape_hyperlink.py new file mode 100644 index 0000000000..d76fb8c850 --- /dev/null +++ b/tests/test_backend_pptx_shape_hyperlink.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Tests for a hyperlink attached to a whole PowerPoint shape.""" + +from pathlib import Path + +from docling_core.types.doc import ContentLayer, TextItem +from pptx import Presentation +from pptx.util import Inches + +from docling.datamodel.document import DoclingDocument + +from .test_backend_pptx import get_converter + + +def _links_by_text(doc: DoclingDocument) -> dict[str, str | None]: + return { + item.text: None if item.hyperlink is None else str(item.hyperlink) + for item, _ in doc.iterate_items(included_content_layers=set(ContentLayer)) + if isinstance(item, TextItem) + } + + +def _convert(prs, path: Path) -> DoclingDocument: + prs.save(path) + return get_converter().convert(path).document + + +def test_pptx_shape_hyperlink_reaches_its_text(tmp_path: Path): + """A link on a whole shape (``a:hlinkClick`` under ``p:cNvPr``) is kept. + + Every paragraph of the shape carries the link; a shape without one is + left alone. + """ + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + plain = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(6), Inches(1)) + plain.text_frame.text = "No link here" + button = slide.shapes.add_textbox(Inches(1), Inches(3), Inches(4), Inches(1)) + button.text_frame.text = "Watch the talk" + button.text_frame.add_paragraph().text = "Second line" + button.click_action.hyperlink.address = "https://example.com/talk" + + doc = _convert(prs, tmp_path / "shape_link.pptx") + + assert _links_by_text(doc) == { + "No link here": None, + "Watch the talk": "https://example.com/talk", + "Second line": "https://example.com/talk", + } + assert "[Watch the talk](https://example.com/talk)" in doc.export_to_markdown() + + +def test_pptx_shape_hyperlink_reaches_list_items(tmp_path: Path): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[1]) + body = slide.placeholders[1] + body.text_frame.text = "First point" + body.text_frame.add_paragraph().text = "Second point" + body.click_action.hyperlink.address = "https://example.com/points" + + doc = _convert(prs, tmp_path / "list_link.pptx") + + links = _links_by_text(doc) + assert links["First point"] == "https://example.com/points" + assert links["Second point"] == "https://example.com/points" + + +def test_pptx_shape_relative_link_is_a_path(tmp_path: Path): + """An address without a URL scheme is kept as a path, as the DOCX backend does.""" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)) + box.text_frame.text = "Open the appendix" + box.click_action.hyperlink.address = "appendix/notes.docx" + + doc = _convert(prs, tmp_path / "relative_link.pptx") + + (item,) = [t for t in doc.texts if t.text == "Open the appendix"] + assert item.hyperlink == Path("appendix/notes.docx") + + +def test_pptx_slide_jump_action_is_not_a_hyperlink(tmp_path: Path): + """A click action that jumps to another slide has no address to keep.""" + prs = Presentation() + first = prs.slides.add_slide(prs.slide_layouts[6]) + second = prs.slides.add_slide(prs.slide_layouts[6]) + box = first.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)) + box.text_frame.text = "Go to the next slide" + box.click_action.target_slide = second + + doc = _convert(prs, tmp_path / "slide_jump.pptx") + + assert _links_by_text(doc) == {"Go to the next slide": None}