markitdown-ocr 0.1.0__py3-none-any.whl
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.
- markitdown_ocr/__about__.py +4 -0
- markitdown_ocr/__init__.py +31 -0
- markitdown_ocr/_docx_converter_with_ocr.py +189 -0
- markitdown_ocr/_ocr_service.py +110 -0
- markitdown_ocr/_pdf_converter_with_ocr.py +422 -0
- markitdown_ocr/_plugin.py +68 -0
- markitdown_ocr/_pptx_converter_with_ocr.py +249 -0
- markitdown_ocr/_xlsx_converter_with_ocr.py +225 -0
- markitdown_ocr-0.1.0.dist-info/METADATA +233 -0
- markitdown_ocr-0.1.0.dist-info/RECORD +13 -0
- markitdown_ocr-0.1.0.dist-info/WHEEL +4 -0
- markitdown_ocr-0.1.0.dist-info/entry_points.txt +2 -0
- markitdown_ocr-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Contributors
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
markitdown-ocr: OCR plugin for MarkItDown
|
|
6
|
+
|
|
7
|
+
Adds LLM Vision-based text extraction from images embedded in PDF, DOCX, PPTX, and XLSX files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._plugin import __plugin_interface_version__, register_converters
|
|
11
|
+
from .__about__ import __version__
|
|
12
|
+
from ._ocr_service import (
|
|
13
|
+
OCRResult,
|
|
14
|
+
LLMVisionOCRService,
|
|
15
|
+
)
|
|
16
|
+
from ._pdf_converter_with_ocr import PdfConverterWithOCR
|
|
17
|
+
from ._docx_converter_with_ocr import DocxConverterWithOCR
|
|
18
|
+
from ._pptx_converter_with_ocr import PptxConverterWithOCR
|
|
19
|
+
from ._xlsx_converter_with_ocr import XlsxConverterWithOCR
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"__version__",
|
|
23
|
+
"__plugin_interface_version__",
|
|
24
|
+
"register_converters",
|
|
25
|
+
"OCRResult",
|
|
26
|
+
"LLMVisionOCRService",
|
|
27
|
+
"PdfConverterWithOCR",
|
|
28
|
+
"DocxConverterWithOCR",
|
|
29
|
+
"PptxConverterWithOCR",
|
|
30
|
+
"XlsxConverterWithOCR",
|
|
31
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced DOCX Converter with OCR support for embedded images.
|
|
3
|
+
Extracts images from Word documents and performs OCR while maintaining context.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, BinaryIO, Optional
|
|
10
|
+
|
|
11
|
+
from markitdown.converters import HtmlConverter
|
|
12
|
+
from markitdown.converter_utils.docx.pre_process import pre_process_docx
|
|
13
|
+
from markitdown import DocumentConverterResult, StreamInfo
|
|
14
|
+
from markitdown._exceptions import (
|
|
15
|
+
MissingDependencyException,
|
|
16
|
+
MISSING_DEPENDENCY_MESSAGE,
|
|
17
|
+
)
|
|
18
|
+
from ._ocr_service import LLMVisionOCRService
|
|
19
|
+
|
|
20
|
+
# Try loading dependencies
|
|
21
|
+
_dependency_exc_info = None
|
|
22
|
+
try:
|
|
23
|
+
import mammoth
|
|
24
|
+
from docx import Document
|
|
25
|
+
except ImportError:
|
|
26
|
+
_dependency_exc_info = sys.exc_info()
|
|
27
|
+
|
|
28
|
+
# Placeholder injected into HTML so that mammoth never sees the OCR markers.
|
|
29
|
+
# Must be a single token with no special markdown characters.
|
|
30
|
+
_PLACEHOLDER = "MARKITDOWNOCRBLOCK{}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DocxConverterWithOCR(HtmlConverter):
|
|
34
|
+
"""
|
|
35
|
+
Enhanced DOCX Converter with OCR support for embedded images.
|
|
36
|
+
Maintains document flow while extracting text from images inline.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, ocr_service: Optional[LLMVisionOCRService] = None):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._html_converter = HtmlConverter()
|
|
42
|
+
self.ocr_service = ocr_service
|
|
43
|
+
|
|
44
|
+
def accepts(
|
|
45
|
+
self,
|
|
46
|
+
file_stream: BinaryIO,
|
|
47
|
+
stream_info: StreamInfo,
|
|
48
|
+
**kwargs: Any,
|
|
49
|
+
) -> bool:
|
|
50
|
+
mimetype = (stream_info.mimetype or "").lower()
|
|
51
|
+
extension = (stream_info.extension or "").lower()
|
|
52
|
+
|
|
53
|
+
if extension == ".docx":
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
if mimetype.startswith(
|
|
57
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml"
|
|
58
|
+
):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def convert(
|
|
64
|
+
self,
|
|
65
|
+
file_stream: BinaryIO,
|
|
66
|
+
stream_info: StreamInfo,
|
|
67
|
+
**kwargs: Any,
|
|
68
|
+
) -> DocumentConverterResult:
|
|
69
|
+
if _dependency_exc_info is not None:
|
|
70
|
+
raise MissingDependencyException(
|
|
71
|
+
MISSING_DEPENDENCY_MESSAGE.format(
|
|
72
|
+
converter=type(self).__name__,
|
|
73
|
+
extension=".docx",
|
|
74
|
+
feature="docx",
|
|
75
|
+
)
|
|
76
|
+
) from _dependency_exc_info[1].with_traceback(
|
|
77
|
+
_dependency_exc_info[2]
|
|
78
|
+
) # type: ignore[union-attr]
|
|
79
|
+
|
|
80
|
+
# Get OCR service if available (from kwargs or instance)
|
|
81
|
+
ocr_service: Optional[LLMVisionOCRService] = (
|
|
82
|
+
kwargs.get("ocr_service") or self.ocr_service
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if ocr_service:
|
|
86
|
+
# 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)
|
|
89
|
+
|
|
90
|
+
# 2. Convert DOCX → HTML via mammoth
|
|
91
|
+
file_stream.seek(0)
|
|
92
|
+
pre_process_stream = pre_process_docx(file_stream)
|
|
93
|
+
html_result = mammoth.convert_to_html(
|
|
94
|
+
pre_process_stream, style_map=kwargs.get("style_map")
|
|
95
|
+
).value
|
|
96
|
+
|
|
97
|
+
# 3. Replace <img> tags with plain placeholder tokens so that
|
|
98
|
+
# mammoth's HTML→markdown step never escapes our OCR markers.
|
|
99
|
+
html_with_placeholders, ocr_texts = self._inject_placeholders(
|
|
100
|
+
html_result, image_ocr_map
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# 4. Convert HTML → markdown
|
|
104
|
+
md_result = self._html_converter.convert_string(
|
|
105
|
+
html_with_placeholders, **kwargs
|
|
106
|
+
)
|
|
107
|
+
md = md_result.markdown
|
|
108
|
+
|
|
109
|
+
# 5. Swap placeholders for the actual OCR blocks (post-conversion
|
|
110
|
+
# so * and _ are never escaped by the markdown converter).
|
|
111
|
+
for i, raw_text in enumerate(ocr_texts):
|
|
112
|
+
placeholder = _PLACEHOLDER.format(i)
|
|
113
|
+
ocr_block = f"*[Image OCR]\n{raw_text}\n[End OCR]*"
|
|
114
|
+
md = md.replace(placeholder, ocr_block)
|
|
115
|
+
|
|
116
|
+
return DocumentConverterResult(markdown=md)
|
|
117
|
+
else:
|
|
118
|
+
# Standard conversion without OCR
|
|
119
|
+
style_map = kwargs.get("style_map", None)
|
|
120
|
+
pre_process_stream = pre_process_docx(file_stream)
|
|
121
|
+
return self._html_converter.convert_string(
|
|
122
|
+
mammoth.convert_to_html(pre_process_stream, style_map=style_map).value,
|
|
123
|
+
**kwargs,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _extract_and_ocr_images(
|
|
127
|
+
self, file_stream: BinaryIO, ocr_service: LLMVisionOCRService
|
|
128
|
+
) -> dict[str, str]:
|
|
129
|
+
"""
|
|
130
|
+
Extract images from DOCX and OCR them.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict mapping image relationship IDs to raw OCR text (no markers).
|
|
134
|
+
"""
|
|
135
|
+
ocr_map = {}
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
file_stream.seek(0)
|
|
139
|
+
doc = Document(file_stream)
|
|
140
|
+
|
|
141
|
+
for rel in doc.part.rels.values():
|
|
142
|
+
if "image" in rel.target_ref.lower():
|
|
143
|
+
try:
|
|
144
|
+
image_bytes = rel.target_part.blob
|
|
145
|
+
image_stream = io.BytesIO(image_bytes)
|
|
146
|
+
ocr_result = ocr_service.extract_text(image_stream)
|
|
147
|
+
|
|
148
|
+
if ocr_result.text.strip():
|
|
149
|
+
# Store raw text only — markers added later
|
|
150
|
+
ocr_map[rel.rId] = ocr_result.text.strip()
|
|
151
|
+
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
return ocr_map
|
|
159
|
+
|
|
160
|
+
def _inject_placeholders(
|
|
161
|
+
self, html: str, ocr_map: dict[str, str]
|
|
162
|
+
) -> tuple[str, list[str]]:
|
|
163
|
+
"""
|
|
164
|
+
Replace <img> tags with numbered placeholder tokens.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
(html_with_placeholders, ordered list of raw OCR texts)
|
|
168
|
+
"""
|
|
169
|
+
if not ocr_map:
|
|
170
|
+
return html, []
|
|
171
|
+
|
|
172
|
+
ocr_texts = list(ocr_map.values())
|
|
173
|
+
used: list[int] = []
|
|
174
|
+
|
|
175
|
+
def replace_img(match: re.Match) -> str: # type: ignore[type-arg]
|
|
176
|
+
for i in range(len(ocr_texts)):
|
|
177
|
+
if i not in used:
|
|
178
|
+
used.append(i)
|
|
179
|
+
return f"<p>{_PLACEHOLDER.format(i)}</p>"
|
|
180
|
+
return "" # remove image if all OCR texts already used
|
|
181
|
+
|
|
182
|
+
result = re.sub(r"<img[^>]*>", replace_img, html)
|
|
183
|
+
|
|
184
|
+
# Any OCR texts that had no matching <img> tag go at the end
|
|
185
|
+
for i in range(len(ocr_texts)):
|
|
186
|
+
if i not in used:
|
|
187
|
+
result += f"<p>{_PLACEHOLDER.format(i)}</p>"
|
|
188
|
+
|
|
189
|
+
return result, ocr_texts
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OCR Service Layer for MarkItDown
|
|
3
|
+
Provides LLM Vision-based image text extraction.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from typing import Any, BinaryIO
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from markitdown import StreamInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OCRResult:
|
|
15
|
+
"""Result from OCR extraction."""
|
|
16
|
+
|
|
17
|
+
text: str
|
|
18
|
+
confidence: float | None = None
|
|
19
|
+
backend_used: str | None = None
|
|
20
|
+
error: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LLMVisionOCRService:
|
|
24
|
+
"""OCR service using LLM vision models (OpenAI-compatible)."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
client: Any,
|
|
29
|
+
model: str,
|
|
30
|
+
default_prompt: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Initialize LLM Vision OCR service.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
client: OpenAI-compatible client
|
|
37
|
+
model: Model name (e.g., 'gpt-4o', 'gemini-2.0-flash')
|
|
38
|
+
default_prompt: Default prompt for OCR extraction
|
|
39
|
+
"""
|
|
40
|
+
self.client = client
|
|
41
|
+
self.model = model
|
|
42
|
+
self.default_prompt = default_prompt or (
|
|
43
|
+
"Extract all text from this image. "
|
|
44
|
+
"Return ONLY the extracted text, maintaining the original "
|
|
45
|
+
"layout and order. Do not add any commentary or description."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def extract_text(
|
|
49
|
+
self,
|
|
50
|
+
image_stream: BinaryIO,
|
|
51
|
+
prompt: str | None = None,
|
|
52
|
+
stream_info: StreamInfo | None = None,
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> OCRResult:
|
|
55
|
+
"""Extract text using LLM vision."""
|
|
56
|
+
if self.client is None:
|
|
57
|
+
return OCRResult(
|
|
58
|
+
text="",
|
|
59
|
+
backend_used="llm_vision",
|
|
60
|
+
error="LLM client not configured",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
image_stream.seek(0)
|
|
65
|
+
|
|
66
|
+
content_type: str | None = None
|
|
67
|
+
if stream_info:
|
|
68
|
+
content_type = stream_info.mimetype
|
|
69
|
+
|
|
70
|
+
if not content_type:
|
|
71
|
+
try:
|
|
72
|
+
from PIL import Image
|
|
73
|
+
|
|
74
|
+
image_stream.seek(0)
|
|
75
|
+
img = Image.open(image_stream)
|
|
76
|
+
fmt = img.format.lower() if img.format else "png"
|
|
77
|
+
content_type = f"image/{fmt}"
|
|
78
|
+
except Exception:
|
|
79
|
+
content_type = "image/png"
|
|
80
|
+
|
|
81
|
+
image_stream.seek(0)
|
|
82
|
+
base64_image = base64.b64encode(image_stream.read()).decode("utf-8")
|
|
83
|
+
data_uri = f"data:{content_type};base64,{base64_image}"
|
|
84
|
+
|
|
85
|
+
actual_prompt = prompt or self.default_prompt
|
|
86
|
+
response = self.client.chat.completions.create(
|
|
87
|
+
model=self.model,
|
|
88
|
+
messages=[
|
|
89
|
+
{
|
|
90
|
+
"role": "user",
|
|
91
|
+
"content": [
|
|
92
|
+
{"type": "text", "text": actual_prompt},
|
|
93
|
+
{
|
|
94
|
+
"type": "image_url",
|
|
95
|
+
"image_url": {"url": data_uri},
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
text = response.choices[0].message.content
|
|
103
|
+
return OCRResult(
|
|
104
|
+
text=text.strip() if text else "",
|
|
105
|
+
backend_used="llm_vision",
|
|
106
|
+
)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
return OCRResult(text="", backend_used="llm_vision", error=str(e))
|
|
109
|
+
finally:
|
|
110
|
+
image_stream.seek(0)
|