From 02ea328f73018c7256d3dbbc469120e9d88126de Mon Sep 17 00:00:00 2001
From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:02:07 +0530
Subject: [PATCH 1/4] fix(pptx): extract the picture of a picture-filled shape
A shape filled with a picture (p:sp with a:blipFill in p:spPr) is read by
python-pptx as an Autoshape, so only its text was handled and the image
was dropped. Extract the embedded fill image as a picture item, like a
p:pic, and keep the shape's text as well. Linked fills are left out.
Signed-off-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
---
docling/backend/mspowerpoint_backend.py | 40 +++++++++++++-
tests/test_backend_pptx.py | 70 +++++++++++++++++++++++++
2 files changed, 108 insertions(+), 2 deletions(-)
diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py
index 0452e02168..07c6068f9f 100644
--- a/docling/backend/mspowerpoint_backend.py
+++ b/docling/backend/mspowerpoint_backend.py
@@ -1166,11 +1166,35 @@ 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``. Linked (not embedded) fills and
+ other fill kinds give ``None``.
+ """
+ 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
+ return blip.get(qn("r:embed")) or None
+
+ 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 +1923,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.py b/tests/test_backend_pptx.py
index 30bdf983ed..61ed756fc7 100644
--- a/tests/test_backend_pptx.py
+++ b/tests/test_backend_pptx.py
@@ -1430,3 +1430,73 @@ def test_pptx_layout_list_marker_precedence(
document = get_converter().convert(path).document
assert document.export_to_markdown() == "# Agenda\n\n" + expected
+
+
+def _picture_filled_shape_pptx(path: Path, text: str = "", embed: bool = True):
+ """Save a one-slide PPTX with a rectangle filled with a 64x32 PNG."""
+ from io import BytesIO
+
+ 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
+
+ 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
+ _, 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)
+ 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 (``a:blipFill``) 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)
+
+ doc = get_converter().convert(pptx_path).document
+
+ assert len(doc.pictures) == 0
+ assert [t.text for t in doc.texts] == ["Linked fill"]
From 00ef272e721eef67f76ef256d0c96d9fe0c99e3a Mon Sep 17 00:00:00 2001
From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:49:16 +0530
Subject: [PATCH 2/4] test(pptx): move the picture-fill tests into their own
file
tests/test_backend_pptx.py went over the 1500-line limit of the max-lines
hook. Also cover a fill whose relationship is missing.
Signed-off-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
---
tests/test_backend_pptx.py | 70 ------------------
tests/test_backend_pptx_picture_fill.py | 95 +++++++++++++++++++++++++
2 files changed, 95 insertions(+), 70 deletions(-)
create mode 100644 tests/test_backend_pptx_picture_fill.py
diff --git a/tests/test_backend_pptx.py b/tests/test_backend_pptx.py
index 61ed756fc7..30bdf983ed 100644
--- a/tests/test_backend_pptx.py
+++ b/tests/test_backend_pptx.py
@@ -1430,73 +1430,3 @@ def test_pptx_layout_list_marker_precedence(
document = get_converter().convert(path).document
assert document.export_to_markdown() == "# Agenda\n\n" + expected
-
-
-def _picture_filled_shape_pptx(path: Path, text: str = "", embed: bool = True):
- """Save a one-slide PPTX with a rectangle filled with a 64x32 PNG."""
- from io import BytesIO
-
- 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
-
- 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
- _, 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)
- 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 (``a:blipFill``) 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)
-
- doc = get_converter().convert(pptx_path).document
-
- assert len(doc.pictures) == 0
- assert [t.text for t in doc.texts] == ["Linked fill"]
diff --git a/tests/test_backend_pptx_picture_fill.py b/tests/test_backend_pptx_picture_fill.py
new file mode 100644
index 0000000000..7dc84ae8c0
--- /dev/null
+++ b/tests/test_backend_pptx_picture_fill.py
@@ -0,0 +1,95 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Tests for PowerPoint shapes filled with a picture (``a:blipFill``)."""
+
+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)
+
+ 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"]
From 41a2daff82bb288e4677ceb1cbb4c2eba326a059 Mon Sep 17 00:00:00 2001
From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
Date: Fri, 23 Oct 2026 19:49:06 +0530
Subject: [PATCH 3/4] test(pptx): cover shapes without an embedded picture fill
Signed-off-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
---
tests/test_backend_pptx_picture_fill.py | 31 +++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/tests/test_backend_pptx_picture_fill.py b/tests/test_backend_pptx_picture_fill.py
index 7dc84ae8c0..098d883d9b 100644
--- a/tests/test_backend_pptx_picture_fill.py
+++ b/tests/test_backend_pptx_picture_fill.py
@@ -93,3 +93,34 @@ def test_pptx_picture_fill_with_missing_part_is_skipped(tmp_path: Path):
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.
+ '',
+ # a:blip with an empty r:embed.
+ ''
+ '',
+ ],
+ ids=["no_sppr", "solid_fill", "empty_embed"],
+)
+def test_picture_fill_rid_without_embedded_fill(xml: str):
+ """Shapes without an embedded picture fill give no relationship id."""
+ 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",
+ )
+ )
+ shape = SimpleNamespace(_element=element)
+
+ assert MsPowerpointDocumentBackend._picture_fill_rid(shape) is None
From f39e2af089c6b1a38d054a03797f818e7a0b36ca Mon Sep 17 00:00:00 2001
From: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:56:18 +0530
Subject: [PATCH 4/4] fix(pptx): warn when a picture fill has no embedded image
Signed-off-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
---
docling/backend/mspowerpoint_backend.py | 16 ++++++++---
tests/test_backend_pptx_picture_fill.py | 35 ++++++++++++++++++-------
2 files changed, 38 insertions(+), 13 deletions(-)
diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py
index 07c6068f9f..611d076d7b 100644
--- a/docling/backend/mspowerpoint_backend.py
+++ b/docling/backend/mspowerpoint_backend.py
@@ -1172,8 +1172,10 @@ def _picture_fill_rid(shape) -> Optional[str]:
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``. Linked (not embedded) fills and
- other fill kinds give ``None``.
+ 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:
@@ -1181,7 +1183,15 @@ def _picture_fill_rid(shape) -> Optional[str]:
blip = sp_pr.find(f"{qn('a:blipFill')}/{qn('a:blip')}")
if blip is None:
return None
- return blip.get(qn("r:embed")) or 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
diff --git a/tests/test_backend_pptx_picture_fill.py b/tests/test_backend_pptx_picture_fill.py
index 098d883d9b..e33b1f542d 100644
--- a/tests/test_backend_pptx_picture_fill.py
+++ b/tests/test_backend_pptx_picture_fill.py
@@ -3,6 +3,7 @@
"""Tests for PowerPoint shapes filled with a picture (``a:blipFill``)."""
+import warnings
from io import BytesIO
from pathlib import Path
@@ -77,7 +78,8 @@ def test_pptx_linked_picture_fill_is_ignored(tmp_path: Path):
pptx_path = tmp_path / "blipfill_link.pptx"
_picture_filled_shape_pptx(pptx_path, text="Linked fill", embed=False)
- doc = get_converter().convert(pptx_path).document
+ 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"]
@@ -102,14 +104,27 @@ def test_pptx_picture_fill_with_missing_part_is_skipped(tmp_path: Path):
'',
# p:spPr with a non-picture fill.
'',
- # a:blip with an empty r:embed.
- ''
- '',
],
- ids=["no_sppr", "solid_fill", "empty_embed"],
+ ids=["no_sppr", "solid_fill"],
)
-def test_picture_fill_rid_without_embedded_fill(xml: str):
- """Shapes without an embedded picture fill give no relationship id."""
+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
@@ -121,6 +136,6 @@ def test_picture_fill_rid_without_embedded_fill(xml: str):
r="http://schemas.openxmlformats.org/officeDocument/2006/relationships",
)
)
- shape = SimpleNamespace(_element=element)
-
- assert MsPowerpointDocumentBackend._picture_fill_rid(shape) is None
+ return MsPowerpointDocumentBackend._picture_fill_rid(
+ SimpleNamespace(_element=element)
+ )