diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py
index 0452e02168..611d076d7b 100644
--- a/docling/backend/mspowerpoint_backend.py
+++ b/docling/backend/mspowerpoint_backend.py
@@ -1166,11 +1166,45 @@ def _rasterize_metafile(self, image_bytes: bytes) -> Optional[Image.Image]:
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
- def _handle_pictures(self, shape, parent_slide, slide_ind, doc, slide_size):
+ @staticmethod
+ def _picture_fill_rid(shape) -> Optional[str]:
+ """Return the relationship id of a shape's embedded picture fill.
+
+ A shape filled with a picture (Format Shape > Fill > Picture) is a
+ ``p:sp`` whose ``p:spPr`` holds an ``a:blipFill``; python-pptx reads it
+ as an ``Autoshape``, not a ``Picture``. Other fill kinds give ``None``.
+ A picture fill without an embedded image (for example one that only
+ links an external file with ``r:link``) also gives ``None``, with a
+ warning, because its image is not in the package.
+ """
+ sp_pr = shape._element.find(qn("p:spPr"))
+ if sp_pr is None:
+ return None
+ blip = sp_pr.find(f"{qn('a:blipFill')}/{qn('a:blip')}")
+ if blip is None:
+ return None
+ rid = blip.get(qn("r:embed"))
+ if not rid:
+ warnings.warn(
+ "Skipping picture fill without an embedded image",
+ UserWarning,
+ stacklevel=2,
+ )
+ return None
+ return rid
+
+ def _handle_pictures(
+ self, shape, parent_slide, slide_ind, doc, slide_size, fill_rid=None
+ ):
# Read the picture out of the package. A shape that slips past other
# tools' parsers can still fail here, and there is nothing to record.
try:
- image = shape.image
+ if fill_rid is None:
+ image = shape.image
+ else:
+ # The picture fill of an ordinary shape lives in the image part
+ # its a:blip references.
+ image = shape.part.related_part(fill_rid).image
image_bytes = image.blob
im_dpi, _ = image.dpi
prov = self._generate_prov(shape, slide_ind, "", slide_size)
@@ -1899,6 +1933,18 @@ def handle_shape_content(shape, parent_slide, slide_ind, doc, slide_size):
# If shape doesn't have any text, move on to the next shape
if not isinstance(shape, Shape):
return
+ fill_rid = self._picture_fill_rid(shape)
+ if fill_rid is not None:
+ # A shape filled with a picture: keep the image like a p:pic,
+ # then any text the shape also carries.
+ self._handle_pictures(
+ shape,
+ parent_slide,
+ slide_ind,
+ doc,
+ slide_size,
+ fill_rid=fill_rid,
+ )
if shape.text is None:
return
if not shape.has_text_frame:
diff --git a/tests/test_backend_pptx_picture_fill.py b/tests/test_backend_pptx_picture_fill.py
new file mode 100644
index 0000000000..e33b1f542d
--- /dev/null
+++ b/tests/test_backend_pptx_picture_fill.py
@@ -0,0 +1,141 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Tests for PowerPoint shapes filled with a picture (``a:blipFill``)."""
+
+import warnings
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+from lxml import etree
+from PIL import Image
+from pptx import Presentation
+from pptx.enum.shapes import MSO_SHAPE
+from pptx.oxml.ns import qn
+from pptx.util import Inches
+
+from .test_backend_pptx import get_converter
+
+
+def _picture_filled_shape_pptx(
+ path: Path, text: str = "", embed: bool = True, r_id: str | None = None
+):
+ """Save a one-slide PPTX with a rectangle filled with a 64x32 PNG.
+
+ ``r_id`` overrides the relationship id the fill points to.
+ """
+ png = BytesIO()
+ Image.new("RGB", (64, 32), "red").save(png, "PNG")
+
+ prs = Presentation()
+ slide = prs.slides.add_slide(prs.slide_layouts[6])
+ shape = slide.shapes.add_shape(
+ MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(2), Inches(1)
+ )
+ if text:
+ shape.text_frame.text = text
+ _, image_r_id = shape.part.get_or_add_image_part(BytesIO(png.getvalue()))
+
+ blip_fill = etree.Element(qn("a:blipFill"))
+ blip = etree.SubElement(blip_fill, qn("a:blip"))
+ blip.set(qn("r:embed" if embed else "r:link"), r_id or image_r_id)
+ etree.SubElement(etree.SubElement(blip_fill, qn("a:stretch")), qn("a:fillRect"))
+ shape._element.spPr.find(qn("a:prstGeom")).addnext(blip_fill)
+ prs.save(path)
+
+
+def test_pptx_picture_filled_shape_is_extracted(tmp_path: Path):
+ """A shape filled with a picture gives a picture item."""
+ pptx_path = tmp_path / "blipfill.pptx"
+ _picture_filled_shape_pptx(pptx_path)
+
+ doc = get_converter().convert(pptx_path).document
+
+ assert len(doc.pictures) == 1
+ picture = doc.pictures[0]
+ assert picture.image is not None
+ assert picture.image.mimetype == "image/png"
+ assert picture.image.size.width == 64.0
+ assert picture.image.size.height == 32.0
+ assert picture.prov and picture.prov[0].page_no == 1
+ assert picture.prov[0].bbox.width > 0
+
+
+def test_pptx_picture_filled_shape_keeps_its_text(tmp_path: Path):
+ """The text of a picture-filled shape is kept next to the picture."""
+ pptx_path = tmp_path / "blipfill_text.pptx"
+ _picture_filled_shape_pptx(pptx_path, text="Caption on the fill")
+
+ doc = get_converter().convert(pptx_path).document
+
+ assert len(doc.pictures) == 1
+ assert [t.text for t in doc.texts] == ["Caption on the fill"]
+
+
+def test_pptx_linked_picture_fill_is_ignored(tmp_path: Path):
+ """A picture fill that only links an external image adds no picture."""
+ pptx_path = tmp_path / "blipfill_link.pptx"
+ _picture_filled_shape_pptx(pptx_path, text="Linked fill", embed=False)
+
+ with pytest.warns(UserWarning, match="picture fill without an embedded image"):
+ doc = get_converter().convert(pptx_path).document
+
+ assert len(doc.pictures) == 0
+ assert [t.text for t in doc.texts] == ["Linked fill"]
+
+
+def test_pptx_picture_fill_with_missing_part_is_skipped(tmp_path: Path):
+ """A fill pointing at a missing relationship is skipped, text is kept."""
+ pptx_path = tmp_path / "blipfill_dangling.pptx"
+ _picture_filled_shape_pptx(pptx_path, text="Dangling fill", r_id="rId999")
+
+ with pytest.warns(UserWarning, match="Skipping malformed picture shape"):
+ doc = get_converter().convert(pptx_path).document
+
+ assert len(doc.pictures) == 0
+ assert [t.text for t in doc.texts] == ["Dangling fill"]
+
+
+@pytest.mark.parametrize(
+ "xml",
+ [
+ # No p:spPr at all.
+ '',
+ # p:spPr with a non-picture fill.
+ '',
+ ],
+ ids=["no_sppr", "solid_fill"],
+)
+def test_picture_fill_rid_without_picture_fill(xml: str):
+ """Shapes without a picture fill give no relationship id and no warning."""
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ assert _picture_fill_rid_from_xml(xml) is None
+
+
+def test_picture_fill_rid_with_empty_embed_warns():
+ """A picture fill with an empty r:embed gives no relationship id."""
+ xml = (
+ ''
+ ''
+ )
+ with pytest.warns(UserWarning, match="picture fill without an embedded image"):
+ assert _picture_fill_rid_from_xml(xml) is None
+
+
+def _picture_fill_rid_from_xml(xml: str):
+ from types import SimpleNamespace
+
+ from docling.backend.mspowerpoint_backend import MsPowerpointDocumentBackend
+
+ element = etree.fromstring(
+ xml.format(
+ p="http://schemas.openxmlformats.org/presentationml/2006/main",
+ a="http://schemas.openxmlformats.org/drawingml/2006/main",
+ r="http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ )
+ )
+ return MsPowerpointDocumentBackend._picture_fill_rid(
+ SimpleNamespace(_element=element)
+ )