Skip to content
Merged
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
57 changes: 56 additions & 1 deletion docling/backend/mspowerpoint_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions tests/test_backend_pptx_shape_hyperlink.py
Original file line number Diff line number Diff line change
@@ -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}
Loading