ocr-llm 1.0.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.
- llm_ocr/__init__.py +18 -0
- llm_ocr/config/__init__.py +0 -0
- llm_ocr/config/settings.py +29 -0
- llm_ocr/core/__init__.py +0 -0
- llm_ocr/core/base.py +48 -0
- llm_ocr/core/processor.py +224 -0
- llm_ocr/prompts/__init__.py +0 -0
- llm_ocr/prompts/markdown.py +33 -0
- llm_ocr/providers/__init__.py +4 -0
- llm_ocr/providers/gemini.py +87 -0
- llm_ocr/providers/openai.py +123 -0
- ocr_llm-1.0.0.dist-info/METADATA +308 -0
- ocr_llm-1.0.0.dist-info/RECORD +16 -0
- ocr_llm-1.0.0.dist-info/WHEEL +5 -0
- ocr_llm-1.0.0.dist-info/licenses/LICENSE +21 -0
- ocr_llm-1.0.0.dist-info/top_level.txt +1 -0
llm_ocr/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""llm-ocr: Convert PDFs to markdown using LLMs."""
|
|
2
|
+
|
|
3
|
+
from .config.settings import OCRConfig
|
|
4
|
+
from .core.base import LLMProvider
|
|
5
|
+
from .core.processor import LLMOCR
|
|
6
|
+
|
|
7
|
+
# Import providers
|
|
8
|
+
from .providers.gemini import Gemini
|
|
9
|
+
from .providers.openai import OpenAI
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0"
|
|
12
|
+
__all__ = [
|
|
13
|
+
"LLMOCR",
|
|
14
|
+
"OCRConfig",
|
|
15
|
+
"LLMProvider",
|
|
16
|
+
"Gemini",
|
|
17
|
+
"OpenAI",
|
|
18
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class OCRConfig:
|
|
7
|
+
"""Configuration for OCR processing."""
|
|
8
|
+
|
|
9
|
+
dpi: int = 200
|
|
10
|
+
max_pages: Optional[int] = None
|
|
11
|
+
batch_size: int = 5 # PDF to image conversion batch size
|
|
12
|
+
llm_batch_size: int = 1 # Number of pages to send to LLM at once
|
|
13
|
+
thread_count: int = 4
|
|
14
|
+
convert_to_grayscale: bool = False
|
|
15
|
+
optimize_png: bool = True
|
|
16
|
+
use_cropbox: bool = True
|
|
17
|
+
max_retries: int = 3
|
|
18
|
+
retry_delay: float = 1.0
|
|
19
|
+
output_format: str = "markdown"
|
|
20
|
+
include_page_markers: bool = False
|
|
21
|
+
|
|
22
|
+
def validate(self):
|
|
23
|
+
"""Validate configuration."""
|
|
24
|
+
if self.dpi < 72 or self.dpi > 600:
|
|
25
|
+
raise ValueError("DPI must be between 72 and 600")
|
|
26
|
+
if self.batch_size < 1:
|
|
27
|
+
raise ValueError("Batch size must be at least 1")
|
|
28
|
+
if self.llm_batch_size < 1:
|
|
29
|
+
raise ValueError("LLM batch size must be at least 1")
|
llm_ocr/core/__init__.py
ADDED
|
File without changes
|
llm_ocr/core/base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LLMProvider(ABC):
|
|
6
|
+
"""Abstract base class for LLM providers."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, api_key: str, model: str, **kwargs):
|
|
9
|
+
"""
|
|
10
|
+
Initialize the LLM provider.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
api_key: API key for the provider
|
|
14
|
+
model: Model identifier/name
|
|
15
|
+
**kwargs: Additional provider-specific parameters
|
|
16
|
+
"""
|
|
17
|
+
self.api_key = api_key
|
|
18
|
+
self.model = model
|
|
19
|
+
self.kwargs = kwargs
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def generate_content(
|
|
23
|
+
self,
|
|
24
|
+
prompt: str,
|
|
25
|
+
image_bytes_list: List[bytes],
|
|
26
|
+
) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Generate content from images and prompt.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
prompt: The instruction prompt
|
|
32
|
+
image_bytes_list: List of PNG image bytes (one or more pages)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Generated text content
|
|
36
|
+
"""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
async def aclose(self):
|
|
41
|
+
"""Close and cleanup resources."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
"""Return the provider name."""
|
|
48
|
+
pass
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import AsyncIterable, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
from pdf2image import convert_from_path, pdfinfo_from_path
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from ..config.settings import OCRConfig
|
|
11
|
+
from ..core.base import LLMProvider
|
|
12
|
+
from ..prompts.markdown import MARKDOWN_SYSTEM_PROMPT
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LLMOCR:
|
|
16
|
+
"""Main processor for converting PDFs to markdown using LLMs."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, provider: LLMProvider, config: Optional[OCRConfig] = None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize LLM OCR converter.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
provider: An instance of an LLM provider (Gemini, OpenAI, etc.)
|
|
24
|
+
config: OCRConfig object for processing settings
|
|
25
|
+
"""
|
|
26
|
+
if not isinstance(provider, LLMProvider):
|
|
27
|
+
raise TypeError(
|
|
28
|
+
f"provider must be an instance of LLMProvider, got {type(provider)}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
self.provider = provider
|
|
32
|
+
self.config = config or OCRConfig()
|
|
33
|
+
self.config.validate()
|
|
34
|
+
|
|
35
|
+
def _process_image_to_bytes(self, img_path: str) -> bytes:
|
|
36
|
+
"""Convert image file to PNG bytes."""
|
|
37
|
+
with Image.open(img_path) as im:
|
|
38
|
+
if self.config.convert_to_grayscale:
|
|
39
|
+
im = im.convert("L")
|
|
40
|
+
buff = io.BytesIO()
|
|
41
|
+
im.save(buff, format="PNG", optimize=self.config.optimize_png)
|
|
42
|
+
return buff.getvalue()
|
|
43
|
+
|
|
44
|
+
async def _iter_pdf_pages_as_png_bytes(
|
|
45
|
+
self, pdf_path: Path
|
|
46
|
+
) -> AsyncIterable[Tuple[int, bytes]]:
|
|
47
|
+
"""Yield (page_number, png_bytes) for each page."""
|
|
48
|
+
info = pdfinfo_from_path(pdf_path)
|
|
49
|
+
total_pages = info["Pages"]
|
|
50
|
+
if self.config.max_pages:
|
|
51
|
+
total_pages = min(total_pages, self.config.max_pages)
|
|
52
|
+
|
|
53
|
+
page = 1
|
|
54
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
55
|
+
while page <= total_pages:
|
|
56
|
+
first = page
|
|
57
|
+
last = min(page + self.config.batch_size - 1, total_pages)
|
|
58
|
+
|
|
59
|
+
img_paths = convert_from_path(
|
|
60
|
+
pdf_path,
|
|
61
|
+
dpi=self.config.dpi,
|
|
62
|
+
first_page=first,
|
|
63
|
+
last_page=last,
|
|
64
|
+
output_folder=tmpdir,
|
|
65
|
+
fmt="png",
|
|
66
|
+
paths_only=True,
|
|
67
|
+
thread_count=self.config.thread_count,
|
|
68
|
+
use_cropbox=self.config.use_cropbox,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
for p, img_path in enumerate(img_paths, start=first):
|
|
72
|
+
png_bytes = await asyncio.to_thread(
|
|
73
|
+
self._process_image_to_bytes, img_path
|
|
74
|
+
)
|
|
75
|
+
yield p, png_bytes
|
|
76
|
+
|
|
77
|
+
page = last + 1
|
|
78
|
+
|
|
79
|
+
async def _process_batch_with_llm(
|
|
80
|
+
self, png_bytes_list: List[bytes], page_nums: List[int], total_pages: int
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Process a batch of pages with the LLM provider."""
|
|
83
|
+
# Format page range for prompt
|
|
84
|
+
if len(page_nums) == 1:
|
|
85
|
+
page_range = f"{page_nums[0]}"
|
|
86
|
+
else:
|
|
87
|
+
page_range = f"{page_nums[0]}-{page_nums[-1]}"
|
|
88
|
+
|
|
89
|
+
prompt = MARKDOWN_SYSTEM_PROMPT.format(
|
|
90
|
+
page_range=page_range, total_pages=total_pages
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
content = await self.provider.generate_content(prompt, png_bytes_list)
|
|
95
|
+
return content.strip()
|
|
96
|
+
except Exception as err:
|
|
97
|
+
print(f"[ERROR] Pages {page_range}: Failed to process: {err}")
|
|
98
|
+
raise # Fail-fast: re-raise to stop processing
|
|
99
|
+
|
|
100
|
+
def _combine_markdown_chunks(self, chunks: List[str]) -> str:
|
|
101
|
+
"""Combine markdown chunks from multiple pages."""
|
|
102
|
+
combined = []
|
|
103
|
+
|
|
104
|
+
for i, chunk in enumerate(chunks):
|
|
105
|
+
if not chunk.strip():
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Add page separator comment for debugging (optional)
|
|
109
|
+
if i > 0 and self.config.include_page_markers:
|
|
110
|
+
combined.append(f"\n<!-- Page {i + 1} -->\n")
|
|
111
|
+
|
|
112
|
+
combined.append(chunk)
|
|
113
|
+
|
|
114
|
+
return "\n\n".join(combined)
|
|
115
|
+
|
|
116
|
+
async def aclose(self):
|
|
117
|
+
"""Close and cleanup resources."""
|
|
118
|
+
await self.provider.aclose()
|
|
119
|
+
|
|
120
|
+
async def __aenter__(self):
|
|
121
|
+
"""Async context manager entry."""
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
125
|
+
"""Async context manager exit."""
|
|
126
|
+
await self.aclose()
|
|
127
|
+
|
|
128
|
+
async def convert(
|
|
129
|
+
self, pdf_path: Union[str, Path], output_path: Optional[Union[str, Path]] = None
|
|
130
|
+
) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Convert PDF to markdown.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
pdf_path: Path to the PDF file
|
|
136
|
+
output_path: Optional path to save the markdown file
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Markdown content as string
|
|
140
|
+
"""
|
|
141
|
+
pdf_path = Path(pdf_path)
|
|
142
|
+
|
|
143
|
+
if not pdf_path.exists():
|
|
144
|
+
raise FileNotFoundError(f"PDF file not found: {pdf_path}")
|
|
145
|
+
|
|
146
|
+
# Get total pages
|
|
147
|
+
info = pdfinfo_from_path(pdf_path)
|
|
148
|
+
total_pages = info["Pages"]
|
|
149
|
+
if self.config.max_pages:
|
|
150
|
+
total_pages = min(total_pages, self.config.max_pages)
|
|
151
|
+
|
|
152
|
+
print(f"[INFO] Converting PDF: {pdf_path.name} ({total_pages} pages)")
|
|
153
|
+
print(f"[INFO] Using provider: {self.provider.name} ({self.provider.model})")
|
|
154
|
+
print(f"[INFO] LLM batch size: {self.config.llm_batch_size}")
|
|
155
|
+
|
|
156
|
+
markdown_chunks = []
|
|
157
|
+
|
|
158
|
+
# Collect pages into batches according to llm_batch_size
|
|
159
|
+
page_batch = []
|
|
160
|
+
page_num_batch = []
|
|
161
|
+
|
|
162
|
+
async for page_num, png_bytes in self._iter_pdf_pages_as_png_bytes(pdf_path):
|
|
163
|
+
page_batch.append(png_bytes)
|
|
164
|
+
page_num_batch.append(page_num)
|
|
165
|
+
|
|
166
|
+
# Process batch when it reaches llm_batch_size or this is the last page
|
|
167
|
+
if len(page_batch) >= self.config.llm_batch_size or page_num == total_pages:
|
|
168
|
+
# Retry logic for transient errors
|
|
169
|
+
markdown_content = ""
|
|
170
|
+
for attempt in range(self.config.max_retries):
|
|
171
|
+
try:
|
|
172
|
+
markdown_content = await self._process_batch_with_llm(
|
|
173
|
+
page_batch, page_num_batch, total_pages
|
|
174
|
+
)
|
|
175
|
+
break
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if attempt == self.config.max_retries - 1:
|
|
178
|
+
page_range = (
|
|
179
|
+
f"{page_num_batch[0]}-{page_num_batch[-1]}"
|
|
180
|
+
if len(page_num_batch) > 1
|
|
181
|
+
else str(page_num_batch[0])
|
|
182
|
+
)
|
|
183
|
+
print(
|
|
184
|
+
f"[ERROR] Pages {page_range}: Failed after "
|
|
185
|
+
f"{self.config.max_retries} attempts: {e}"
|
|
186
|
+
)
|
|
187
|
+
raise # Fail-fast: stop processing on failure
|
|
188
|
+
await asyncio.sleep(self.config.retry_delay * (attempt + 1))
|
|
189
|
+
|
|
190
|
+
if markdown_content.strip():
|
|
191
|
+
page_range = (
|
|
192
|
+
f"{page_num_batch[0]}-{page_num_batch[-1]}"
|
|
193
|
+
if len(page_num_batch) > 1
|
|
194
|
+
else str(page_num_batch[0])
|
|
195
|
+
)
|
|
196
|
+
print(
|
|
197
|
+
f"[OK] Pages {page_range}/{total_pages} processed "
|
|
198
|
+
f"({len(markdown_content)} chars)"
|
|
199
|
+
)
|
|
200
|
+
markdown_chunks.append(markdown_content)
|
|
201
|
+
else:
|
|
202
|
+
page_range = (
|
|
203
|
+
f"{page_num_batch[0]}-{page_num_batch[-1]}"
|
|
204
|
+
if len(page_num_batch) > 1
|
|
205
|
+
else str(page_num_batch[0])
|
|
206
|
+
)
|
|
207
|
+
print(f"[OK] Pages {page_range}/{total_pages} - No content")
|
|
208
|
+
|
|
209
|
+
# Clear the batch
|
|
210
|
+
page_batch = []
|
|
211
|
+
page_num_batch = []
|
|
212
|
+
|
|
213
|
+
# Combine all chunks
|
|
214
|
+
final_markdown = self._combine_markdown_chunks(markdown_chunks)
|
|
215
|
+
|
|
216
|
+
# Save to file if output path provided
|
|
217
|
+
if output_path:
|
|
218
|
+
output_path = Path(output_path)
|
|
219
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
output_path.write_text(final_markdown, encoding="utf-8")
|
|
221
|
+
print(f"[DONE] Saved to: {output_path}")
|
|
222
|
+
|
|
223
|
+
print(f"[DONE] Generated {len(final_markdown)} characters of markdown")
|
|
224
|
+
return final_markdown
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from textwrap import dedent
|
|
2
|
+
|
|
3
|
+
MARKDOWN_SYSTEM_PROMPT = dedent(
|
|
4
|
+
"""
|
|
5
|
+
You are an expert at converting document images to markdown format.
|
|
6
|
+
|
|
7
|
+
Your task is to accurately transcribe all text content from the image(s) and format it as clean, readable markdown.
|
|
8
|
+
|
|
9
|
+
FORMATTING RULES:
|
|
10
|
+
- Use proper markdown headers (# ## ###) for document structure
|
|
11
|
+
- Preserve lists (ordered and unordered)
|
|
12
|
+
- Format tables using markdown table syntax
|
|
13
|
+
- Use **bold** and *italic* for emphasized text
|
|
14
|
+
- Preserve code blocks with ```language syntax
|
|
15
|
+
- Use > for blockquotes
|
|
16
|
+
- Keep links and images in markdown format
|
|
17
|
+
- Maintain paragraph breaks and spacing
|
|
18
|
+
|
|
19
|
+
CRITICAL RULES:
|
|
20
|
+
- DO NOT add any commentary, explanations, or meta-text
|
|
21
|
+
- DO NOT include markdown code fences (```) around your output
|
|
22
|
+
- Output ONLY the markdown content itself
|
|
23
|
+
- If a page is blank or contains no meaningful content, skip it
|
|
24
|
+
- Preserve the original document's structure and hierarchy
|
|
25
|
+
- For multi-column layouts, process left to right, top to bottom
|
|
26
|
+
- When processing multiple pages, combine them into a single coherent output
|
|
27
|
+
- Maintain continuity across pages (e.g., tables, paragraphs spanning pages)
|
|
28
|
+
|
|
29
|
+
PAGES: {page_range} of {total_pages}
|
|
30
|
+
|
|
31
|
+
Output only the markdown content:
|
|
32
|
+
"""
|
|
33
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
from google.genai import Client, types
|
|
5
|
+
|
|
6
|
+
from ..core.base import LLMProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Gemini(LLMProvider):
|
|
10
|
+
"""
|
|
11
|
+
Google Gemini provider for OCR.
|
|
12
|
+
|
|
13
|
+
Supported models:
|
|
14
|
+
- gemini-2.5-pro (recommended, best quality)
|
|
15
|
+
- gemini-2.5-flash (fast)
|
|
16
|
+
- gemini-2.0-pro
|
|
17
|
+
- gemini-2.0-flash
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Available models with their identifiers
|
|
21
|
+
FLASH_2_5 = "gemini-2.5-flash"
|
|
22
|
+
PRO_2_5 = "gemini-2.5-pro"
|
|
23
|
+
FLASH_2_0 = "gemini-2.0-flash"
|
|
24
|
+
PRO_2_0 = "gemini-2.0-pro"
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_key: Optional[str] = None, model: str = FLASH_2_5, **kwargs):
|
|
27
|
+
"""
|
|
28
|
+
Initialize Gemini provider.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
api_key: Gemini API key. If None, reads from GEMINI_API_KEY env var
|
|
32
|
+
model: Model to use. Use Gemini.FLASH_2_5, Gemini.PRO_2_5, etc.
|
|
33
|
+
**kwargs: Additional parameters for the Gemini API
|
|
34
|
+
"""
|
|
35
|
+
api_key = api_key or os.getenv("GEMINI_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"Gemini API key is required. "
|
|
39
|
+
"Pass it directly or set GEMINI_API_KEY environment variable."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
super().__init__(api_key, model, **kwargs)
|
|
43
|
+
|
|
44
|
+
# Initialize the async client
|
|
45
|
+
client = Client(api_key=self.api_key)
|
|
46
|
+
self.aclient = client.aio
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
"""Return provider name."""
|
|
51
|
+
return "gemini"
|
|
52
|
+
|
|
53
|
+
async def generate_content(
|
|
54
|
+
self,
|
|
55
|
+
prompt: str,
|
|
56
|
+
image_bytes_list: List[bytes],
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Generate content using Gemini with one or more images."""
|
|
59
|
+
# Create image parts for all images in the batch
|
|
60
|
+
image_parts = [
|
|
61
|
+
types.Part(inline_data=types.Blob(mime_type="image/png", data=image_bytes))
|
|
62
|
+
for image_bytes in image_bytes_list
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
# Combine prompt and all images
|
|
66
|
+
contents = [prompt] + image_parts
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
resp = await self.aclient.models.generate_content(
|
|
70
|
+
model=self.model, contents=contents, **self.kwargs
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return resp.text or ""
|
|
75
|
+
except (ValueError, AttributeError):
|
|
76
|
+
print("[WARNING] Gemini returned no content")
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
except Exception as err:
|
|
80
|
+
print(f"[ERROR] Gemini generation failed: {err}")
|
|
81
|
+
raise
|
|
82
|
+
|
|
83
|
+
async def aclose(self):
|
|
84
|
+
"""Close the Gemini client."""
|
|
85
|
+
# The Google GenAI AsyncClient doesn't require explicit closing
|
|
86
|
+
# Resources are cleaned up automatically
|
|
87
|
+
pass
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from openai import AsyncOpenAI
|
|
6
|
+
|
|
7
|
+
from ..core.base import LLMProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenAI(LLMProvider):
|
|
11
|
+
"""
|
|
12
|
+
OpenAI provider for OCR.
|
|
13
|
+
|
|
14
|
+
Supported models:
|
|
15
|
+
- gpt-5
|
|
16
|
+
- gpt-5-mini
|
|
17
|
+
- gpt-5-nano
|
|
18
|
+
- gpt-5-chat-latest
|
|
19
|
+
- gpt-4.1
|
|
20
|
+
- gpt-4.1-mini
|
|
21
|
+
- gpt-4.1-nano
|
|
22
|
+
- o4-mini
|
|
23
|
+
- o1
|
|
24
|
+
- o3
|
|
25
|
+
- gpt-4o
|
|
26
|
+
- gpt-4o-mini
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# Available models with their identifiers
|
|
30
|
+
GPT_5 = "gpt-5"
|
|
31
|
+
GPT_5_MINI = "gpt-5-mini"
|
|
32
|
+
GPT_5_NANO = "gpt-5-nano"
|
|
33
|
+
GPT_5_CHAT_LATEST = "gpt-5-chat-latest"
|
|
34
|
+
|
|
35
|
+
GPT_4_1 = "gpt-4.1"
|
|
36
|
+
GPT_4_1_MINI = "gpt-4.1-mini"
|
|
37
|
+
GPT_4_1_NANO = "gpt-4.1-nano"
|
|
38
|
+
|
|
39
|
+
O4_MINI = "o4-mini"
|
|
40
|
+
O1 = "o1"
|
|
41
|
+
O3 = "o3"
|
|
42
|
+
|
|
43
|
+
GPT_4O = "gpt-4o"
|
|
44
|
+
GPT_4O_MINI = "gpt-4o-mini"
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, api_key: Optional[str] = None, model: str = GPT_4O_MINI, **kwargs
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize OpenAI provider.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
api_key: OpenAI API key. If None, reads from OPENAI_API_KEY env var
|
|
54
|
+
model: Model to use. Use OpenAI.GPT_4O, OpenAI.GPT_4O_MINI, etc.
|
|
55
|
+
**kwargs: Additional parameters for the OpenAI API (e.g., max_tokens, temperature)
|
|
56
|
+
"""
|
|
57
|
+
api_key = api_key or os.getenv("OPENAI_API_KEY")
|
|
58
|
+
if not api_key:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"OpenAI API key is required. "
|
|
61
|
+
"Pass it directly or set OPENAI_API_KEY environment variable."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
super().__init__(api_key, model, **kwargs)
|
|
65
|
+
|
|
66
|
+
# Initialize the async client
|
|
67
|
+
self.aclient = AsyncOpenAI(api_key=self.api_key)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def name(self) -> str:
|
|
71
|
+
"""Return provider name."""
|
|
72
|
+
return "openai"
|
|
73
|
+
|
|
74
|
+
async def generate_content(
|
|
75
|
+
self,
|
|
76
|
+
prompt: str,
|
|
77
|
+
image_bytes_list: List[bytes],
|
|
78
|
+
) -> str:
|
|
79
|
+
"""Generate content using OpenAI with one or more images."""
|
|
80
|
+
# Create content array with text prompt
|
|
81
|
+
content = [{"type": "text", "text": prompt}]
|
|
82
|
+
|
|
83
|
+
# Add all images as base64-encoded data URLs
|
|
84
|
+
for image_bytes in image_bytes_list:
|
|
85
|
+
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
|
86
|
+
content.append(
|
|
87
|
+
{
|
|
88
|
+
"type": "image_url",
|
|
89
|
+
"image_url": {
|
|
90
|
+
"url": f"data:image/png;base64,{b64_image}",
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Use the Chat Completions API with vision
|
|
97
|
+
response = await self.aclient.chat.completions.create(
|
|
98
|
+
model=self.model,
|
|
99
|
+
messages=[
|
|
100
|
+
{
|
|
101
|
+
"role": "user",
|
|
102
|
+
"content": content,
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
**self.kwargs,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Extract the text content from the response
|
|
109
|
+
if response.choices and len(response.choices) > 0:
|
|
110
|
+
message_content = response.choices[0].message.content
|
|
111
|
+
return message_content or ""
|
|
112
|
+
else:
|
|
113
|
+
print("[WARNING] OpenAI returned no content")
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
except Exception as err:
|
|
117
|
+
print(f"[ERROR] OpenAI generation failed: {err}")
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
async def aclose(self):
|
|
121
|
+
"""Close the OpenAI client."""
|
|
122
|
+
# Close the async client
|
|
123
|
+
await self.aclient.close()
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocr-llm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Convert PDFs to markdown using Large Language Models (LLMs) with vision capabilities
|
|
5
|
+
Author-email: Shehryar Sohail <hafizshehryar88@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Shehryar718/llm-ocr
|
|
8
|
+
Project-URL: Repository, https://github.com/Shehryar718/llm-ocr
|
|
9
|
+
Project-URL: Issues, https://github.com/Shehryar718/llm-ocr/issues
|
|
10
|
+
Keywords: ocr,pdf,markdown,llm,vision,gemini,openai,ai
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
20
|
+
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: pdf2image>=1.16.0
|
|
25
|
+
Requires-Dist: Pillow>=11.0.0
|
|
26
|
+
Requires-Dist: google-genai>=1.0.0
|
|
27
|
+
Requires-Dist: openai>=2.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov>=6.5.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
|
|
33
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: isort>=6.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: flake8>=7.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.10.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# LLM OCR
|
|
41
|
+
|
|
42
|
+
[](https://pypi.org/project/ocr-llm/)
|
|
43
|
+
[](https://pypi.org/project/ocr-llm/)
|
|
44
|
+
[](https://github.com/Shehryar718/llm-ocr/blob/main/LICENSE)
|
|
45
|
+
|
|
46
|
+
Convert PDFs to markdown using Large Language Models (LLMs) with vision capabilities.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- 🔍 High-quality OCR using vision-capable LLMs
|
|
51
|
+
- 📄 Batch processing of multiple PDF pages
|
|
52
|
+
- 🔌 Multiple provider support (Gemini, OpenAI)
|
|
53
|
+
- ⚙️ Configurable processing settings
|
|
54
|
+
- 🔄 Automatic retry logic for transient errors
|
|
55
|
+
- 📝 Clean markdown output
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install ocr-llm
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### System Dependencies
|
|
64
|
+
|
|
65
|
+
You also need to install poppler (required for PDF processing):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# macOS
|
|
69
|
+
brew install poppler
|
|
70
|
+
|
|
71
|
+
# Ubuntu/Debian
|
|
72
|
+
sudo apt-get install poppler-utils
|
|
73
|
+
|
|
74
|
+
# Fedora/RHEL
|
|
75
|
+
sudo yum install poppler-utils
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Dependencies
|
|
79
|
+
|
|
80
|
+
The library requires:
|
|
81
|
+
|
|
82
|
+
- **System**: `poppler-utils` for PDF processing
|
|
83
|
+
- **Python**:
|
|
84
|
+
- `google-genai` for Gemini provider
|
|
85
|
+
- `openai` for OpenAI provider
|
|
86
|
+
- `pdf2image` and `Pillow` for PDF processing
|
|
87
|
+
|
|
88
|
+
## Quick Start
|
|
89
|
+
|
|
90
|
+
### Using OpenAI
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import asyncio
|
|
94
|
+
from llm_ocr import LLMOCR, OpenAI
|
|
95
|
+
|
|
96
|
+
async def main():
|
|
97
|
+
# Initialize OpenAI provider
|
|
98
|
+
provider = OpenAI(
|
|
99
|
+
api_key="your-api-key", # Or set OPENAI_API_KEY env var
|
|
100
|
+
model=OpenAI.GPT_4O_MINI
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Create OCR processor
|
|
104
|
+
async with LLMOCR(provider) as ocr:
|
|
105
|
+
# Convert PDF to markdown
|
|
106
|
+
markdown = await ocr.convert(
|
|
107
|
+
"document.pdf",
|
|
108
|
+
output_path="output.md"
|
|
109
|
+
)
|
|
110
|
+
print(markdown)
|
|
111
|
+
|
|
112
|
+
asyncio.run(main())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Using Gemini
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from llm_ocr import LLMOCR, Gemini
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
# Initialize Gemini provider
|
|
123
|
+
provider = Gemini(
|
|
124
|
+
api_key="your-api-key", # Or set GEMINI_API_KEY env var
|
|
125
|
+
model=Gemini.FLASH_2_5 # Or Gemini.PRO_2_5 for best quality
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Create OCR processor
|
|
129
|
+
async with LLMOCR(provider) as ocr:
|
|
130
|
+
# Convert PDF to markdown
|
|
131
|
+
markdown = await ocr.convert(
|
|
132
|
+
"document.pdf",
|
|
133
|
+
output_path="output.md"
|
|
134
|
+
)
|
|
135
|
+
print(markdown)
|
|
136
|
+
|
|
137
|
+
asyncio.run(main())
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Available Models
|
|
141
|
+
|
|
142
|
+
### OpenAI
|
|
143
|
+
|
|
144
|
+
- `OpenAI.GPT_4O`
|
|
145
|
+
- `OpenAI.GPT_4O_MINI` (default)
|
|
146
|
+
|
|
147
|
+
Additional models: `O1`, `O3`, `O4_MINI`, `GPT_5`, `GPT_5_MINI`, `GPT_4_1`, and more.
|
|
148
|
+
|
|
149
|
+
> See `llm_ocr/providers/openai.py` for the complete list.
|
|
150
|
+
|
|
151
|
+
### Gemini
|
|
152
|
+
|
|
153
|
+
- `Gemini.PRO_2_5`
|
|
154
|
+
- `Gemini.FLASH_2_5` (default)
|
|
155
|
+
|
|
156
|
+
Additional models: `PRO_2_0`, `FLASH_2_0`.
|
|
157
|
+
|
|
158
|
+
> See `llm_ocr/providers/gemini.py` for the complete list.
|
|
159
|
+
|
|
160
|
+
## Configuration
|
|
161
|
+
|
|
162
|
+
Customize the OCR processing with `OCRConfig`:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from llm_ocr import LLMOCR, OpenAI, OCRConfig
|
|
166
|
+
|
|
167
|
+
config = OCRConfig(
|
|
168
|
+
dpi=300, # Higher DPI for better quality
|
|
169
|
+
max_pages=10, # Limit number of pages to process
|
|
170
|
+
llm_batch_size=2, # Send 2 pages to LLM at once
|
|
171
|
+
convert_to_grayscale=True, # Convert images to grayscale
|
|
172
|
+
max_retries=3, # Retry failed requests
|
|
173
|
+
retry_delay=1.0, # Wait 1 second between retries
|
|
174
|
+
include_page_markers=True, # Add page markers in output
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
provider = OpenAI()
|
|
178
|
+
ocr = LLMOCR(provider, config=config)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Configuration Options
|
|
182
|
+
|
|
183
|
+
| Option | Default | Description |
|
|
184
|
+
| ---------------------- | ------- | ------------------------------------------ |
|
|
185
|
+
| `dpi` | 200 | DPI for PDF to image conversion (72-600) |
|
|
186
|
+
| `max_pages` | None | Maximum number of pages to process |
|
|
187
|
+
| `batch_size` | 5 | PDF to image conversion batch size |
|
|
188
|
+
| `llm_batch_size` | 1 | Number of pages to send to LLM at once |
|
|
189
|
+
| `thread_count` | 4 | Number of threads for PDF conversion |
|
|
190
|
+
| `convert_to_grayscale` | False | Convert images to grayscale |
|
|
191
|
+
| `optimize_png` | True | Optimize PNG compression |
|
|
192
|
+
| `use_cropbox` | True | Use PDF cropbox for conversion |
|
|
193
|
+
| `max_retries` | 3 | Maximum retry attempts for failed requests |
|
|
194
|
+
| `retry_delay` | 1.0 | Delay between retries in seconds |
|
|
195
|
+
| `include_page_markers` | False | Add page markers in markdown output |
|
|
196
|
+
|
|
197
|
+
## Advanced Usage
|
|
198
|
+
|
|
199
|
+
### Custom Provider Parameters
|
|
200
|
+
|
|
201
|
+
Pass additional parameters to the LLM provider:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
# OpenAI with custom parameters
|
|
205
|
+
provider = OpenAI(
|
|
206
|
+
model=OpenAI.GPT_4O,
|
|
207
|
+
max_tokens=4000,
|
|
208
|
+
temperature=0.0,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Gemini with custom parameters
|
|
212
|
+
provider = Gemini(
|
|
213
|
+
model=Gemini.PRO_2_5,
|
|
214
|
+
temperature=0.0,
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Processing Multiple Documents
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
import asyncio
|
|
222
|
+
from pathlib import Path
|
|
223
|
+
from llm_ocr import LLMOCR, OpenAI
|
|
224
|
+
|
|
225
|
+
async def process_documents():
|
|
226
|
+
provider = OpenAI()
|
|
227
|
+
|
|
228
|
+
async with LLMOCR(provider) as ocr:
|
|
229
|
+
pdf_files = Path("pdfs").glob("*.pdf")
|
|
230
|
+
|
|
231
|
+
for pdf_file in pdf_files:
|
|
232
|
+
output_file = pdf_file.with_suffix(".md")
|
|
233
|
+
await ocr.convert(pdf_file, output_path=output_file)
|
|
234
|
+
print(f"Converted {pdf_file.name} -> {output_file.name}")
|
|
235
|
+
|
|
236
|
+
asyncio.run(process_documents())
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Without Context Manager
|
|
240
|
+
|
|
241
|
+
If you prefer not to use the context manager:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
import asyncio
|
|
245
|
+
from llm_ocr import LLMOCR, OpenAI
|
|
246
|
+
|
|
247
|
+
async def main():
|
|
248
|
+
provider = OpenAI()
|
|
249
|
+
ocr = LLMOCR(provider)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
markdown = await ocr.convert("document.pdf")
|
|
253
|
+
print(markdown)
|
|
254
|
+
finally:
|
|
255
|
+
await ocr.aclose() # Don't forget to close!
|
|
256
|
+
|
|
257
|
+
asyncio.run(main())
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Environment Variables
|
|
261
|
+
|
|
262
|
+
Set API keys via environment variables:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
# For OpenAI
|
|
266
|
+
export OPENAI_API_KEY="your-openai-api-key"
|
|
267
|
+
|
|
268
|
+
# For Gemini
|
|
269
|
+
export GEMINI_API_KEY="your-gemini-api-key"
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Then use providers without passing API keys:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
# API key read from environment variable
|
|
276
|
+
provider = OpenAI() # Uses OPENAI_API_KEY
|
|
277
|
+
# or
|
|
278
|
+
provider = Gemini() # Uses GEMINI_API_KEY
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Error Handling
|
|
282
|
+
|
|
283
|
+
The library uses a fail-fast approach with automatic retries:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
import asyncio
|
|
287
|
+
from llm_ocr import LLMOCR, OpenAI, OCRConfig
|
|
288
|
+
|
|
289
|
+
async def main():
|
|
290
|
+
provider = OpenAI()
|
|
291
|
+
config = OCRConfig(
|
|
292
|
+
max_retries=5, # Retry up to 5 times
|
|
293
|
+
retry_delay=2.0, # Wait 2 seconds between retries
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
async with LLMOCR(provider, config) as ocr:
|
|
297
|
+
try:
|
|
298
|
+
markdown = await ocr.convert("document.pdf")
|
|
299
|
+
print(markdown)
|
|
300
|
+
except Exception as e:
|
|
301
|
+
print(f"Failed to process document: {e}")
|
|
302
|
+
|
|
303
|
+
asyncio.run(main())
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
## License
|
|
307
|
+
|
|
308
|
+
See LICENSE file for details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
llm_ocr/__init__.py,sha256=85oykW2FSKOFMZKUccUW5wEs8Wlk5-8Q8KBPyL0GF4c,371
|
|
2
|
+
llm_ocr/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
llm_ocr/config/settings.py,sha256=WzZfNKTj1VZ4yS0bCEmVX9R5IxwGtMeje9x6hiHzE9U,943
|
|
4
|
+
llm_ocr/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
llm_ocr/core/base.py,sha256=guS3dTsGBFzAKXTjIni3w2oeHNM6EAOQYqaKLoOkJqw,1140
|
|
6
|
+
llm_ocr/core/processor.py,sha256=kbCnmXNaRoyVyA2m170FARKf_Z2nbZDgcRLZPMj2Xr0,8380
|
|
7
|
+
llm_ocr/prompts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
llm_ocr/prompts/markdown.py,sha256=ik6QhFLVsnxDVTN-z1S7tjHaZGcZJ8wcdnCPhfv5ZHY,1336
|
|
9
|
+
llm_ocr/providers/__init__.py,sha256=Sf8t5ZxwOZ7XlwxX0OSXlgYMPhAaEc6JhUdO58zgk6k,86
|
|
10
|
+
llm_ocr/providers/gemini.py,sha256=_Fo-AKF5XStQ-7stti2GfBbs9DQsf88hUo0vcJxfhKw,2603
|
|
11
|
+
llm_ocr/providers/openai.py,sha256=RFuwsaYae3WDQd6V1plN4q88owg9B4Q2W0h1GwdfHvw,3402
|
|
12
|
+
ocr_llm-1.0.0.dist-info/licenses/LICENSE,sha256=YNyaca-Sr7AUZJKoh_1wWi9GN6PRNZErWfIASRtCBAM,1072
|
|
13
|
+
ocr_llm-1.0.0.dist-info/METADATA,sha256=5j_lVKX0Ef_KaGoR5dD5sF9uzbWaKV3001ADLUy67Sc,8411
|
|
14
|
+
ocr_llm-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
ocr_llm-1.0.0.dist-info/top_level.txt,sha256=kUf9ARPXFNWvv-LRnhJPYz7BbBgPNDi9uesrPduvD0k,8
|
|
16
|
+
ocr_llm-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Shehryar Sohail
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llm_ocr
|