diff --git a/packages/markitdown/src/markitdown/converters/_pptx_converter.py b/packages/markitdown/src/markitdown/converters/_pptx_converter.py index 76cf7751bc..488d4cac10 100644 --- a/packages/markitdown/src/markitdown/converters/_pptx_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pptx_converter.py @@ -31,6 +31,18 @@ ACCEPTED_FILE_EXTENSIONS = [".pptx"] +def _normalize_soft_line_breaks(text: str) -> str: + """Turn the soft line breaks python-pptx reports into newlines. + + A soft line break (Shift+Enter) is an ```` inside one paragraph, and + python-pptx renders it as a vertical tab. A vertical tab is not Markdown: it + is a C0 control that renderers drop and that nothing downstream treats as + whitespace, so the lines on either side of it run together. The paragraphs + of a shape already arrive joined with newlines, so use the same separator. + """ + return text.replace("\v", "\n") + + class PptxConverter(DocumentConverter): """ Converts PPTX files to Markdown. Supports heading, tables and images with alt text. @@ -105,10 +117,12 @@ def get_shape_content(shape, **kwargs): # Text areas elif shape.has_text_frame: - text = shape.text or "" + text = _normalize_soft_line_breaks(shape.text or "") if shape == title: if text.strip(): - md_content += "# " + text.lstrip() + "\n" + # An ATX heading cannot span lines: a newline would + # close the heading and leave the rest as body text. + md_content += "# " + text.replace("\n", " ").strip() + "\n" else: md_content += text + "\n" @@ -141,7 +155,11 @@ def get_shape_content(shape, **kwargs): # 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 "" + notes_text = ( + _normalize_soft_line_breaks(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() @@ -309,10 +327,11 @@ def _convert_table_to_markdown(self, table, **kwargs): for row in table.rows: html_table += "" for cell in row.cells: + cell_text = html.escape(_normalize_soft_line_breaks(cell.text)) if first_row: - html_table += "" + html.escape(cell.text) + "" + html_table += "" + cell_text + "" else: - html_table += "" + html.escape(cell.text) + "" + html_table += "" + cell_text + "" html_table += "" first_row = False html_table += "" @@ -330,7 +349,7 @@ def _convert_chart_to_markdown(self, chart): # 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 += f": {_normalize_soft_line_breaks(chart.chart_title.text_frame.text)}" md += "\n\n" data = [] category_names = [c.label for c in chart.plots[0].categories] diff --git a/packages/markitdown/tests/test_pptx.py b/packages/markitdown/tests/test_pptx.py index bfbef3e911..3fa536ba4d 100644 --- a/packages/markitdown/tests/test_pptx.py +++ b/packages/markitdown/tests/test_pptx.py @@ -770,6 +770,49 @@ def test_missing_optional_dependencies_fail_before_hook( # Conversion regressions +def test_pptx_soft_line_breaks_are_not_vertical_tabs() -> None: + """A soft line break must not reach the Markdown as a vertical tab. + + Shift+Enter inside a PowerPoint paragraph is an ````, which python-pptx + reports as ``\v``. A vertical tab is a C0 control, not Markdown: a renderer + drops it, so the lines on either side run together, and nothing downstream + treats it as whitespace either. + """ + presentation = Presentation() + slide = presentation.slides.add_slide(presentation.slide_layouts[5]) + slide.shapes.title.text = "TITLE ONE\vTITLE TWO" + + text_box = slide.shapes.add_textbox(Inches(0.5), Inches(2), Inches(4), Inches(1)) + text_box.text_frame.text = "ADDRESS LINE ONE\vADDRESS LINE TWO" + + table = slide.shapes.add_table( + 2, 2, Inches(0.5), Inches(3.5), Inches(6), Inches(1.5) + ).table + table.cell(0, 0).text = "HEADER A\vHEADER B" + table.cell(0, 1).text = "H2" + table.cell(1, 0).text = "CELL LINE ONE\vCELL LINE TWO" + table.cell(1, 1).text = "C2" + + slide.notes_slide.notes_text_frame.text = "NOTE LINE ONE\vNOTE LINE TWO" + + buffer = io.BytesIO() + presentation.save(buffer) + buffer.seek(0) + + md = MarkItDown().convert_stream(buffer, file_extension=".pptx").markdown + + assert "\v" not in md + # A shape's own paragraphs are joined with newlines, so a soft break reads + # the same way. + assert "ADDRESS LINE ONE\nADDRESS LINE TWO" in md + assert "NOTE LINE ONE\nNOTE LINE TWO" in md + # A heading and a table row cannot carry a newline, so those collapse to a + # space and the row stays one row. + assert "# TITLE ONE TITLE TWO" in md + assert "| CELL LINE ONE CELL LINE TWO | C2 |" in md + assert "| HEADER A HEADER B | H2 |" in md + + def test_pptx_chart_multi_series_conversion() -> None: """Charts with multiple series and many categories must convert correctly.