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,422 @@
1
+ """
2
+ Enhanced PDF Converter with OCR support for embedded images.
3
+ Extracts images from PDFs and performs OCR while maintaining document context.
4
+ """
5
+
6
+ import io
7
+ import sys
8
+ from typing import Any, BinaryIO, Optional
9
+
10
+ from markitdown import DocumentConverter, DocumentConverterResult, StreamInfo
11
+ from markitdown._exceptions import (
12
+ MissingDependencyException,
13
+ MISSING_DEPENDENCY_MESSAGE,
14
+ )
15
+ from ._ocr_service import LLMVisionOCRService
16
+
17
+ # Import dependencies
18
+ _dependency_exc_info = None
19
+ try:
20
+ import pdfminer
21
+ import pdfminer.high_level
22
+ import pdfplumber
23
+ from PIL import Image
24
+ except ImportError:
25
+ _dependency_exc_info = sys.exc_info()
26
+
27
+
28
+ def _extract_images_from_page(page: Any) -> list[dict]:
29
+ """
30
+ Extract images from a PDF page by rendering page regions.
31
+
32
+ Returns:
33
+ List of dicts with 'stream', 'bbox', 'name', 'y_pos' keys
34
+ """
35
+ images_info = []
36
+
37
+ try:
38
+ # Try multiple methods to detect images
39
+ images = []
40
+
41
+ # Method 1: Use page.images (standard approach)
42
+ if hasattr(page, "images") and page.images:
43
+ images = page.images
44
+
45
+ # Method 2: If no images found, try underlying PDF objects
46
+ if not images and hasattr(page, "objects") and "image" in page.objects:
47
+ images = page.objects.get("image", [])
48
+
49
+ # Method 3: Try filtering all objects for image types
50
+ if not images and hasattr(page, "objects"):
51
+ all_objs = page.objects
52
+ for obj_type in all_objs.keys():
53
+ if "image" in obj_type.lower() or "xobject" in obj_type.lower():
54
+ potential_imgs = all_objs.get(obj_type, [])
55
+ if potential_imgs:
56
+ images = potential_imgs
57
+ break
58
+
59
+ for i, img_dict in enumerate(images):
60
+ try:
61
+ # Try to get the actual image stream from the PDF
62
+ img_stream = None
63
+ y_pos = 0
64
+
65
+ # Method A: If img_dict has 'stream' key, use it directly
66
+ if "stream" in img_dict and hasattr(img_dict["stream"], "get_data"):
67
+ try:
68
+ img_bytes = img_dict["stream"].get_data()
69
+
70
+ # Try to open as PIL Image to validate/decode
71
+ pil_img = Image.open(io.BytesIO(img_bytes))
72
+
73
+ # Convert to RGB if needed (handle CMYK, etc.)
74
+ if pil_img.mode not in ("RGB", "L"):
75
+ pil_img = pil_img.convert("RGB")
76
+
77
+ # Save to stream as PNG
78
+ img_stream = io.BytesIO()
79
+ pil_img.save(img_stream, format="PNG")
80
+ img_stream.seek(0)
81
+
82
+ y_pos = img_dict.get("top", 0)
83
+ except Exception:
84
+ pass
85
+
86
+ # Method B: Fallback to rendering page region
87
+ if img_stream is None:
88
+ x0 = img_dict.get("x0", 0)
89
+ y0 = img_dict.get("top", 0)
90
+ x1 = img_dict.get("x1", 0)
91
+ y1 = img_dict.get("bottom", 0)
92
+ y_pos = y0
93
+
94
+ # Check if dimensions are valid
95
+ if x1 <= x0 or y1 <= y0:
96
+ continue
97
+
98
+ # Use pdfplumber's within_bbox to crop, then render
99
+ # This preserves coordinate system correctly
100
+ bbox = (x0, y0, x1, y1)
101
+ cropped_page = page.within_bbox(bbox)
102
+
103
+ # Render at 150 DPI (balance between quality and size)
104
+ page_img = cropped_page.to_image(resolution=150)
105
+
106
+ # Save to stream
107
+ img_stream = io.BytesIO()
108
+ page_img.original.save(img_stream, format="PNG")
109
+ img_stream.seek(0)
110
+
111
+ if img_stream:
112
+ images_info.append(
113
+ {
114
+ "stream": img_stream,
115
+ "name": f"page_{page.page_number}_img_{i}",
116
+ "y_pos": y_pos,
117
+ }
118
+ )
119
+
120
+ except Exception:
121
+ continue
122
+
123
+ except Exception:
124
+ pass
125
+
126
+ return images_info
127
+
128
+
129
+ class PdfConverterWithOCR(DocumentConverter):
130
+ """
131
+ Enhanced PDF Converter with OCR support for embedded images.
132
+ Maintains document structure while extracting text from images inline.
133
+ """
134
+
135
+ def __init__(self, ocr_service: Optional[LLMVisionOCRService] = None):
136
+ super().__init__()
137
+ self.ocr_service = ocr_service
138
+
139
+ def accepts(
140
+ self,
141
+ file_stream: BinaryIO,
142
+ stream_info: StreamInfo,
143
+ **kwargs: Any,
144
+ ) -> bool:
145
+ mimetype = (stream_info.mimetype or "").lower()
146
+ extension = (stream_info.extension or "").lower()
147
+
148
+ if extension == ".pdf":
149
+ return True
150
+
151
+ if mimetype.startswith("application/pdf") or mimetype.startswith(
152
+ "application/x-pdf"
153
+ ):
154
+ return True
155
+
156
+ return False
157
+
158
+ def convert(
159
+ self,
160
+ file_stream: BinaryIO,
161
+ stream_info: StreamInfo,
162
+ **kwargs: Any,
163
+ ) -> DocumentConverterResult:
164
+ if _dependency_exc_info is not None:
165
+ raise MissingDependencyException(
166
+ MISSING_DEPENDENCY_MESSAGE.format(
167
+ converter=type(self).__name__,
168
+ extension=".pdf",
169
+ feature="pdf",
170
+ )
171
+ ) from _dependency_exc_info[1].with_traceback(
172
+ _dependency_exc_info[2]
173
+ ) # type: ignore[union-attr]
174
+
175
+ # Get OCR service if available (from kwargs or instance)
176
+ ocr_service: LLMVisionOCRService | None = (
177
+ kwargs.get("ocr_service") or self.ocr_service
178
+ )
179
+
180
+ # Read PDF into BytesIO
181
+ file_stream.seek(0)
182
+ pdf_bytes = io.BytesIO(file_stream.read())
183
+
184
+ markdown_content = []
185
+
186
+ try:
187
+ with pdfplumber.open(pdf_bytes) as pdf:
188
+ for page_num, page in enumerate(pdf.pages, 1):
189
+ markdown_content.append(f"\n## Page {page_num}\n")
190
+
191
+ # If OCR is enabled, interleave text and images by position
192
+ if ocr_service:
193
+ images_on_page = self._extract_page_images(pdf_bytes, page_num)
194
+
195
+ if images_on_page:
196
+ # Extract text lines with Y positions
197
+ chars = page.chars
198
+ if chars:
199
+ # Group chars into lines based on Y position
200
+ lines_with_y = []
201
+ current_line = []
202
+ current_y = None
203
+
204
+ for char in sorted(
205
+ chars, key=lambda c: (c["top"], c["x0"])
206
+ ):
207
+ y = char["top"]
208
+ if current_y is None:
209
+ current_y = y
210
+ elif abs(y - current_y) > 2: # New line threshold
211
+ if current_line:
212
+ text = "".join(
213
+ [c["text"] for c in current_line]
214
+ )
215
+ lines_with_y.append(
216
+ {"y": current_y, "text": text.strip()}
217
+ )
218
+ current_line = []
219
+ current_y = y
220
+ current_line.append(char)
221
+
222
+ # Add last line
223
+ if current_line:
224
+ text = "".join([c["text"] for c in current_line])
225
+ lines_with_y.append(
226
+ {"y": current_y, "text": text.strip()}
227
+ )
228
+ else:
229
+ # Fallback: use simple text extraction
230
+ text_content = page.extract_text() or ""
231
+ lines_with_y = [
232
+ {"y": i * 10, "text": line}
233
+ for i, line in enumerate(text_content.split("\n"))
234
+ ]
235
+
236
+ # OCR all images
237
+ image_data = []
238
+ for img_info in images_on_page:
239
+ ocr_result = ocr_service.extract_text(
240
+ img_info["stream"]
241
+ )
242
+ if ocr_result.text.strip():
243
+ image_data.append(
244
+ {
245
+ "y_pos": img_info["y_pos"],
246
+ "name": img_info["name"],
247
+ "ocr_text": ocr_result.text,
248
+ "backend": ocr_result.backend_used,
249
+ "type": "image",
250
+ }
251
+ )
252
+
253
+ # Add text items
254
+ content_items = [
255
+ {
256
+ "y_pos": item["y"],
257
+ "text": item["text"],
258
+ "type": "text",
259
+ }
260
+ for item in lines_with_y
261
+ if item["text"]
262
+ ]
263
+ content_items.extend(image_data)
264
+
265
+ # Sort all items by Y position (top to bottom)
266
+ content_items.sort(key=lambda x: x["y_pos"])
267
+
268
+ # Build markdown by interleaving text and images
269
+ for item in content_items:
270
+ if item["type"] == "text":
271
+ markdown_content.append(item["text"])
272
+ else: # image
273
+ ocr_text = item["ocr_text"]
274
+ img_marker = (
275
+ f"\n\n*[Image OCR]\n{ocr_text}\n[End OCR]*\n"
276
+ )
277
+ markdown_content.append(img_marker)
278
+ else:
279
+ # No images detected - just extract regular text
280
+ text_content = page.extract_text() or ""
281
+ if text_content.strip():
282
+ markdown_content.append(text_content.strip())
283
+ else:
284
+ # No OCR, just extract text
285
+ text_content = page.extract_text() or ""
286
+ if text_content.strip():
287
+ markdown_content.append(text_content.strip())
288
+
289
+ # Build final markdown
290
+ markdown = "\n\n".join(markdown_content).strip()
291
+
292
+ # Fallback to pdfminer if empty
293
+ if not markdown:
294
+ pdf_bytes.seek(0)
295
+ markdown = pdfminer.high_level.extract_text(pdf_bytes)
296
+
297
+ except Exception:
298
+ # Fallback to pdfminer
299
+ try:
300
+ pdf_bytes.seek(0)
301
+ markdown = pdfminer.high_level.extract_text(pdf_bytes)
302
+ except Exception:
303
+ markdown = ""
304
+
305
+ # Final fallback: If still empty/whitespace and OCR is available,
306
+ # treat as scanned PDF and OCR full pages
307
+ if ocr_service and (not markdown or not markdown.strip()):
308
+ pdf_bytes.seek(0)
309
+ markdown = self._ocr_full_pages(pdf_bytes, ocr_service)
310
+
311
+ return DocumentConverterResult(markdown=markdown)
312
+
313
+ def _extract_page_images(self, pdf_bytes: io.BytesIO, page_num: int) -> list[dict]:
314
+ """
315
+ Extract images from a PDF page using pdfplumber.
316
+
317
+ Args:
318
+ pdf_bytes: PDF file as BytesIO
319
+ page_num: Page number (1-indexed)
320
+
321
+ Returns:
322
+ List of image info dicts with 'stream', 'bbox', 'name', 'y_pos'
323
+ """
324
+ images = []
325
+
326
+ try:
327
+ pdf_bytes.seek(0)
328
+ with pdfplumber.open(pdf_bytes) as pdf:
329
+ if page_num <= len(pdf.pages):
330
+ page = pdf.pages[page_num - 1] # 0-indexed
331
+ images = _extract_images_from_page(page)
332
+ except Exception:
333
+ pass
334
+
335
+ # Sort by vertical position (top to bottom)
336
+ images.sort(key=lambda x: x["y_pos"])
337
+
338
+ return images
339
+
340
+ def _ocr_full_pages(
341
+ self, pdf_bytes: io.BytesIO, ocr_service: LLMVisionOCRService
342
+ ) -> str:
343
+ """
344
+ Fallback for scanned PDFs: Convert entire pages to images and OCR them.
345
+ Used when text extraction returns empty/whitespace results.
346
+
347
+ Args:
348
+ pdf_bytes: PDF file as BytesIO
349
+ ocr_service: OCR service to use
350
+
351
+ Returns:
352
+ Markdown text extracted from OCR of full pages
353
+ """
354
+ markdown_parts = []
355
+
356
+ try:
357
+ pdf_bytes.seek(0)
358
+ with pdfplumber.open(pdf_bytes) as pdf:
359
+ for page_num, page in enumerate(pdf.pages, 1):
360
+ try:
361
+ markdown_parts.append(f"\n## Page {page_num}\n")
362
+
363
+ # Render page to image
364
+ page_img = page.to_image(resolution=300)
365
+ img_stream = io.BytesIO()
366
+ page_img.original.save(img_stream, format="PNG")
367
+ img_stream.seek(0)
368
+
369
+ # Run OCR
370
+ ocr_result = ocr_service.extract_text(img_stream)
371
+
372
+ if ocr_result.text.strip():
373
+ text = ocr_result.text.strip()
374
+ markdown_parts.append(f"*[Image OCR]\n{text}\n[End OCR]*")
375
+ else:
376
+ markdown_parts.append(
377
+ "*[No text could be extracted from this page]*"
378
+ )
379
+
380
+ except Exception as e:
381
+ markdown_parts.append(
382
+ f"*[Error processing page {page_num}: {str(e)}]*"
383
+ )
384
+ continue
385
+
386
+ except Exception:
387
+ # pdfplumber failed (e.g. malformed EOF) — try PyMuPDF for rendering
388
+ markdown_parts = []
389
+ try:
390
+ import fitz # PyMuPDF
391
+
392
+ pdf_bytes.seek(0)
393
+ doc = fitz.open(stream=pdf_bytes.read(), filetype="pdf")
394
+ for page_num in range(1, doc.page_count + 1):
395
+ try:
396
+ markdown_parts.append(f"\n## Page {page_num}\n")
397
+ page = doc[page_num - 1]
398
+ mat = fitz.Matrix(300 / 72, 300 / 72) # 300 DPI
399
+ pix = page.get_pixmap(matrix=mat)
400
+ img_stream = io.BytesIO(pix.tobytes("png"))
401
+ img_stream.seek(0)
402
+
403
+ ocr_result = ocr_service.extract_text(img_stream)
404
+
405
+ if ocr_result.text.strip():
406
+ text = ocr_result.text.strip()
407
+ markdown_parts.append(f"*[Image OCR]\n{text}\n[End OCR]*")
408
+ else:
409
+ markdown_parts.append(
410
+ "*[No text could be extracted from this page]*"
411
+ )
412
+
413
+ except Exception as e:
414
+ markdown_parts.append(
415
+ f"*[Error processing page {page_num}: {str(e)}]*"
416
+ )
417
+ continue
418
+ doc.close()
419
+ except Exception:
420
+ return "*[Error: Could not process scanned PDF]*"
421
+
422
+ return "\n\n".join(markdown_parts).strip()
@@ -0,0 +1,68 @@
1
+ """
2
+ Plugin registration for markitdown-ocr.
3
+ Registers OCR-enhanced converters with priority-based replacement strategy.
4
+ """
5
+
6
+ from typing import Any
7
+ from markitdown import MarkItDown
8
+
9
+ from ._ocr_service import LLMVisionOCRService
10
+ from ._pdf_converter_with_ocr import PdfConverterWithOCR
11
+ from ._docx_converter_with_ocr import DocxConverterWithOCR
12
+ from ._pptx_converter_with_ocr import PptxConverterWithOCR
13
+ from ._xlsx_converter_with_ocr import XlsxConverterWithOCR
14
+
15
+
16
+ __plugin_interface_version__ = 1
17
+
18
+
19
+ def register_converters(markitdown: MarkItDown, **kwargs: Any) -> None:
20
+ """
21
+ Register OCR-enhanced converters with MarkItDown.
22
+
23
+ This plugin provides OCR support for PDF, DOCX, PPTX, and XLSX files.
24
+ The converters are registered with priority -1.0 to run BEFORE built-in
25
+ converters (which have priority 0.0), effectively replacing them when
26
+ the plugin is enabled.
27
+
28
+ Args:
29
+ markitdown: MarkItDown instance to register converters with
30
+ **kwargs: Additional keyword arguments that may include:
31
+ - llm_client: OpenAI-compatible client for LLM-based OCR (required for OCR to work)
32
+ - llm_model: Model name (e.g., 'gpt-4o')
33
+ - llm_prompt: Custom prompt for text extraction
34
+ """
35
+ # Create OCR service — reads the same llm_client/llm_model kwargs
36
+ # that MarkItDown itself already accepts for image descriptions
37
+ llm_client = kwargs.get("llm_client")
38
+ llm_model = kwargs.get("llm_model")
39
+ llm_prompt = kwargs.get("llm_prompt")
40
+
41
+ ocr_service: LLMVisionOCRService | None = None
42
+ if llm_client and llm_model:
43
+ ocr_service = LLMVisionOCRService(
44
+ client=llm_client,
45
+ model=llm_model,
46
+ default_prompt=llm_prompt,
47
+ )
48
+
49
+ # Register converters with priority -1.0 (before built-ins at 0.0)
50
+ # This effectively "replaces" the built-in converters when plugin is installed
51
+ # Pass the OCR service to each converter's constructor
52
+ PRIORITY_OCR_ENHANCED = -1.0
53
+
54
+ markitdown.register_converter(
55
+ PdfConverterWithOCR(ocr_service=ocr_service), priority=PRIORITY_OCR_ENHANCED
56
+ )
57
+
58
+ markitdown.register_converter(
59
+ DocxConverterWithOCR(ocr_service=ocr_service), priority=PRIORITY_OCR_ENHANCED
60
+ )
61
+
62
+ markitdown.register_converter(
63
+ PptxConverterWithOCR(ocr_service=ocr_service), priority=PRIORITY_OCR_ENHANCED
64
+ )
65
+
66
+ markitdown.register_converter(
67
+ XlsxConverterWithOCR(ocr_service=ocr_service), priority=PRIORITY_OCR_ENHANCED
68
+ )