import sys import base64 import os import io import re import html from typing import BinaryIO, Any, Optional from ._html_converter import HtmlConverter from ._llm_caption import llm_caption from ..converter_utils._image import _parse_image_html from .._base_converter import DocumentConverter, DocumentConverterResult from .._stream_info import StreamInfo from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE # Try loading optional (but in this case, required) dependencies # Save reporting of any exceptions for later _dependency_exc_info = None try: import pptx except ImportError: # Preserve the error and stack trace for later _dependency_exc_info = sys.exc_info() ACCEPTED_MIME_TYPE_PREFIXES = [ "application/vnd.openxmlformats-officedocument.presentationml", ] ACCEPTED_FILE_EXTENSIONS = [".pptx"] class PptxConverter(DocumentConverter): """ Converts PPTX files to Markdown. Supports heading, tables and images with alt text. """ def __init__(self): super().__init__() self._html_converter = HtmlConverter() def accepts( self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any, # Options to pass to the converter ) -> bool: mimetype = (stream_info.mimetype or "").lower() extension = (stream_info.extension or "").lower() if extension in ACCEPTED_FILE_EXTENSIONS: return True for prefix in ACCEPTED_MIME_TYPE_PREFIXES: if mimetype.startswith(prefix): return True return False def convert( self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any, # Options to pass to the converter ) -> DocumentConverterResult: # Check the dependencies if _dependency_exc_info is not None: raise MissingDependencyException( MISSING_DEPENDENCY_MESSAGE.format( converter=type(self).__name__, extension=".pptx", feature="pptx", ) ) from _dependency_exc_info[ 1 ].with_traceback( # type: ignore[union-attr] _dependency_exc_info[2] ) # Perform the conversion presentation = pptx.Presentation(file_stream) md_content = "" slide_num = 0 for slide in presentation.slides: slide_num += 1 md_content += f"\n\n\n" title = slide.shapes.title def get_shape_content(shape, **kwargs): nonlocal md_content # Pictures if self._is_picture(shape): md_content += self._convert_picture_to_markdown(shape, **kwargs) # Tables if self._is_table(shape): md_content += self._convert_table_to_markdown(shape.table, **kwargs) # Charts if shape.has_chart: md_content += self._convert_chart_to_markdown(shape.chart) # Text areas elif shape.has_text_frame: text = shape.text or "" if shape == title: if text.strip(): md_content += "# " + text.lstrip() + "\n" else: md_content += text + "\n" # Group Shapes if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP: sorted_shapes = sorted( shape.shapes, key=lambda x: ( float("-inf") if x.top is None else x.top, float("-inf") if x.left is None else x.left, ), ) for subshape in sorted_shapes: get_shape_content(subshape, **kwargs) sorted_shapes = sorted( slide.shapes, key=lambda x: ( float("-inf") if x.top is None else x.top, float("-inf") if x.left is None else x.left, ), ) for shape in sorted_shapes: get_shape_content(shape, **kwargs) md_content = md_content.strip() if slide.has_notes_slide: # PowerPoint attaches a notes slide to a slide whose notes pane # has merely been opened, so having one says nothing about there # being notes to read. Only head a section that has content. notes_frame = slide.notes_slide.notes_text_frame notes_text = (notes_frame.text or "") if notes_frame is not None else "" if notes_text.strip(): md_content += "\n\n### Notes:\n" + notes_text md_content = md_content.strip() return DocumentConverterResult(markdown=md_content.strip()) def _image_to_html( self, image_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any, ) -> Optional[str]: """Override to render an embedded image as an HTML fragment. The stream is borrowed, seekable, and positioned at zero; do not close or retain it. StreamInfo describes the image, not the presentation. Existing conversion options are forwarded through kwargs. Native LLM captions take precedence. Otherwise, return None or blank text to retain the native image representation, or HTML with literal text escaped. The fragment passes through HtmlConverter before being placed at the picture's position in slide/group order. Hook failures propagate through the normal conversion failure path. """ return None def _convert_picture_to_markdown(self, shape, **kwargs): llm_description = "" alt_text = "" image_blob, image_content_type, image_filename = self._get_image_info(shape) image_stream_info = StreamInfo( mimetype=image_content_type, extension=os.path.splitext(image_filename)[1] if image_filename else None, filename=image_filename, ) llm_client = kwargs.get("llm_client") llm_model = kwargs.get("llm_model") if llm_client is not None and llm_model is not None and image_blob is not None: with io.BytesIO(image_blob) as image_stream: try: llm_description = llm_caption( image_stream, image_stream_info, client=llm_client, model=llm_model, prompt=kwargs.get("llm_prompt"), ) except Exception: # Preserve native caption failure fallback. pass if ( (not llm_description or not llm_description.strip()) and image_blob is not None and type(self)._image_to_html is not PptxConverter._image_to_html ): with io.BytesIO(image_blob) as image_stream: fragment = self._image_to_html( image_stream, image_stream_info, **kwargs ) soup = _parse_image_html(fragment) if soup is not None: return ( "\n" + self._html_converter.convert_string(str(soup), **kwargs).markdown + "\n" ) # Keep native caption/alt Markdown separate from custom image HTML. try: alt_text = shape._element._nvXxPr.cNvPr.attrib.get("descr", "") except Exception: pass alt_text = ( "\n".join( text for text in [llm_description, alt_text] if text and text.strip() ) or shape.name ) alt_text = re.sub(r"[\r\n\[\]]", " ", alt_text) alt_text = re.sub(r"\s+", " ", alt_text).strip() if kwargs.get("keep_data_uris", False) and image_blob is not None: content_type = image_content_type or "image/png" b64_string = base64.b64encode(image_blob).decode("utf-8") return f"\n![{alt_text}](data:{content_type};base64,{b64_string})\n" filename = re.sub(r"\W", "", shape.name) + ".jpg" return "\n![" + alt_text + "](" + filename + ")\n" def _find_svg_blip_part(self, shape): """Return the image part referenced by an ````, if any. PowerPoint stores SVG pictures as a blip whose main ``r:embed`` points to a rasterized PNG fallback, plus an ```` extension pointing to the SVG. When there is no raster fallback the ```` has no ``r:embed`` at all, so python-pptx's ``shape.image`` fails. This resolves the SVG part directly from the ``svgBlip`` extension. """ try: nsmap = { "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", } r_embed = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" for svg_blip in shape._element.findall(".//asvg:svgBlip", nsmap): embed_rid = svg_blip.get(r_embed) if not embed_rid: continue return shape.part.related_part(embed_rid) except Exception: pass return None def _get_image_info(self, shape): """Return (blob, content_type, filename) for a picture shape. Handles SVG images that lack a rasterized fallback. In that case ``shape.image`` raises ``ValueError("no embedded image")`` because the ```` has no ``r:embed`` attribute (only an ```` extension). We fall back to resolving the SVG blip directly. """ try: image = shape.image return image.blob, image.content_type, image.filename except Exception: pass # Fall back to an embedded SVG blip (image without a raster fallback) part = self._find_svg_blip_part(shape) if part is not None: try: filename = os.path.basename(getattr(part, "partname", "") or "") or None return part.blob, "image/svg+xml", filename except Exception: pass return None, None, None def _is_picture(self, shape): if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE: return True if shape.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 # AttributeError). try: if shape.image is not None: return True except Exception: # Still a picture if it carries an embedded SVG blip. if self._find_svg_blip_part(shape) is not None: return True return False def _is_table(self, shape): if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.TABLE: return True return False def _convert_table_to_markdown(self, table, **kwargs): # Write the table as HTML, then convert it to Markdown html_table = "" first_row = True for row in table.rows: html_table += "" for cell in row.cells: if first_row: html_table += "" else: html_table += "" html_table += "" first_row = False html_table += "
" + html.escape(cell.text) + "" + html.escape(cell.text) + "
" return ( self._html_converter.convert_string(html_table, **kwargs).markdown.strip() + "\n" ) def _convert_chart_to_markdown(self, chart): try: md = "\n\n### Chart" # ChartTitle.text_frame is documented as destructive -- it creates # a text frame if one isn't already present, so it never returns # None. has_text_frame is the property that actually reflects # whether a text frame exists. if chart.has_title and chart.chart_title.has_text_frame: md += f": {chart.chart_title.text_frame.text}" md += "\n\n" data = [] category_names = [c.label for c in chart.plots[0].categories] series_list = list(chart.series) series_names = [s.name for s in series_list] data.append(["Category"] + series_names) # Materialize each series' values once. Accessing series.values[idx] # inside the nested loop is O(n^2) in python-pptx (each lookup does an # XPath scan of all points), which is extremely slow on large charts. series_values = [list(s.values) for s in series_list] for idx, category in enumerate(category_names): row = [category] for sv in series_values: row.append(sv[idx] if idx < len(sv) else None) data.append(row) markdown_table = [] for row in data: markdown_table.append("| " + " | ".join(map(str, row)) + " |") header = markdown_table[0] separator = "|" + "|".join(["---"] * len(data[0])) + "|" return md + "\n".join([header, separator] + markdown_table[1:]) except ValueError as e: # Handle the specific error for unsupported chart types if "unsupported plot type" in str(e): return "\n\n[unsupported chart]\n\n" except Exception: # Catch any other exceptions that might occur return "\n\n[unsupported chart]\n\n"