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) + )