Skip to content

Commit fd6aa68

Browse files
committed
feat: extend page extraction to PPTX and DOCX converters
- Add slide-level extraction for PPTX files with extract_pages parameter - Each slide is treated as a PageInfo object with sequential numbering - Add extract_pages parameter to DOCX for API consistency (returns None due to dynamic pagination) - Import PageInfo class in both converters to support the new functionality - Add comprehensive test suites for both formats ensuring backward compatibility - Maintain 100% backward compatibility with existing API
1 parent dbb811c commit fd6aa68

4 files changed

Lines changed: 296 additions & 20 deletions

File tree

packages/markitdown/src/markitdown/converters/_docx_converter.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ._html_converter import HtmlConverter
88
from ..converter_utils.docx.pre_process import pre_process_docx
9-
from .._base_converter import DocumentConverterResult
9+
from .._base_converter import DocumentConverterResult, PageInfo
1010
from .._stream_info import StreamInfo
1111
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
1212

@@ -75,9 +75,22 @@ def convert(
7575
_dependency_exc_info[2]
7676
)
7777

78+
# Check if page extraction is requested
79+
extract_pages = kwargs.get("extract_pages", False)
80+
7881
style_map = kwargs.get("style_map", None)
7982
pre_process_stream = pre_process_docx(file_stream)
80-
return self._html_converter.convert_string(
81-
mammoth.convert_to_html(pre_process_stream, style_map=style_map).value,
82-
**kwargs,
83-
)
83+
84+
# Convert to HTML
85+
html_result = mammoth.convert_to_html(pre_process_stream, style_map=style_map)
86+
87+
# Convert HTML to markdown
88+
result = self._html_converter.convert_string(html_result.value, **kwargs)
89+
90+
# Note: DOCX files don't have fixed pages like PDFs.
91+
# Page breaks depend on rendering settings (margins, font size, etc.)
92+
# For now, we'll return None for pages even if extract_pages is True
93+
# This maintains API compatibility while acknowledging the limitation
94+
pages = None
95+
96+
return DocumentConverterResult(markdown=result.markdown, title=result.title, pages=pages)

packages/markitdown/src/markitdown/converters/_pptx_converter.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ._html_converter import HtmlConverter
1212
from ._llm_caption import llm_caption
13-
from .._base_converter import DocumentConverter, DocumentConverterResult
13+
from .._base_converter import DocumentConverter, DocumentConverterResult, PageInfo
1414
from .._stream_info import StreamInfo
1515
from .._exceptions import MissingDependencyException, MISSING_DEPENDENCY_MESSAGE
1616

@@ -78,19 +78,23 @@ def convert(
7878
_dependency_exc_info[2]
7979
)
8080

81+
# Check if page extraction is requested
82+
extract_pages = kwargs.get("extract_pages", False)
83+
8184
# Perform the conversion
8285
presentation = pptx.Presentation(file_stream)
8386
md_content = ""
87+
pages = [] if extract_pages else None
8488
slide_num = 0
8589
for slide in presentation.slides:
8690
slide_num += 1
8791

88-
md_content += f"\n\n<!-- Slide number: {slide_num} -->\n"
92+
slide_content = f"\n\n<!-- Slide number: {slide_num} -->\n"
8993

9094
title = slide.shapes.title
9195

9296
def get_shape_content(shape, **kwargs):
93-
nonlocal md_content
97+
nonlocal slide_content
9498
# Pictures
9599
if self._is_picture(shape):
96100
# https://github.com/scanny/python-pptx/pull/512#issuecomment-1713100069
@@ -145,26 +149,26 @@ def get_shape_content(shape, **kwargs):
145149
blob = shape.image.blob
146150
content_type = shape.image.content_type or "image/png"
147151
b64_string = base64.b64encode(blob).decode("utf-8")
148-
md_content += f"\n![{alt_text}](data:{content_type};base64,{b64_string})\n"
152+
slide_content += f"\n![{alt_text}](data:{content_type};base64,{b64_string})\n"
149153
else:
150154
# A placeholder name
151155
filename = re.sub(r"\W", "", shape.name) + ".jpg"
152-
md_content += "\n![" + alt_text + "](" + filename + ")\n"
156+
slide_content += "\n![" + alt_text + "](" + filename + ")\n"
153157

154158
# Tables
155159
if self._is_table(shape):
156-
md_content += self._convert_table_to_markdown(shape.table, **kwargs)
160+
slide_content += self._convert_table_to_markdown(shape.table, **kwargs)
157161

158162
# Charts
159163
if shape.has_chart:
160-
md_content += self._convert_chart_to_markdown(shape.chart)
164+
slide_content += self._convert_chart_to_markdown(shape.chart)
161165

162166
# Text areas
163167
elif shape.has_text_frame:
164168
if shape == title:
165-
md_content += "# " + shape.text.lstrip() + "\n"
169+
slide_content += "# " + shape.text.lstrip() + "\n"
166170
else:
167-
md_content += shape.text + "\n"
171+
slide_content += shape.text + "\n"
168172

169173
# Group Shapes
170174
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP:
@@ -188,16 +192,23 @@ def get_shape_content(shape, **kwargs):
188192
for shape in sorted_shapes:
189193
get_shape_content(shape, **kwargs)
190194

191-
md_content = md_content.strip()
195+
slide_content = slide_content.strip()
192196

193197
if slide.has_notes_slide:
194-
md_content += "\n\n### Notes:\n"
198+
slide_content += "\n\n### Notes:\n"
195199
notes_frame = slide.notes_slide.notes_text_frame
196200
if notes_frame is not None:
197-
md_content += notes_frame.text
198-
md_content = md_content.strip()
199-
200-
return DocumentConverterResult(markdown=md_content.strip())
201+
slide_content += notes_frame.text
202+
slide_content = slide_content.strip()
203+
204+
# Add to overall content
205+
md_content += slide_content
206+
207+
# If extracting pages, add to pages list
208+
if extract_pages:
209+
pages.append(PageInfo(page_number=slide_num, content=slide_content.strip()))
210+
211+
return DocumentConverterResult(markdown=md_content.strip(), pages=pages)
201212

202213
def _is_picture(self, shape):
203214
if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE:
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env python3 -m pytest
2+
"""
3+
Unit tests for DOCX page extraction functionality.
4+
"""
5+
6+
import os
7+
import tempfile
8+
import pytest
9+
from typing import Optional
10+
11+
from markitdown import MarkItDown, PageInfo, DocumentConverterResult
12+
13+
14+
class TestDocxPageExtraction:
15+
"""Test cases for DOCX page extraction functionality."""
16+
17+
@pytest.fixture(autouse=True)
18+
def setup(self):
19+
"""Set up test fixtures."""
20+
self.markitdown = MarkItDown()
21+
self.test_docx_path = os.path.join(
22+
os.path.dirname(__file__),
23+
'test_files',
24+
'test.docx'
25+
)
26+
27+
def test_traditional_docx_conversion(self):
28+
"""Test that traditional DOCX conversion works unchanged."""
29+
if not os.path.exists(self.test_docx_path):
30+
pytest.skip("Test DOCX file not found")
31+
32+
result = self.markitdown.convert(self.test_docx_path)
33+
34+
# Verify result structure
35+
assert isinstance(result, DocumentConverterResult)
36+
assert isinstance(result.markdown, str)
37+
assert len(result.markdown) > 0
38+
assert result.pages is None # Should be None by default
39+
40+
# Verify backward compatibility
41+
assert hasattr(result, 'text_content')
42+
assert result.text_content == result.markdown
43+
assert str(result) == result.markdown
44+
45+
def test_docx_page_extraction_enabled(self):
46+
"""Test DOCX conversion with page extraction enabled.
47+
48+
Note: DOCX files don't have fixed pages like PDFs.
49+
Page breaks depend on rendering settings. Currently,
50+
this returns None for pages even with extract_pages=True.
51+
"""
52+
if not os.path.exists(self.test_docx_path):
53+
pytest.skip("Test DOCX file not found")
54+
55+
result = self.markitdown.convert(self.test_docx_path, extract_pages=True)
56+
57+
# Verify result structure
58+
assert isinstance(result, DocumentConverterResult)
59+
assert isinstance(result.markdown, str)
60+
assert len(result.markdown) > 0
61+
62+
# Currently, DOCX doesn't support page extraction due to dynamic pagination
63+
assert result.pages is None
64+
65+
def test_docx_page_extraction_disabled(self):
66+
"""Test DOCX conversion with page extraction explicitly disabled."""
67+
if not os.path.exists(self.test_docx_path):
68+
pytest.skip("Test DOCX file not found")
69+
70+
result = self.markitdown.convert(self.test_docx_path, extract_pages=False)
71+
72+
# Should behave the same as default
73+
assert isinstance(result, DocumentConverterResult)
74+
assert isinstance(result.markdown, str)
75+
assert len(result.markdown) > 0
76+
assert result.pages is None
77+
78+
def test_backward_compatibility(self):
79+
"""Test that all existing functionality remains intact."""
80+
if not os.path.exists(self.test_docx_path):
81+
pytest.skip("Test DOCX file not found")
82+
83+
# Test different ways of calling convert
84+
result1 = self.markitdown.convert(self.test_docx_path)
85+
result2 = self.markitdown.convert(self.test_docx_path, extract_pages=False)
86+
result3 = self.markitdown.convert(self.test_docx_path, extract_pages=True)
87+
88+
# Results should be equivalent for markdown content
89+
assert result1.markdown == result2.markdown
90+
assert result1.markdown == result3.markdown
91+
assert result1.title == result2.title
92+
assert result1.title == result3.title
93+
94+
# All should have None for pages (DOCX limitation)
95+
assert result1.pages is None
96+
assert result2.pages is None
97+
assert result3.pages is None
98+
99+
# All should work with string conversion
100+
assert str(result1) == str(result2)
101+
assert str(result1) == str(result3)
102+
103+
# All should work with text_content property
104+
assert result1.text_content == result2.text_content
105+
assert result1.text_content == result3.text_content
106+
107+
def test_extract_pages_parameter_types(self):
108+
"""Test different types for extract_pages parameter."""
109+
if not os.path.exists(self.test_docx_path):
110+
pytest.skip("Test DOCX file not found")
111+
112+
# Test with different truthy/falsy values
113+
result_false = self.markitdown.convert(self.test_docx_path, extract_pages=False)
114+
result_true = self.markitdown.convert(self.test_docx_path, extract_pages=True)
115+
result_none = self.markitdown.convert(self.test_docx_path, extract_pages=None)
116+
result_zero = self.markitdown.convert(self.test_docx_path, extract_pages=0)
117+
result_one = self.markitdown.convert(self.test_docx_path, extract_pages=1)
118+
119+
# All should return None for pages (DOCX limitation)
120+
assert result_false.pages is None
121+
assert result_true.pages is None
122+
assert result_none.pages is None
123+
assert result_zero.pages is None
124+
assert result_one.pages is None
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3 -m pytest
2+
"""
3+
Unit tests for PPTX slide extraction functionality.
4+
"""
5+
6+
import os
7+
import tempfile
8+
import pytest
9+
from typing import Optional
10+
11+
from markitdown import MarkItDown, PageInfo, DocumentConverterResult
12+
13+
14+
class TestPptxPageExtraction:
15+
"""Test cases for PPTX slide extraction functionality."""
16+
17+
@pytest.fixture(autouse=True)
18+
def setup(self):
19+
"""Set up test fixtures."""
20+
self.markitdown = MarkItDown()
21+
self.test_pptx_path = os.path.join(
22+
os.path.dirname(__file__),
23+
'test_files',
24+
'test.pptx'
25+
)
26+
27+
def test_traditional_pptx_conversion(self):
28+
"""Test that traditional PPTX conversion works unchanged."""
29+
if not os.path.exists(self.test_pptx_path):
30+
pytest.skip("Test PPTX file not found")
31+
32+
result = self.markitdown.convert(self.test_pptx_path)
33+
34+
# Verify result structure
35+
assert isinstance(result, DocumentConverterResult)
36+
assert isinstance(result.markdown, str)
37+
assert len(result.markdown) > 0
38+
assert result.pages is None # Should be None by default
39+
40+
# Verify backward compatibility
41+
assert hasattr(result, 'text_content')
42+
assert result.text_content == result.markdown
43+
assert str(result) == result.markdown
44+
45+
def test_pptx_page_extraction_enabled(self):
46+
"""Test PPTX conversion with slide extraction enabled."""
47+
if not os.path.exists(self.test_pptx_path):
48+
pytest.skip("Test PPTX file not found")
49+
50+
result = self.markitdown.convert(self.test_pptx_path, extract_pages=True)
51+
52+
# Verify result structure
53+
assert isinstance(result, DocumentConverterResult)
54+
assert isinstance(result.markdown, str)
55+
assert len(result.markdown) > 0
56+
assert result.pages is not None
57+
assert isinstance(result.pages, list)
58+
assert len(result.pages) > 0
59+
60+
# Verify page structure
61+
for page in result.pages:
62+
assert isinstance(page, PageInfo)
63+
assert isinstance(page.page_number, int)
64+
assert page.page_number > 0
65+
assert isinstance(page.content, str)
66+
# Slides can be empty, so we don't check content length
67+
68+
# Verify page numbers are sequential
69+
page_numbers = [page.page_number for page in result.pages]
70+
assert page_numbers == list(range(1, len(result.pages) + 1))
71+
72+
# Verify each slide has slide number comment
73+
for page in result.pages:
74+
assert f"<!-- Slide number: {page.page_number} -->" in page.content
75+
76+
def test_pptx_page_extraction_disabled(self):
77+
"""Test PPTX conversion with slide extraction explicitly disabled."""
78+
if not os.path.exists(self.test_pptx_path):
79+
pytest.skip("Test PPTX file not found")
80+
81+
result = self.markitdown.convert(self.test_pptx_path, extract_pages=False)
82+
83+
# Should behave the same as default
84+
assert isinstance(result, DocumentConverterResult)
85+
assert isinstance(result.markdown, str)
86+
assert len(result.markdown) > 0
87+
assert result.pages is None
88+
89+
def test_backward_compatibility(self):
90+
"""Test that all existing functionality remains intact."""
91+
if not os.path.exists(self.test_pptx_path):
92+
pytest.skip("Test PPTX file not found")
93+
94+
# Test different ways of calling convert
95+
result1 = self.markitdown.convert(self.test_pptx_path)
96+
result2 = self.markitdown.convert(self.test_pptx_path, extract_pages=False)
97+
98+
# Results should be equivalent
99+
assert result1.markdown == result2.markdown
100+
assert result1.title == result2.title
101+
assert result1.pages == result2.pages
102+
103+
# Both should work with string conversion
104+
assert str(result1) == str(result2)
105+
106+
# Both should work with text_content property
107+
assert result1.text_content == result2.text_content
108+
109+
def test_extract_pages_parameter_types(self):
110+
"""Test different types for extract_pages parameter."""
111+
if not os.path.exists(self.test_pptx_path):
112+
pytest.skip("Test PPTX file not found")
113+
114+
# Test with different truthy/falsy values
115+
result_false = self.markitdown.convert(self.test_pptx_path, extract_pages=False)
116+
result_true = self.markitdown.convert(self.test_pptx_path, extract_pages=True)
117+
result_none = self.markitdown.convert(self.test_pptx_path, extract_pages=None)
118+
result_zero = self.markitdown.convert(self.test_pptx_path, extract_pages=0)
119+
result_one = self.markitdown.convert(self.test_pptx_path, extract_pages=1)
120+
121+
# False, None, 0 should not extract pages
122+
assert result_false.pages is None
123+
assert result_none.pages is None
124+
assert result_zero.pages is None
125+
126+
# True, 1 should extract pages
127+
assert result_true.pages is not None
128+
assert result_one.pages is not None

0 commit comments

Comments
 (0)