Skip to content
Open
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
26 changes: 22 additions & 4 deletions packages/markitdown/src/markitdown/converters/_pptx_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ def get_shape_content(shape, **kwargs):
md_content += text + "\n"

# Group Shapes
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP:
if (
self._shape_type_or_none(shape)
== pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP
):
sorted_shapes = sorted(
shape.shapes,
key=lambda x: (
Expand Down Expand Up @@ -280,10 +283,25 @@ def _get_image_info(self, shape):

return None, None, None

@staticmethod
def _shape_type_or_none(shape):
"""Return the shape's MSO type, or None if python-pptx can't identify it.

Some decks contain a shape whose type python-pptx does not recognize;
reading shape.shape_type then raises NotImplementedError. A single such
shape should not abort the whole presentation, so callers treat an
unrecognized shape as "none of the handled types" and fall through.
"""
try:
return shape.shape_type
except NotImplementedError:
return None

def _is_picture(self, shape):
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE:
shape_type = self._shape_type_or_none(shape)
if shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE:
return True
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER:
if shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PLACEHOLDER:
# ``shape.image`` can raise (e.g. ValueError "no embedded image")
# for SVG placeholders without a raster fallback, so guard against
# any exception rather than relying on hasattr (which only swallows
Expand All @@ -298,7 +316,7 @@ def _is_picture(self, shape):
return False

def _is_table(self, shape):
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE:
if self._shape_type_or_none(shape) == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE:
return True
return False

Expand Down
92 changes: 92 additions & 0 deletions packages/markitdown/tests/test_pptx_unrecognized_shape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env python3 -m pytest
"""Robustness test for the PPTX converter.

A shape whose type python-pptx cannot identify raises NotImplementedError when
its ``shape_type`` is read. One such shape must not abort the whole
presentation, so the type checks treat it as "none of the handled types" and
fall through instead of propagating.

The fixture is a real ``<p:sp>`` with no placeholder, no preset or custom
geometry, and a ``<p:cNvSpPr>`` without ``txBox="1"`` - exactly the element
python-pptx cannot map - injected into a slide's shape tree.
"""
import io
from typing import Any

import pytest

pytest.importorskip("pptx")
from pptx import Presentation
from pptx.oxml import parse_xml
from pptx.util import Inches

from markitdown import MarkItDown
from markitdown._stream_info import StreamInfo
from markitdown.converters._pptx_converter import PptxConverter

# Not a placeholder, no <a:prstGeom>/<a:custGeom>, and cNvSpPr lacks txBox="1",
# so Shape.shape_type raises NotImplementedError. It still carries a text frame,
# so falling through must recover its text rather than drop it.
_UNRECOGNIZED_SP = (
'<p:sp xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'
' xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">'
'<p:nvSpPr><p:cNvPr id="9999" name="BadShape"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
"<p:spPr/>"
"<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r>"
"<a:t>text in an unrecognized shape</a:t>"
"</a:r></a:p></p:txBody>"
"</p:sp>"
)


def _slide_with_unrecognized_shape() -> tuple[Any, Any]:
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 = "surrounding text"
slide.shapes._spTree.append(parse_xml(_UNRECOGNIZED_SP))
return prs, slide


def _bad_shape(slide: Any) -> Any:
return next(s for s in slide.shapes if s.name == "BadShape")


def test_fixture_really_is_unrecognized() -> None:
"""Guard the fixture itself: python-pptx must raise on its shape_type."""
_, slide = _slide_with_unrecognized_shape()
with pytest.raises(NotImplementedError):
_ = _bad_shape(slide).shape_type


def test_shape_type_or_none_swallows_unrecognized_type() -> None:
_, slide = _slide_with_unrecognized_shape()
assert PptxConverter._shape_type_or_none(_bad_shape(slide)) is None


def test_unrecognized_shape_is_neither_picture_nor_table() -> None:
_, slide = _slide_with_unrecognized_shape()
converter = PptxConverter()
bad = _bad_shape(slide)
assert converter._is_picture(bad) is False
assert converter._is_table(bad) is False


def test_one_unrecognized_shape_does_not_abort_the_deck() -> None:
"""The deck still converts, and both the surrounding text and the unrecognized
shape's own text are recovered (falling through reaches the text-frame branch)."""
prs, _ = _slide_with_unrecognized_shape()
buffer = io.BytesIO()
prs.save(buffer)
buffer.seek(0)
result = MarkItDown().convert_stream(
buffer, stream_info=StreamInfo(extension=".pptx")
)
assert "surrounding text" in result.markdown
assert "text in an unrecognized shape" in result.markdown


if __name__ == "__main__":
import sys

sys.exit(pytest.main([__file__, "-v"]))