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
31 changes: 25 additions & 6 deletions packages/markitdown/src/markitdown/converters/_pptx_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<a:br/>`` 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.
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -309,10 +327,11 @@ def _convert_table_to_markdown(self, table, **kwargs):
for row in table.rows:
html_table += "<tr>"
for cell in row.cells:
cell_text = html.escape(_normalize_soft_line_breaks(cell.text))
if first_row:
html_table += "<th>" + html.escape(cell.text) + "</th>"
html_table += "<th>" + cell_text + "</th>"
else:
html_table += "<td>" + html.escape(cell.text) + "</td>"
html_table += "<td>" + cell_text + "</td>"
html_table += "</tr>"
first_row = False
html_table += "</table></body></html>"
Expand All @@ -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]
Expand Down
43 changes: 43 additions & 0 deletions packages/markitdown/tests/test_pptx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<a:br/>``, 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.

Expand Down