markitdown-ocr 0.1.0__tar.gz → 0.1.1b2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/PKG-INFO +2 -2
  2. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/__about__.py +1 -1
  3. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_docx_converter_with_ocr.py +47 -8
  4. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_pptx_converter_with_ocr.py +5 -1
  5. markitdown_ocr-0.1.1b2/tests/test_docx_converter.py +428 -0
  6. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/test_pdf_converter.py +19 -6
  7. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/test_pptx_converter.py +53 -0
  8. markitdown_ocr-0.1.0/tests/test_docx_converter.py +0 -223
  9. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/.gitignore +0 -0
  10. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/LICENSE +0 -0
  11. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/README.md +0 -0
  12. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/pyproject.toml +0 -0
  13. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/__init__.py +0 -0
  14. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_ocr_service.py +0 -0
  15. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_pdf_converter_with_ocr.py +0 -0
  16. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_plugin.py +0 -0
  17. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/src/markitdown_ocr/_xlsx_converter_with_ocr.py +0 -0
  18. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/__init__.py +0 -0
  19. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_complex_layout.docx +0 -0
  20. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_image_end.docx +0 -0
  21. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_image_middle.docx +0 -0
  22. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_image_start.docx +0 -0
  23. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_multipage.docx +0 -0
  24. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/docx_multiple_images.docx +0 -0
  25. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_complex_layout.pdf +0 -0
  26. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_image_end.pdf +0 -0
  27. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_image_middle.pdf +0 -0
  28. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_image_start.pdf +0 -0
  29. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_multipage.pdf +0 -0
  30. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_multiple_images.pdf +0 -0
  31. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_scanned_invoice.pdf +0 -0
  32. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_scanned_meeting_minutes.pdf +0 -0
  33. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_scanned_minimal.pdf +0 -0
  34. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_scanned_report.pdf +0 -0
  35. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pdf_scanned_sales_report.pdf +0 -0
  36. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pptx_complex_layout.pptx +0 -0
  37. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pptx_image_end.pptx +0 -0
  38. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pptx_image_middle.pptx +0 -0
  39. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pptx_image_start.pptx +0 -0
  40. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/pptx_multiple_images.pptx +0 -0
  41. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/xlsx_complex_layout.xlsx +0 -0
  42. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/xlsx_image_end.xlsx +0 -0
  43. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/xlsx_image_middle.xlsx +0 -0
  44. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/xlsx_image_start.xlsx +0 -0
  45. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/ocr_test_data/xlsx_multiple_images.xlsx +0 -0
  46. {markitdown_ocr-0.1.0 → markitdown_ocr-0.1.1b2}/tests/test_xlsx_converter.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: markitdown-ocr
3
- Version: 0.1.0
3
+ Version: 0.1.1b2
4
4
  Summary: OCR plugin for MarkItDown - Extracts text from images in PDF, DOCX, PPTX, and XLSX via LLM Vision
5
5
  Project-URL: Documentation, https://github.com/microsoft/markitdown#readme
6
6
  Project-URL: Issues, https://github.com/microsoft/markitdown/issues
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Contributors
2
2
  # SPDX-License-Identifier: MIT
3
3
 
4
- __version__ = "0.1.0"
4
+ __version__ = "0.1.1b2"
@@ -29,6 +29,18 @@ except ImportError:
29
29
  # Must be a single token with no special markdown characters.
30
30
  _PLACEHOLDER = "MARKITDOWNOCRBLOCK{}"
31
31
 
32
+ _UNDERLINE_STYLE_MAP = "u => u"
33
+
34
+
35
+ def _read_embedded_style_map(file_stream: BinaryIO) -> Optional[str]:
36
+ """Read the style map embedded in a .docx, if it has one."""
37
+ position = file_stream.tell()
38
+ file_stream.seek(0)
39
+ try:
40
+ return mammoth.read_embedded_style_map(file_stream)
41
+ finally:
42
+ file_stream.seek(position)
43
+
32
44
 
33
45
  class DocxConverterWithOCR(HtmlConverter):
34
46
  """
@@ -82,16 +94,40 @@ class DocxConverterWithOCR(HtmlConverter):
82
94
  kwargs.get("ocr_service") or self.ocr_service
83
95
  )
84
96
 
97
+ # Pre-process once, up front, so that every subsequent read sees the
98
+ # repaired archive. pre_process_docx() also fixes .docx files whose ZIP
99
+ # local file headers disagree with the central directory; reading the
100
+ # original stream first would make those raise, or silently yield no
101
+ # images and drop the OCR output.
102
+ pre_process_stream = pre_process_docx(file_stream)
103
+
104
+ # Read the embedded style map and combine with any provided style map
105
+ caller_style_map = kwargs.get("style_map")
106
+ embedded_style_map = _read_embedded_style_map(pre_process_stream)
107
+
108
+ style_map = "\n".join(
109
+ part
110
+ for part in (
111
+ caller_style_map,
112
+ embedded_style_map,
113
+ _UNDERLINE_STYLE_MAP,
114
+ )
115
+ if part
116
+ )
117
+
85
118
  if ocr_service:
86
119
  # 1. Extract and OCR images — returns raw text per image
87
- file_stream.seek(0)
88
- image_ocr_map = self._extract_and_ocr_images(file_stream, ocr_service)
120
+ pre_process_stream.seek(0)
121
+ image_ocr_map = self._extract_and_ocr_images(
122
+ pre_process_stream, ocr_service
123
+ )
89
124
 
90
125
  # 2. Convert DOCX → HTML via mammoth
91
- file_stream.seek(0)
92
- pre_process_stream = pre_process_docx(file_stream)
126
+ pre_process_stream.seek(0)
93
127
  html_result = mammoth.convert_to_html(
94
- pre_process_stream, style_map=kwargs.get("style_map")
128
+ pre_process_stream,
129
+ style_map=style_map,
130
+ include_embedded_style_map=False,
95
131
  ).value
96
132
 
97
133
  # 3. Replace <img> tags with plain placeholder tokens so that
@@ -116,10 +152,13 @@ class DocxConverterWithOCR(HtmlConverter):
116
152
  return DocumentConverterResult(markdown=md)
117
153
  else:
118
154
  # Standard conversion without OCR
119
- style_map = kwargs.get("style_map", None)
120
- pre_process_stream = pre_process_docx(file_stream)
155
+ pre_process_stream.seek(0)
121
156
  return self._html_converter.convert_string(
122
- mammoth.convert_to_html(pre_process_stream, style_map=style_map).value,
157
+ mammoth.convert_to_html(
158
+ pre_process_stream,
159
+ style_map=style_map,
160
+ include_embedded_style_map=False,
161
+ ).value,
123
162
  **kwargs,
124
163
  )
125
164
 
@@ -222,7 +222,11 @@ class PptxConverterWithOCR(DocumentConverter):
222
222
  def _convert_chart_to_markdown(self, chart):
223
223
  try:
224
224
  md = "\\n\\n### Chart"
225
- if chart.has_title:
225
+ # ChartTitle.text_frame is documented as destructive -- it creates
226
+ # a text frame if one isn't already present, so it never returns
227
+ # None. has_text_frame is the property that actually reflects
228
+ # whether a text frame exists.
229
+ if chart.has_title and chart.chart_title.has_text_frame:
226
230
  md += f": {chart.chart_title.text_frame.text}"
227
231
  md += "\\n\\n"
228
232
  data = []
@@ -0,0 +1,428 @@
1
+ """
2
+ Unit tests for DocxConverterWithOCR.
3
+
4
+ For each DOCX test file: convert with a mock OCR service then compare the
5
+ full output string against the expected snapshot.
6
+
7
+ OCR block format used by the converter:
8
+ *[Image OCR]
9
+ MOCK_OCR_TEXT_12345
10
+ [End OCR]*
11
+ """
12
+
13
+ import io
14
+ import struct
15
+ import sys
16
+ import zipfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pytest
21
+
22
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
23
+
24
+ from markitdown_ocr._ocr_service import OCRResult # noqa: E402
25
+ from markitdown_ocr._docx_converter_with_ocr import ( # noqa: E402
26
+ DocxConverterWithOCR,
27
+ )
28
+ from markitdown import StreamInfo # noqa: E402
29
+
30
+ TEST_DATA_DIR = Path(__file__).parent / "ocr_test_data"
31
+
32
+ _MOCK_TEXT = "MOCK_OCR_TEXT_12345"
33
+
34
+
35
+ class MockOCRService:
36
+ def extract_text( # noqa: ANN101
37
+ self, image_stream: Any, **kwargs: Any
38
+ ) -> OCRResult:
39
+ return OCRResult(text=_MOCK_TEXT, backend_used="mock")
40
+
41
+
42
+ @pytest.fixture(scope="module")
43
+ def svc() -> MockOCRService:
44
+ return MockOCRService()
45
+
46
+
47
+ def _convert(filename: str, ocr_service: MockOCRService) -> str:
48
+ path = TEST_DATA_DIR / filename
49
+ if not path.exists():
50
+ pytest.skip(f"Test file not found: {path}")
51
+ converter = DocxConverterWithOCR()
52
+ with open(path, "rb") as f:
53
+ return converter.convert(
54
+ f, StreamInfo(extension=".docx"), ocr_service=ocr_service
55
+ ).text_content
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # docx_image_start.docx
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def test_docx_image_start(svc: MockOCRService) -> None:
64
+ expected = (
65
+ "Document with Image at Start\n\n"
66
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
67
+ "This is the main content after the header image.\n\n"
68
+ "More text content here."
69
+ )
70
+ assert _convert("docx_image_start.docx", svc) == expected
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # docx_image_middle.docx
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def test_docx_image_middle(svc: MockOCRService) -> None:
79
+ expected = (
80
+ "# Introduction\n\n"
81
+ "This is the introduction section.\n\n"
82
+ "We will see an image below.\n\n"
83
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
84
+ "# Analysis\n\n"
85
+ "This section comes after the image."
86
+ )
87
+ assert _convert("docx_image_middle.docx", svc) == expected
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # docx_image_end.docx
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def test_docx_image_end(svc: MockOCRService) -> None:
96
+ expected = (
97
+ "Report\n\n"
98
+ "Main findings of the report.\n\n"
99
+ "Details and analysis.\n\n"
100
+ "Recommendations.\n\n"
101
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
102
+ )
103
+ assert _convert("docx_image_end.docx", svc) == expected
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # docx_multiple_images.docx
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ def test_docx_multiple_images(svc: MockOCRService) -> None:
112
+ expected = (
113
+ "Multi-Image Document\n\n"
114
+ "First section\n\n"
115
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
116
+ "Second section with another image\n\n"
117
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
118
+ "Conclusion"
119
+ )
120
+ assert _convert("docx_multiple_images.docx", svc) == expected
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # docx_multipage.docx
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def test_docx_multipage(svc: MockOCRService) -> None:
129
+ expected = (
130
+ "# Page 1 - Mixed Content\n\n"
131
+ "This is the first paragraph on page 1.\n\n"
132
+ "BEFORE IMAGE: Important content appears here.\n\n"
133
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
134
+ "AFTER IMAGE: This content follows the image.\n\n"
135
+ "More text on page 1.\n\n"
136
+ "# Page 2 - Image at End\n\n"
137
+ "Content on page 2.\n\n"
138
+ "Multiple paragraphs of text.\n\n"
139
+ "Building up to the image...\n\n"
140
+ "Final paragraph before image.\n\n"
141
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
142
+ "# Page 3 - Image at Start\n\n"
143
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
144
+ "Content that follows the header image.\n\n"
145
+ "AFTER IMAGE: This text is after the image."
146
+ )
147
+ assert _convert("docx_multipage.docx", svc) == expected
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # docx_complex_layout.docx
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def test_docx_complex_layout(svc: MockOCRService) -> None:
156
+ expected = (
157
+ "Complex Document\n\n"
158
+ "| | |\n"
159
+ "| --- | --- |\n"
160
+ "| Feature | Status |\n"
161
+ "| Authentication | Active |\n"
162
+ "| Encryption | Enabled |\n\n"
163
+ "Security notice:\n\n"
164
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
165
+ )
166
+ assert _convert("docx_complex_layout.docx", svc) == expected
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # _inject_placeholders — internal unit tests (no file I/O)
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def test_inject_placeholders_single_image() -> None:
175
+ converter = DocxConverterWithOCR()
176
+ html = "<p>Before</p><img src='x.png'/><p>After</p>"
177
+ result_html, texts = converter._inject_placeholders(html, {"rId1": "TEXT"})
178
+ assert "<img" not in result_html
179
+ assert "MARKITDOWNOCRBLOCK0" in result_html
180
+ assert texts == ["TEXT"]
181
+
182
+
183
+ def test_inject_placeholders_two_images_sequential_tokens() -> None:
184
+ converter = DocxConverterWithOCR()
185
+ html = "<img src='a.png'/><p>Mid</p><img src='b.png'/>"
186
+ result_html, texts = converter._inject_placeholders(
187
+ html, {"rId1": "FIRST", "rId2": "SECOND"}
188
+ )
189
+ assert "MARKITDOWNOCRBLOCK0" in result_html
190
+ assert "MARKITDOWNOCRBLOCK1" in result_html
191
+ assert result_html.index("MARKITDOWNOCRBLOCK0") < result_html.index(
192
+ "MARKITDOWNOCRBLOCK1"
193
+ )
194
+ assert len(texts) == 2
195
+
196
+
197
+ def test_inject_placeholders_no_img_tag_appends_at_end() -> None:
198
+ converter = DocxConverterWithOCR()
199
+ html = "<p>No images</p>"
200
+ result_html, texts = converter._inject_placeholders(html, {"rId1": "ORPHAN"})
201
+ assert "MARKITDOWNOCRBLOCK0" in result_html
202
+ assert texts == ["ORPHAN"]
203
+
204
+
205
+ def test_inject_placeholders_empty_map_leaves_html_unchanged() -> None:
206
+ converter = DocxConverterWithOCR()
207
+ html = "<p>Content</p><img src='pic.jpg'/>"
208
+ result_html, texts = converter._inject_placeholders(html, {})
209
+ assert result_html == html
210
+ assert texts == []
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # No OCR service — no OCR tags emitted
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ def test_docx_no_ocr_service_no_tags() -> None:
219
+ path = TEST_DATA_DIR / "docx_image_middle.docx"
220
+ if not path.exists():
221
+ pytest.skip(f"Test file not found: {path}")
222
+ converter = DocxConverterWithOCR()
223
+ with open(path, "rb") as f:
224
+ md = converter.convert(f, StreamInfo(extension=".docx")).text_content
225
+ assert "*[Image OCR]" not in md
226
+ assert "[End OCR]*" not in md
227
+
228
+
229
+ @pytest.mark.parametrize("double_strike", [False, True])
230
+ @pytest.mark.parametrize("use_ocr", [False, True])
231
+ def test_docx_styles_with_redundant_default_namespace(
232
+ svc: MockOCRService, use_ocr: bool, double_strike: bool
233
+ ) -> None:
234
+ path = TEST_DATA_DIR / "docx_image_middle.docx"
235
+ if not path.exists():
236
+ pytest.skip(f"Test file not found: {path}")
237
+ original = path.read_bytes()
238
+ fixture = io.BytesIO()
239
+ namespace = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
240
+ declaration = f'xmlns:w="{namespace}"'.encode("utf-8")
241
+ with zipfile.ZipFile(io.BytesIO(original)) as source, zipfile.ZipFile(
242
+ fixture, "w"
243
+ ) as target:
244
+ for item in source.infolist():
245
+ content = source.read(item)
246
+ if item.filename == "word/styles.xml":
247
+ if double_strike:
248
+ assert content.count(b"</w:styles>") == 1
249
+ content = content.replace(
250
+ b"</w:styles>",
251
+ b'<w:style w:type="character" w:styleId="DoubleStrike">'
252
+ b'<w:name w:val="Double Strike"/>'
253
+ b'<w:rPr><w:dstrike w:val="1"/></w:rPr>'
254
+ b"</w:style></w:styles>",
255
+ 1,
256
+ )
257
+ assert content.count(declaration) == 1
258
+ content = content.replace(
259
+ declaration, declaration + f' xmlns="{namespace}"'.encode(), 1
260
+ )
261
+ target.writestr(item, content)
262
+
263
+ converter = DocxConverterWithOCR()
264
+ service = svc if use_ocr else None
265
+ expected = converter.convert(
266
+ io.BytesIO(original), StreamInfo(extension=".docx"), ocr_service=service
267
+ ).markdown
268
+ fixture.seek(0)
269
+ actual = converter.convert(
270
+ fixture, StreamInfo(extension=".docx"), ocr_service=service
271
+ ).markdown
272
+
273
+ assert "# Introduction" in actual
274
+ assert actual == expected
275
+ if use_ocr:
276
+ assert _MOCK_TEXT in actual
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Underlined runs survive both the OCR and the non-OCR mammoth paths
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def _underlined_docx(
285
+ tmp_path: Path,
286
+ *,
287
+ paragraph_xml: str = (
288
+ "<w:r><w:t>plain </w:t></w:r>"
289
+ '<w:r><w:rPr><w:u w:val="single"/></w:rPr><w:t>underlined</w:t></w:r>'
290
+ ),
291
+ ) -> Path:
292
+ docx_file = tmp_path / "underlined.docx"
293
+ document_xml = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
294
+ <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
295
+ <w:body>
296
+ <w:p>{paragraph_xml}</w:p>
297
+ </w:body>
298
+ </w:document>"""
299
+
300
+ with zipfile.ZipFile(docx_file, "w") as archive:
301
+ archive.writestr(
302
+ "[Content_Types].xml",
303
+ """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
304
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
305
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
306
+ <Default Extension="xml" ContentType="application/xml"/>
307
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
308
+ </Types>""",
309
+ )
310
+ archive.writestr(
311
+ "_rels/.rels",
312
+ """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
313
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
314
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
315
+ </Relationships>""",
316
+ )
317
+ archive.writestr("word/document.xml", document_xml)
318
+
319
+ return docx_file
320
+
321
+
322
+ def test_docx_underlined_text_is_preserved_without_ocr(tmp_path: Path) -> None:
323
+ converter = DocxConverterWithOCR()
324
+ with open(_underlined_docx(tmp_path), "rb") as f:
325
+ md = converter.convert(f, StreamInfo(extension=".docx")).markdown
326
+ assert "plain <u>underlined</u>" in md
327
+
328
+
329
+ def test_docx_underlined_text_is_preserved_with_ocr(
330
+ tmp_path: Path, svc: MockOCRService
331
+ ) -> None:
332
+ converter = DocxConverterWithOCR()
333
+ with open(_underlined_docx(tmp_path), "rb") as f:
334
+ md = converter.convert(
335
+ f, StreamInfo(extension=".docx"), ocr_service=svc
336
+ ).markdown
337
+ assert "plain <u>underlined</u>" in md
338
+
339
+
340
+ @pytest.mark.parametrize("use_ocr", [False, True])
341
+ @pytest.mark.parametrize(
342
+ ("run_xml", "expected"),
343
+ [
344
+ ('<w:t xml:space="preserve"> </w:t>', "First Last"),
345
+ ("<w:tab/>", "First Last"),
346
+ ("<w:t>&#160;</w:t>", "First\u00a0Last"),
347
+ # Direct conversion keeps the two-space hard break; the dispatcher strips it.
348
+ ("<w:br/>", "First \nLast"),
349
+ ],
350
+ )
351
+ def test_docx_underlined_whitespace_is_preserved(
352
+ tmp_path: Path,
353
+ svc: MockOCRService,
354
+ use_ocr: bool,
355
+ run_xml: str,
356
+ expected: str,
357
+ ) -> None:
358
+ path = _underlined_docx(
359
+ tmp_path,
360
+ paragraph_xml=(
361
+ "<w:r><w:t>First</w:t></w:r>"
362
+ f'<w:r><w:rPr><w:u w:val="single"/></w:rPr>{run_xml}</w:r>'
363
+ "<w:r><w:t>Last</w:t></w:r>"
364
+ ),
365
+ )
366
+ converter = DocxConverterWithOCR()
367
+ with path.open("rb") as stream:
368
+ result = converter.convert(
369
+ stream, StreamInfo(extension=".docx"), ocr_service=svc if use_ocr else None
370
+ )
371
+
372
+ assert result.markdown == expected
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # ZIP local file header casing mismatch
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ def _uppercase_local_header_names(docx_bytes: bytes) -> bytes:
381
+ """Uppercase every local file header filename, leaving the central directory
382
+ untouched, to mimic generators that disagree with themselves about casing."""
383
+ raw = bytearray(docx_bytes)
384
+ offset = patched = 0
385
+ while offset + 30 <= len(raw) and raw[offset : offset + 4] == b"PK\x03\x04":
386
+ fname_len = struct.unpack_from("<H", raw, offset + 26)[0]
387
+ extra_len = struct.unpack_from("<H", raw, offset + 28)[0]
388
+ comp_size = struct.unpack_from("<I", raw, offset + 18)[0]
389
+ name = raw[offset + 30 : offset + 30 + fname_len]
390
+ if name.upper() != name:
391
+ raw[offset + 30 : offset + 30 + fname_len] = name.upper()
392
+ patched += 1
393
+ offset += 30 + fname_len + extra_len + comp_size
394
+ assert patched, "fixture produced no mismatched local file headers"
395
+ return bytes(raw)
396
+
397
+
398
+ def test_docx_zip_filename_casing_mismatch_preserves_ocr(svc: MockOCRService) -> None:
399
+ """OCR output survives a .docx whose local file headers disagree with the
400
+ central directory on casing.
401
+
402
+ The image extraction swallows every exception, so an unrepaired stream used
403
+ to yield an empty OCR map and silently drop the OCR blocks rather than fail.
404
+ """
405
+ path = TEST_DATA_DIR / "docx_image_middle.docx"
406
+ if not path.exists():
407
+ pytest.skip(f"Test file not found: {path}")
408
+
409
+ original = path.read_bytes()
410
+ mismatched = _uppercase_local_header_names(original)
411
+
412
+ # Plain zipfile cannot read the mismatched archive ...
413
+ with pytest.raises(zipfile.BadZipFile):
414
+ with zipfile.ZipFile(io.BytesIO(mismatched), "r") as zf:
415
+ for name in zf.namelist():
416
+ zf.read(name)
417
+
418
+ # ... but the converter produces the same output it does for the original.
419
+ converter = DocxConverterWithOCR()
420
+ expected = converter.convert(
421
+ io.BytesIO(original), StreamInfo(extension=".docx"), ocr_service=svc
422
+ ).markdown
423
+ actual = converter.convert(
424
+ io.BytesIO(mismatched), StreamInfo(extension=".docx"), ocr_service=svc
425
+ ).markdown
426
+
427
+ assert _MOCK_TEXT in actual
428
+ assert actual == expected
@@ -144,17 +144,30 @@ def test_pdf_complex_layout(svc: MockOCRService) -> None:
144
144
 
145
145
 
146
146
  # ---------------------------------------------------------------------------
147
- # pdf_multipage.pdf — pdfplumber/pdfminer fail (EOF); PyMuPDF fallback used
147
+ # pdf_multipage.pdf
148
148
  # ---------------------------------------------------------------------------
149
149
 
150
150
 
151
151
  def test_pdf_multipage(svc: MockOCRService) -> None:
152
- # pdfplumber cannot open this file (Unexpected EOF), so _ocr_full_pages
153
- # falls back to PyMuPDF for page rendering. Each page becomes one OCR block.
154
152
  expected = (
155
- f"## Page 1\n\n\n{_OCR_BLOCK}\n\n\n"
156
- f"## Page 2\n\n\n{_OCR_BLOCK}\n\n\n"
157
- f"## Page 3\n\n\n{_OCR_BLOCK}"
153
+ "## Page 1\n\n\n"
154
+ "Page 1 - Content before image\n\n"
155
+ "This is important text that appears BEFORE the image.\n\n\n\n"
156
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n\n"
157
+ "This text appears AFTER the image on page 1.\n\n"
158
+ "More content follows here.\n\n\n"
159
+ "## Page 2\n\n\n"
160
+ "Page 2 - Content with image at end\n\n"
161
+ "Main content of page 2 starts here.\n\n"
162
+ "This is paragraph 1.\n\n"
163
+ "This is paragraph 2.\n\n"
164
+ "Final paragraph before image.\n\n\n\n"
165
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n\n\n"
166
+ "## Page 3\n\n\n"
167
+ "Page 3 - Image at top\n\n\n\n"
168
+ "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n\n"
169
+ "Content that follows the image.\n\n"
170
+ "This text is AFTER the image."
158
171
  )
159
172
  assert _convert("pdf_multipage.pdf", svc) == expected
160
173
 
@@ -146,3 +146,56 @@ def test_pptx_no_ocr_service_no_tags() -> None:
146
146
  md = converter.convert(f, StreamInfo(extension=".pptx")).text_content
147
147
  assert "*[Image OCR]" not in md
148
148
  assert "[End OCR]*" not in md
149
+
150
+
151
+ def test_pptx_ocr_chart_no_title_text_frame() -> None:
152
+ from unittest.mock import MagicMock
153
+ from markitdown_ocr._pptx_converter_with_ocr import PptxConverterWithOCR
154
+
155
+ # python-pptx's ChartTitle.text_frame is destructive -- it creates a text
156
+ # frame if one isn't already present, so it never returns None.
157
+ # has_text_frame is the property that actually reflects presence/absence.
158
+ mock_chart = MagicMock()
159
+ mock_chart.has_title = True
160
+ mock_chart.chart_title.has_text_frame = False
161
+
162
+ mock_category = MagicMock()
163
+ mock_category.label = "Cat 1"
164
+ mock_chart.plots = [MagicMock(categories=[mock_category])]
165
+
166
+ mock_series = MagicMock()
167
+ mock_series.name = "Series 1"
168
+ mock_series.values = [10.0]
169
+ mock_chart.series = [mock_series]
170
+
171
+ converter = PptxConverterWithOCR()
172
+ result = converter._convert_chart_to_markdown(mock_chart)
173
+
174
+ assert "### Chart" in result
175
+ assert "Cat 1" in result
176
+ assert "Series 1" in result
177
+ assert ":" not in result
178
+
179
+
180
+ def test_pptx_ocr_chart_with_title_text_frame() -> None:
181
+ from unittest.mock import MagicMock
182
+ from markitdown_ocr._pptx_converter_with_ocr import PptxConverterWithOCR
183
+
184
+ mock_chart = MagicMock()
185
+ mock_chart.has_title = True
186
+ mock_chart.chart_title.has_text_frame = True
187
+ mock_chart.chart_title.text_frame.text = "Revenue"
188
+
189
+ mock_category = MagicMock()
190
+ mock_category.label = "Cat 1"
191
+ mock_chart.plots = [MagicMock(categories=[mock_category])]
192
+
193
+ mock_series = MagicMock()
194
+ mock_series.name = "Series 1"
195
+ mock_series.values = [10.0]
196
+ mock_chart.series = [mock_series]
197
+
198
+ converter = PptxConverterWithOCR()
199
+ result = converter._convert_chart_to_markdown(mock_chart)
200
+
201
+ assert "### Chart: Revenue" in result
@@ -1,223 +0,0 @@
1
- """
2
- Unit tests for DocxConverterWithOCR.
3
-
4
- For each DOCX test file: convert with a mock OCR service then compare the
5
- full output string against the expected snapshot.
6
-
7
- OCR block format used by the converter:
8
- *[Image OCR]
9
- MOCK_OCR_TEXT_12345
10
- [End OCR]*
11
- """
12
-
13
- import sys
14
- from pathlib import Path
15
- from typing import Any
16
-
17
- import pytest
18
-
19
- sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
20
-
21
- from markitdown_ocr._ocr_service import OCRResult # noqa: E402
22
- from markitdown_ocr._docx_converter_with_ocr import ( # noqa: E402
23
- DocxConverterWithOCR,
24
- )
25
- from markitdown import StreamInfo # noqa: E402
26
-
27
- TEST_DATA_DIR = Path(__file__).parent / "ocr_test_data"
28
-
29
- _MOCK_TEXT = "MOCK_OCR_TEXT_12345"
30
-
31
-
32
- class MockOCRService:
33
- def extract_text( # noqa: ANN101
34
- self, image_stream: Any, **kwargs: Any
35
- ) -> OCRResult:
36
- return OCRResult(text=_MOCK_TEXT, backend_used="mock")
37
-
38
-
39
- @pytest.fixture(scope="module")
40
- def svc() -> MockOCRService:
41
- return MockOCRService()
42
-
43
-
44
- def _convert(filename: str, ocr_service: MockOCRService) -> str:
45
- path = TEST_DATA_DIR / filename
46
- if not path.exists():
47
- pytest.skip(f"Test file not found: {path}")
48
- converter = DocxConverterWithOCR()
49
- with open(path, "rb") as f:
50
- return converter.convert(
51
- f, StreamInfo(extension=".docx"), ocr_service=ocr_service
52
- ).text_content
53
-
54
-
55
- # ---------------------------------------------------------------------------
56
- # docx_image_start.docx
57
- # ---------------------------------------------------------------------------
58
-
59
-
60
- def test_docx_image_start(svc: MockOCRService) -> None:
61
- expected = (
62
- "Document with Image at Start\n\n"
63
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
64
- "This is the main content after the header image.\n\n"
65
- "More text content here."
66
- )
67
- assert _convert("docx_image_start.docx", svc) == expected
68
-
69
-
70
- # ---------------------------------------------------------------------------
71
- # docx_image_middle.docx
72
- # ---------------------------------------------------------------------------
73
-
74
-
75
- def test_docx_image_middle(svc: MockOCRService) -> None:
76
- expected = (
77
- "# Introduction\n\n"
78
- "This is the introduction section.\n\n"
79
- "We will see an image below.\n\n"
80
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
81
- "# Analysis\n\n"
82
- "This section comes after the image."
83
- )
84
- assert _convert("docx_image_middle.docx", svc) == expected
85
-
86
-
87
- # ---------------------------------------------------------------------------
88
- # docx_image_end.docx
89
- # ---------------------------------------------------------------------------
90
-
91
-
92
- def test_docx_image_end(svc: MockOCRService) -> None:
93
- expected = (
94
- "Report\n\n"
95
- "Main findings of the report.\n\n"
96
- "Details and analysis.\n\n"
97
- "Recommendations.\n\n"
98
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
99
- )
100
- assert _convert("docx_image_end.docx", svc) == expected
101
-
102
-
103
- # ---------------------------------------------------------------------------
104
- # docx_multiple_images.docx
105
- # ---------------------------------------------------------------------------
106
-
107
-
108
- def test_docx_multiple_images(svc: MockOCRService) -> None:
109
- expected = (
110
- "Multi-Image Document\n\n"
111
- "First section\n\n"
112
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
113
- "Second section with another image\n\n"
114
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
115
- "Conclusion"
116
- )
117
- assert _convert("docx_multiple_images.docx", svc) == expected
118
-
119
-
120
- # ---------------------------------------------------------------------------
121
- # docx_multipage.docx
122
- # ---------------------------------------------------------------------------
123
-
124
-
125
- def test_docx_multipage(svc: MockOCRService) -> None:
126
- expected = (
127
- "# Page 1 - Mixed Content\n\n"
128
- "This is the first paragraph on page 1.\n\n"
129
- "BEFORE IMAGE: Important content appears here.\n\n"
130
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
131
- "AFTER IMAGE: This content follows the image.\n\n"
132
- "More text on page 1.\n\n"
133
- "# Page 2 - Image at End\n\n"
134
- "Content on page 2.\n\n"
135
- "Multiple paragraphs of text.\n\n"
136
- "Building up to the image...\n\n"
137
- "Final paragraph before image.\n\n"
138
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
139
- "# Page 3 - Image at Start\n\n"
140
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*\n\n"
141
- "Content that follows the header image.\n\n"
142
- "AFTER IMAGE: This text is after the image."
143
- )
144
- assert _convert("docx_multipage.docx", svc) == expected
145
-
146
-
147
- # ---------------------------------------------------------------------------
148
- # docx_complex_layout.docx
149
- # ---------------------------------------------------------------------------
150
-
151
-
152
- def test_docx_complex_layout(svc: MockOCRService) -> None:
153
- expected = (
154
- "Complex Document\n\n"
155
- "| | |\n"
156
- "| --- | --- |\n"
157
- "| Feature | Status |\n"
158
- "| Authentication | Active |\n"
159
- "| Encryption | Enabled |\n\n"
160
- "Security notice:\n\n"
161
- "*[Image OCR]\nMOCK_OCR_TEXT_12345\n[End OCR]*"
162
- )
163
- assert _convert("docx_complex_layout.docx", svc) == expected
164
-
165
-
166
- # ---------------------------------------------------------------------------
167
- # _inject_placeholders — internal unit tests (no file I/O)
168
- # ---------------------------------------------------------------------------
169
-
170
-
171
- def test_inject_placeholders_single_image() -> None:
172
- converter = DocxConverterWithOCR()
173
- html = "<p>Before</p><img src='x.png'/><p>After</p>"
174
- result_html, texts = converter._inject_placeholders(html, {"rId1": "TEXT"})
175
- assert "<img" not in result_html
176
- assert "MARKITDOWNOCRBLOCK0" in result_html
177
- assert texts == ["TEXT"]
178
-
179
-
180
- def test_inject_placeholders_two_images_sequential_tokens() -> None:
181
- converter = DocxConverterWithOCR()
182
- html = "<img src='a.png'/><p>Mid</p><img src='b.png'/>"
183
- result_html, texts = converter._inject_placeholders(
184
- html, {"rId1": "FIRST", "rId2": "SECOND"}
185
- )
186
- assert "MARKITDOWNOCRBLOCK0" in result_html
187
- assert "MARKITDOWNOCRBLOCK1" in result_html
188
- assert result_html.index("MARKITDOWNOCRBLOCK0") < result_html.index(
189
- "MARKITDOWNOCRBLOCK1"
190
- )
191
- assert len(texts) == 2
192
-
193
-
194
- def test_inject_placeholders_no_img_tag_appends_at_end() -> None:
195
- converter = DocxConverterWithOCR()
196
- html = "<p>No images</p>"
197
- result_html, texts = converter._inject_placeholders(html, {"rId1": "ORPHAN"})
198
- assert "MARKITDOWNOCRBLOCK0" in result_html
199
- assert texts == ["ORPHAN"]
200
-
201
-
202
- def test_inject_placeholders_empty_map_leaves_html_unchanged() -> None:
203
- converter = DocxConverterWithOCR()
204
- html = "<p>Content</p><img src='pic.jpg'/>"
205
- result_html, texts = converter._inject_placeholders(html, {})
206
- assert result_html == html
207
- assert texts == []
208
-
209
-
210
- # ---------------------------------------------------------------------------
211
- # No OCR service — no OCR tags emitted
212
- # ---------------------------------------------------------------------------
213
-
214
-
215
- def test_docx_no_ocr_service_no_tags() -> None:
216
- path = TEST_DATA_DIR / "docx_image_middle.docx"
217
- if not path.exists():
218
- pytest.skip(f"Test file not found: {path}")
219
- converter = DocxConverterWithOCR()
220
- with open(path, "rb") as f:
221
- md = converter.convert(f, StreamInfo(extension=".docx")).text_content
222
- assert "*[Image OCR]" not in md
223
- assert "[End OCR]*" not in md
File without changes