From 2564a804f782779783f41de54c46d3a6018f9018 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 10:56:49 +0200 Subject: [PATCH 1/2] 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. From 8285984ee49bbcc3728ceac9916e0b26e75d587b Mon Sep 17 00:00:00 2001 From: L4XB Date: Sat, 17 Oct 2026 12:07:55 +0200 Subject: [PATCH 2/2] fix(pptx): keep a chart title on one heading line A soft line break in a chart title became a newline inside the '### Chart:' heading, which ends the heading and leaves the rest of the title as body text. Collapse it to a space, as for the slide title. --- .../src/markitdown/converters/_pptx_converter.py | 4 +++- packages/markitdown/tests/test_pptx.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/markitdown/src/markitdown/converters/_pptx_converter.py b/packages/markitdown/src/markitdown/converters/_pptx_converter.py index 488d4cac10..6d00dfa6ab 100644 --- a/packages/markitdown/src/markitdown/converters/_pptx_converter.py +++ b/packages/markitdown/src/markitdown/converters/_pptx_converter.py @@ -349,7 +349,9 @@ 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": {_normalize_soft_line_breaks(chart.chart_title.text_frame.text)}" + # A heading is one line, as for the slide title. + title = _normalize_soft_line_breaks(chart.chart_title.text_frame.text) + md += f": {title.replace(chr(10), ' ').strip()}" 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 3fa536ba4d..bd9807a58d 100644 --- a/packages/markitdown/tests/test_pptx.py +++ b/packages/markitdown/tests/test_pptx.py @@ -795,6 +795,20 @@ def test_pptx_soft_line_breaks_are_not_vertical_tabs() -> None: slide.notes_slide.notes_text_frame.text = "NOTE LINE ONE\vNOTE LINE TWO" + chart_data = CategoryChartData() + chart_data.categories = ["Q1", "Q2"] + chart_data.add_series("Sales", (1, 2)) + chart = slide.shapes.add_chart( + XL_CHART_TYPE.COLUMN_CLUSTERED, + Inches(0.5), + Inches(5), + Inches(4), + Inches(2), + chart_data, + ).chart + chart.has_title = True + chart.chart_title.text_frame.text = "CHART LINE ONE\vCHART LINE TWO" + buffer = io.BytesIO() presentation.save(buffer) buffer.seek(0) @@ -809,6 +823,7 @@ def test_pptx_soft_line_breaks_are_not_vertical_tabs() -> None: # 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 "### Chart: CHART LINE ONE CHART LINE TWO" in md assert "| CELL LINE ONE CELL LINE TWO | C2 |" in md assert "| HEADER A HEADER B | H2 |" in md