From 2564a804f782779783f41de54c46d3a6018f9018 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 10:56:49 +0200 Subject: [PATCH] fix(pptx): convert soft line breaks instead of emitting a vertical tab Shift+Enter inside a PowerPoint paragraph is an ``, and python-pptx reports it as `\v`. The converter passes `shape.text`, `cell.text`, the notes text and the chart title straight through, so that vertical tab reaches the Markdown. It is a C0 control, not Markdown: renderers drop it, so the two lines run together, and nothing downstream treats it as whitespace either. Normalize it where the text enters. A shape's own paragraphs already arrive joined with newlines, so a soft break reads the same way. A table cell keeps its row: the newline goes into the HTML the table is built from, where it is ordinary whitespace and collapses to a space. A slide title is a special case: an ATX heading cannot span lines, so a newline there would close the heading and leave the rest as body text. Titles now collapse their line breaks to spaces, which also fixes the multi-paragraph titles that already produced a stray body line. --- .../markitdown/converters/_pptx_converter.py | 31 ++++++++++--- packages/markitdown/tests/test_module_misc.py | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_pptx_converter.py b/packages/markitdown/src/markitdown/converters/_pptx_converter.py index 9b1c64366b..c69d59e1ee 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. @@ -178,10 +190,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" @@ -214,7 +228,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() @@ -298,10 +316,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 += "" @@ -319,7 +338,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_module_misc.py b/packages/markitdown/tests/test_module_misc.py index 434cd3c5bf..539991404f 100644 --- a/packages/markitdown/tests/test_module_misc.py +++ b/packages/markitdown/tests/test_module_misc.py @@ -895,6 +895,52 @@ def test_get_content_disposition_filename_decodes_rfc5987() -> None: ) +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. + """ + pptx = pytest.importorskip("pptx") + from pptx.util import Inches + + presentation = pptx.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.