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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present Contributors
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ __version__ = "0.1.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)