natural-pdf 0.1.5__py3-none-any.whl → 0.1.7__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.
Files changed (61) hide show
  1. docs/finetuning/index.md +176 -0
  2. docs/ocr/index.md +34 -47
  3. docs/tutorials/01-loading-and-extraction.ipynb +34 -1536
  4. docs/tutorials/02-finding-elements.ipynb +42 -42
  5. docs/tutorials/03-extracting-blocks.ipynb +17 -17
  6. docs/tutorials/04-table-extraction.ipynb +12 -12
  7. docs/tutorials/05-excluding-content.ipynb +30 -30
  8. docs/tutorials/06-document-qa.ipynb +28 -28
  9. docs/tutorials/07-layout-analysis.ipynb +63 -35
  10. docs/tutorials/07-working-with-regions.ipynb +55 -51
  11. docs/tutorials/07-working-with-regions.md +2 -2
  12. docs/tutorials/08-spatial-navigation.ipynb +60 -60
  13. docs/tutorials/09-section-extraction.ipynb +113 -113
  14. docs/tutorials/10-form-field-extraction.ipynb +78 -50
  15. docs/tutorials/11-enhanced-table-processing.ipynb +6 -6
  16. docs/tutorials/12-ocr-integration.ipynb +149 -131
  17. docs/tutorials/12-ocr-integration.md +0 -13
  18. docs/tutorials/13-semantic-search.ipynb +313 -873
  19. natural_pdf/__init__.py +21 -22
  20. natural_pdf/analyzers/layout/gemini.py +280 -0
  21. natural_pdf/analyzers/layout/layout_manager.py +28 -1
  22. natural_pdf/analyzers/layout/layout_options.py +11 -0
  23. natural_pdf/analyzers/layout/yolo.py +6 -2
  24. natural_pdf/collections/pdf_collection.py +24 -0
  25. natural_pdf/core/element_manager.py +18 -13
  26. natural_pdf/core/page.py +174 -36
  27. natural_pdf/core/pdf.py +156 -42
  28. natural_pdf/elements/base.py +9 -17
  29. natural_pdf/elements/collections.py +99 -38
  30. natural_pdf/elements/region.py +77 -37
  31. natural_pdf/elements/text.py +5 -0
  32. natural_pdf/exporters/__init__.py +4 -0
  33. natural_pdf/exporters/base.py +61 -0
  34. natural_pdf/exporters/paddleocr.py +345 -0
  35. natural_pdf/ocr/__init__.py +57 -36
  36. natural_pdf/ocr/engine.py +160 -49
  37. natural_pdf/ocr/engine_easyocr.py +178 -157
  38. natural_pdf/ocr/engine_paddle.py +114 -189
  39. natural_pdf/ocr/engine_surya.py +87 -144
  40. natural_pdf/ocr/ocr_factory.py +125 -0
  41. natural_pdf/ocr/ocr_manager.py +65 -89
  42. natural_pdf/ocr/ocr_options.py +8 -13
  43. natural_pdf/ocr/utils.py +113 -0
  44. natural_pdf/templates/finetune/fine_tune_paddleocr.md +415 -0
  45. natural_pdf/templates/spa/css/style.css +334 -0
  46. natural_pdf/templates/spa/index.html +31 -0
  47. natural_pdf/templates/spa/js/app.js +472 -0
  48. natural_pdf/templates/spa/words.txt +235976 -0
  49. natural_pdf/utils/debug.py +34 -0
  50. natural_pdf/utils/identifiers.py +33 -0
  51. natural_pdf/utils/packaging.py +485 -0
  52. natural_pdf/utils/text_extraction.py +44 -64
  53. natural_pdf/utils/visualization.py +1 -1
  54. {natural_pdf-0.1.5.dist-info → natural_pdf-0.1.7.dist-info}/METADATA +44 -20
  55. {natural_pdf-0.1.5.dist-info → natural_pdf-0.1.7.dist-info}/RECORD +58 -47
  56. {natural_pdf-0.1.5.dist-info → natural_pdf-0.1.7.dist-info}/WHEEL +1 -1
  57. {natural_pdf-0.1.5.dist-info → natural_pdf-0.1.7.dist-info}/top_level.txt +0 -1
  58. natural_pdf/templates/ocr_debug.html +0 -517
  59. tests/test_loading.py +0 -50
  60. tests/test_optional_deps.py +0 -298
  61. {natural_pdf-0.1.5.dist-info → natural_pdf-0.1.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,125 @@
1
+ import logging
2
+ import importlib.util
3
+ from typing import Dict, Any, Optional, Type, Union, List
4
+
5
+ from .engine import OCREngine
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class OCRFactory:
11
+ """Factory for creating and managing OCR engines with optional dependencies."""
12
+
13
+ @staticmethod
14
+ def create_engine(engine_type: str, **kwargs) -> OCREngine:
15
+ """Create and return an OCR engine instance.
16
+
17
+ Args:
18
+ engine_type: One of 'surya', 'easyocr', 'paddle'
19
+ **kwargs: Arguments to pass to the engine constructor
20
+
21
+ Returns:
22
+ An initialized OCR engine
23
+
24
+ Raises:
25
+ ImportError: If the required dependencies aren't installed
26
+ ValueError: If the engine_type is unknown
27
+ """
28
+ if engine_type == "surya":
29
+ try:
30
+ from .engine_surya import SuryaOCREngine
31
+
32
+ return SuryaOCREngine(**kwargs)
33
+ except ImportError:
34
+ raise ImportError(
35
+ "Surya engine requires the 'surya' package. " "Install with: pip install surya"
36
+ )
37
+ elif engine_type == "easyocr":
38
+ try:
39
+ from .engine_easyocr import EasyOCREngine
40
+
41
+ return EasyOCREngine(**kwargs)
42
+ except ImportError:
43
+ raise ImportError(
44
+ "EasyOCR engine requires the 'easyocr' package. "
45
+ "Install with: pip install easyocr"
46
+ )
47
+ elif engine_type == "paddle":
48
+ try:
49
+ from .engine_paddle import PaddleOCREngine
50
+
51
+ return PaddleOCREngine(**kwargs)
52
+ except ImportError:
53
+ raise ImportError(
54
+ "PaddleOCR engine requires 'paddleocr' and 'paddlepaddle'. "
55
+ "Install with: pip install paddleocr paddlepaddle"
56
+ )
57
+ else:
58
+ raise ValueError(f"Unknown engine type: {engine_type}")
59
+
60
+ @staticmethod
61
+ def list_available_engines() -> Dict[str, bool]:
62
+ """Returns a dictionary of engine names and their availability status."""
63
+ engines = {}
64
+
65
+ # Check Surya
66
+ try:
67
+ engines["surya"] = importlib.util.find_spec("surya") is not None
68
+ except ImportError:
69
+ engines["surya"] = False
70
+
71
+ # Check EasyOCR
72
+ try:
73
+ engines["easyocr"] = importlib.util.find_spec("easyocr") is not None
74
+ except ImportError:
75
+ engines["easyocr"] = False
76
+
77
+ # Check PaddleOCR
78
+ try:
79
+ paddle = (
80
+ importlib.util.find_spec("paddle") is not None
81
+ or importlib.util.find_spec("paddlepaddle") is not None
82
+ )
83
+ paddleocr = importlib.util.find_spec("paddleocr") is not None
84
+ engines["paddle"] = paddle and paddleocr
85
+ except ImportError:
86
+ engines["paddle"] = False
87
+
88
+ return engines
89
+
90
+ @staticmethod
91
+ def get_recommended_engine(**kwargs) -> OCREngine:
92
+ """Returns the best available OCR engine based on what's installed.
93
+
94
+ First tries engines in order of preference: EasyOCR, Paddle, Surya.
95
+ If none are available, raises ImportError with installation instructions.
96
+
97
+ Args:
98
+ **kwargs: Arguments to pass to the engine constructor
99
+
100
+ Returns:
101
+ The best available OCR engine instance
102
+
103
+ Raises:
104
+ ImportError: If no engines are available
105
+ """
106
+ available = OCRFactory.list_available_engines()
107
+
108
+ # Try engines in order of recommendation
109
+ if available.get("easyocr", False):
110
+ logger.info("Using EasyOCR engine (recommended)")
111
+ return OCRFactory.create_engine("easyocr", **kwargs)
112
+ elif available.get("paddle", False):
113
+ logger.info("Using PaddleOCR engine")
114
+ return OCRFactory.create_engine("paddle", **kwargs)
115
+ elif available.get("surya", False):
116
+ logger.info("Using Surya OCR engine")
117
+ return OCRFactory.create_engine("surya", **kwargs)
118
+
119
+ # If we get here, no engines are available
120
+ raise ImportError(
121
+ "No OCR engines available. Please install at least one of: \n"
122
+ "- EasyOCR (recommended): pip install easyocr\n"
123
+ "- PaddleOCR: pip install paddleocr paddlepaddle\n"
124
+ "- Surya OCR: pip install surya"
125
+ )
@@ -9,8 +9,8 @@ from PIL import Image
9
9
  from .engine import OCREngine
10
10
  from .engine_easyocr import EasyOCREngine
11
11
  from .engine_paddle import PaddleOCREngine
12
- from .engine_surya import SuryaOCREngine # <-- Import Surya Engine
13
- from .ocr_options import OCROptions # <-- Import Surya Options
12
+ from .engine_surya import SuryaOCREngine
13
+ from .ocr_options import OCROptions
14
14
  from .ocr_options import BaseOCROptions, EasyOCROptions, PaddleOCROptions, SuryaOCROptions
15
15
 
16
16
  logger = logging.getLogger(__name__)
@@ -27,15 +27,6 @@ class OCRManager:
27
27
  # Add other engines here
28
28
  }
29
29
 
30
- # Define the limited set of kwargs allowed for the simple apply_ocr call
31
- SIMPLE_MODE_ALLOWED_KWARGS = {
32
- "engine",
33
- "languages",
34
- "min_confidence",
35
- "device",
36
- # Add image pre-processing args like 'resolution', 'width' if handled here
37
- }
38
-
39
30
  def __init__(self):
40
31
  """Initializes the OCR Manager."""
41
32
  self._engine_instances: Dict[str, OCREngine] = {} # Cache for engine instances
@@ -49,16 +40,16 @@ class OCRManager:
49
40
  f"Unknown OCR engine: '{engine_name}'. Available: {list(self.ENGINE_REGISTRY.keys())}"
50
41
  )
51
42
 
52
- # Surya engine might manage its own predictor state, consider if caching instance is always right
53
- # For now, we cache the engine instance itself.
54
43
  if engine_name not in self._engine_instances:
55
44
  logger.info(f"Creating instance of engine: {engine_name}")
56
45
  engine_class = self.ENGINE_REGISTRY[engine_name]["class"]
57
46
  engine_instance = engine_class() # Instantiate first
58
47
  if not engine_instance.is_available():
59
48
  # Check availability before storing
49
+ # Construct helpful error message with install hint
50
+ install_hint = f"pip install 'natural-pdf[{engine_name}]'"
60
51
  raise RuntimeError(
61
- f"Engine '{engine_name}' is not available. Please check dependencies."
52
+ f"Engine '{engine_name}' is not available. Please install the required dependencies: {install_hint}"
62
53
  )
63
54
  self._engine_instances[engine_name] = engine_instance # Store if available
64
55
 
@@ -66,106 +57,91 @@ class OCRManager:
66
57
 
67
58
  def apply_ocr(
68
59
  self,
69
- images: Union[Image.Image, List[Image.Image]], # Accept single or list
70
- engine: Optional[str] = "easyocr", # Default engine
71
- options: Optional[OCROptions] = None,
72
- **kwargs,
73
- ) -> Union[List[Dict[str, Any]], List[List[Dict[str, Any]]]]: # Return single or list of lists
60
+ images: Union[Image.Image, List[Image.Image]],
61
+ # --- Explicit Common Parameters ---
62
+ engine: Optional[str] = None,
63
+ languages: Optional[List[str]] = None,
64
+ min_confidence: Optional[float] = None,
65
+ device: Optional[str] = None,
66
+ detect_only: bool = False,
67
+ # --- Engine-Specific Options ---
68
+ options: Optional[Any] = None, # e.g. EasyOCROptions(), PaddleOCROptions()
69
+ ) -> Union[List[Dict[str, Any]], List[List[Dict[str, Any]]]]:
74
70
  """
75
- Applies OCR to a single image or a batch of images using either simple
76
- keyword arguments or an options object.
71
+ Applies OCR to a single image or a batch of images.
77
72
 
78
73
  Args:
79
74
  images: A single PIL Image or a list of PIL Images to process.
80
- engine: Name of the engine to use (e.g., 'easyocr', 'paddle', 'surya').
81
- Ignored if 'options' object is provided. Defaults to 'easyocr'.
82
- options: An instance of EasyOCROptions, PaddleOCROptions, or SuryaOCROptions
83
- for detailed configuration. If provided, simple kwargs (languages, etc.)
84
- and the 'engine' arg are ignored.
85
- **kwargs: For simple mode, accepts: 'languages', 'min_confidence', 'device'.
86
- Other kwargs will raise a TypeError unless 'options' is provided.
75
+ engine: Name of the engine (e.g., 'easyocr', 'paddle', 'surya').
76
+ Defaults to 'easyocr' if not specified.
77
+ languages: List of language codes (e.g., ['en', 'fr'], ['en', 'german']).
78
+ **Passed directly to the engine.** Must be codes understood
79
+ by the specific engine. No mapping is performed by the manager.
80
+ min_confidence: Minimum confidence threshold (0.0-1.0).
81
+ Passed directly to the engine.
82
+ device: Device string (e.g., 'cpu', 'cuda').
83
+ Passed directly to the engine.
84
+ detect_only: If True, only detect text regions, do not perform OCR.
85
+ options: An engine-specific options object (e.g., EasyOCROptions) or dict
86
+ containing additional parameters specific to the chosen engine.
87
+ Passed directly to the engine.
87
88
 
88
89
  Returns:
89
90
  If input is a single image: List of result dictionaries.
90
- If input is a list of images: List of lists of result dictionaries,
91
- corresponding to each input image.
91
+ If input is a list of images: List of lists of result dictionaries.
92
92
 
93
93
  Raises:
94
94
  ValueError: If the engine name is invalid.
95
- TypeError: If unexpected keyword arguments are provided in simple mode,
96
- or if input 'images' is not a PIL Image or list of PIL Images.
97
- RuntimeError: If the selected engine is not available.
95
+ TypeError: If input 'images' is not valid or options type is incompatible.
96
+ RuntimeError: If the selected engine is not available or processing fails.
98
97
  """
99
- final_options: BaseOCROptions
100
- selected_engine_name: str
101
-
102
98
  # --- Validate input type ---
103
99
  is_batch = isinstance(images, list)
104
100
  if not is_batch and not isinstance(images, Image.Image):
105
101
  raise TypeError("Input 'images' must be a PIL Image or a list of PIL Images.")
106
- # Allow engines to handle non-PIL images in list if they support it/log warnings
107
- # if is_batch and not all(isinstance(img, Image.Image) for img in images):
108
- # logger.warning("Batch may contain items that are not PIL Images.")
109
-
110
- # --- Determine Options and Engine ---
111
- if options is not None:
112
- # Advanced Mode
113
- logger.debug(f"Using advanced mode with options object: {type(options).__name__}")
114
- final_options = copy.deepcopy(options) # Prevent modification of original
115
- found_engine = False
116
- for name, registry_entry in self.ENGINE_REGISTRY.items():
117
- # Check if options object is an instance of the registered options class
118
- if isinstance(options, registry_entry["options_class"]):
119
- selected_engine_name = name
120
- found_engine = True
121
- break
122
- if not found_engine:
123
- raise TypeError(
124
- f"Provided options object type '{type(options).__name__}' does not match any registered engine options."
125
- )
126
- if kwargs:
127
- logger.warning(
128
- f"Keyword arguments {list(kwargs.keys())} were provided alongside 'options' and will be ignored."
129
- )
130
- else:
131
- # Simple Mode
132
- selected_engine_name = engine.lower() if engine else "easyocr" # Fallback default
133
- logger.debug(
134
- f"Using simple mode with engine: '{selected_engine_name}' and kwargs: {kwargs}"
135
- )
136
-
137
- if selected_engine_name not in self.ENGINE_REGISTRY:
138
- raise ValueError(
139
- f"Unknown OCR engine: '{selected_engine_name}'. Available: {list(self.ENGINE_REGISTRY.keys())}"
140
- )
141
102
 
142
- unexpected_kwargs = set(kwargs.keys()) - self.SIMPLE_MODE_ALLOWED_KWARGS
143
- if unexpected_kwargs:
144
- raise TypeError(
145
- f"Got unexpected keyword arguments in simple mode: {list(unexpected_kwargs)}. Use the 'options' parameter for detailed configuration."
146
- )
103
+ # --- Determine Engine ---
104
+ selected_engine_name = (engine or "easyocr").lower()
105
+ if selected_engine_name not in self.ENGINE_REGISTRY:
106
+ raise ValueError(
107
+ f"Unknown OCR engine: '{selected_engine_name}'. Available: {list(self.ENGINE_REGISTRY.keys())}"
108
+ )
109
+ logger.debug(f"Selected engine: '{selected_engine_name}'")
147
110
 
148
- # Get the *correct* options class for the selected engine
149
- options_class = self.ENGINE_REGISTRY[selected_engine_name]["options_class"]
111
+ # --- Prepare Options ---
112
+ final_options = copy.deepcopy(options) if options is not None else None
150
113
 
151
- # Create options instance using provided simple kwargs or defaults
152
- simple_args = {
153
- "languages": kwargs.get("languages", ["en"]),
154
- "min_confidence": kwargs.get("min_confidence", 0.5),
155
- "device": kwargs.get("device", "cpu"),
156
- # Note: 'extra_args' isn't populated in simple mode
157
- }
158
- final_options = options_class(**simple_args)
159
- logger.debug(f"Constructed options for simple mode: {final_options}")
114
+ # Type check options object if provided
115
+ if final_options is not None:
116
+ options_class = self.ENGINE_REGISTRY[selected_engine_name].get(
117
+ "options_class", BaseOCROptions
118
+ )
119
+ if not isinstance(final_options, options_class):
120
+ # Allow dicts to be passed directly too, assuming engine handles them
121
+ if not isinstance(final_options, dict):
122
+ raise TypeError(
123
+ f"Provided options type '{type(final_options).__name__}' is not compatible with engine '{selected_engine_name}'. Expected '{options_class.__name__}' or dict."
124
+ )
160
125
 
161
126
  # --- Get Engine Instance and Process ---
162
127
  try:
163
128
  engine_instance = self._get_engine_instance(selected_engine_name)
164
129
  processing_mode = "batch" if is_batch else "single image"
165
130
  logger.info(f"Processing {processing_mode} with engine '{selected_engine_name}'...")
131
+ logger.debug(
132
+ f" Engine Args: languages={languages}, min_confidence={min_confidence}, device={device}, options={final_options}"
133
+ )
166
134
 
167
- # Call the engine's process_image, passing single image or list
168
- results = engine_instance.process_image(images, final_options)
135
+ # Call the engine's process_image, passing common args and options object
136
+ # **ASSUMPTION**: Engine process_image signatures are updated to accept these common args.
137
+ results = engine_instance.process_image(
138
+ images=images,
139
+ languages=languages,
140
+ min_confidence=min_confidence,
141
+ device=device,
142
+ detect_only=detect_only,
143
+ options=final_options,
144
+ )
169
145
 
170
146
  # Log result summary based on mode
171
147
  if is_batch:
@@ -14,9 +14,6 @@ from typing import Any, Dict, List, Optional, Tuple, Union
14
14
  class BaseOCROptions:
15
15
  """Base class for OCR engine options."""
16
16
 
17
- languages: List[str] = field(default_factory=lambda: ["en"])
18
- min_confidence: float = 0.5
19
- device: Optional[str] = "cpu" # Suggestion, actual device usage depends on engine impl.
20
17
  extra_args: Dict[str, Any] = field(default_factory=dict)
21
18
 
22
19
 
@@ -95,12 +92,14 @@ class PaddleOCROptions(BaseOCROptions):
95
92
  cls: Optional[bool] = None
96
93
 
97
94
  def __post_init__(self):
98
- if self.use_gpu is None:
99
- if self.device and "cuda" in self.device.lower():
100
- self.use_gpu = True
101
- else:
102
- self.use_gpu = False
103
- # logger.debug(f"Initialized PaddleOCROptions: {self}")
95
+ pass
96
+
97
+ # if self.use_gpu is None:
98
+ # if self.device and "cuda" in self.device.lower():
99
+ # self.use_gpu = True
100
+ # else:
101
+ # self.use_gpu = False
102
+ # # logger.debug(f"Initialized PaddleOCROptions: {self}")
104
103
 
105
104
 
106
105
  # --- Surya Specific Options ---
@@ -109,10 +108,6 @@ class SuryaOCROptions(BaseOCROptions):
109
108
  """Specific options for the Surya OCR engine."""
110
109
 
111
110
  # Currently, Surya example shows languages passed at prediction time.
112
- # Add fields here if Surya's RecognitionPredictor or DetectionPredictor
113
- # constructors accept relevant arguments (e.g., model paths, device settings).
114
- # For now, it primarily uses the base options like 'languages' and 'min_confidence'.
115
- # Configuration like batch sizes are often set via environment variables for Surya.
116
111
  pass
117
112
 
118
113
 
@@ -0,0 +1,113 @@
1
+ import io
2
+ import base64
3
+ import logging
4
+ from typing import TYPE_CHECKING, Callable, Iterable, Optional, Any
5
+ from natural_pdf.elements.text import TextElement
6
+ from tqdm.auto import tqdm
7
+
8
+ if TYPE_CHECKING:
9
+ from natural_pdf.elements.base import Element
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _apply_ocr_correction_to_elements(
15
+ elements: Iterable["Element"],
16
+ correction_callback: Callable[[Any], Optional[str]],
17
+ caller_info: str = "Utility",
18
+ ) -> None:
19
+ """
20
+ Applies OCR correction callback to a list of elements in place,
21
+ showing a progress bar.
22
+
23
+ Iterates through elements, checks if source starts with 'ocr', calls
24
+ the callback, and updates element.text if a new string is returned.
25
+
26
+ Args:
27
+ elements: An iterable of Element objects.
28
+ correction_callback: A function accepting an element and returning
29
+ Optional[str] (new text or None).
30
+ caller_info: String identifying the calling context for logs.
31
+ """
32
+ if not callable(correction_callback):
33
+ # Raise error here so individual methods don't need to repeat the check
34
+ raise TypeError("`correction_callback` must be a callable function.")
35
+
36
+ if not elements:
37
+ logger.warning(f"{caller_info}: No elements provided for correction.")
38
+ return
39
+
40
+ corrections_applied = 0
41
+ elements_checked = 0
42
+
43
+ # Prepare the iterable with tqdm
44
+ element_iterable = tqdm(elements, desc=f"Correcting OCR ({caller_info})", unit="element")
45
+
46
+ for element in element_iterable:
47
+ # Check if the element is likely from OCR and has text attribute
48
+ element_source = getattr(element, "source", None)
49
+ if (
50
+ isinstance(element_source, str)
51
+ and element_source.startswith("ocr")
52
+ and hasattr(element, "text")
53
+ ):
54
+ elements_checked += 1
55
+ current_text = getattr(element, "text") # Already checked hasattr
56
+
57
+ new_text = correction_callback(element)
58
+
59
+ if new_text is not None:
60
+ if new_text != current_text:
61
+ element.text = new_text # Update in place
62
+ corrections_applied += 1
63
+
64
+ logger.info(
65
+ f"{caller_info}: OCR correction finished. Checked: {elements_checked}, Applied: {corrections_applied}"
66
+ )
67
+ # No return value needed, modifies elements in place
68
+
69
+
70
+ def direct_ocr_llm(
71
+ element,
72
+ client,
73
+ model="",
74
+ resolution=150,
75
+ prompt="OCR this image. Return only the exact text from the image. Include misspellings, punctuation, etc.",
76
+ padding=2,
77
+ ) -> str:
78
+ """Convenience method to directly OCR a region of the page."""
79
+
80
+ if isinstance(element, TextElement):
81
+ region = element.expand(left=padding, right=padding, top=padding, bottom=padding)
82
+ else:
83
+ region = element
84
+
85
+ buffered = io.BytesIO()
86
+ region_img = region.to_image(resolution=resolution, include_highlights=False)
87
+ region_img.save(buffered, format="PNG")
88
+ base64_image = base64.b64encode(buffered.getvalue()).decode("utf-8")
89
+
90
+ response = client.chat.completions.create(
91
+ model=model,
92
+ messages=[
93
+ {
94
+ "role": "system",
95
+ "content": "You are an expert OCR engineer. You will be given an image of a region of a page. You will return the exact text from the image.",
96
+ },
97
+ {
98
+ "role": "user",
99
+ "content": [
100
+ {"type": "text", "text": prompt},
101
+ {
102
+ "type": "image_url",
103
+ "image_url": {"url": f"data:image/png;base64,{base64_image}"},
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ )
109
+
110
+ corrected = response.choices[0].message.content
111
+ logger.debug(f"Corrected {region.extract_text()} to {corrected}")
112
+
113
+ return corrected