pdf-ocr-cli 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.
- pdf_ocr_cli-1.0.0.dist-info/METADATA +207 -0
- pdf_ocr_cli-1.0.0.dist-info/RECORD +11 -0
- pdf_ocr_cli-1.0.0.dist-info/WHEEL +5 -0
- pdf_ocr_cli-1.0.0.dist-info/entry_points.txt +3 -0
- pdf_ocr_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- pdf_ocr_cli-1.0.0.dist-info/top_level.txt +2 -0
- pdf_ocr_cli.py +5 -0
- pdf_ocr_tool/__init__.py +3 -0
- pdf_ocr_tool/cli.py +352 -0
- pdf_ocr_tool/ocr.py +135 -0
- pdf_ocr_tool/pdf_utils.py +154 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf-ocr-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: High-quality OCR tool for scanned PDFs using Tesseract
|
|
5
|
+
Author-email: Sameer Bajaj <sameer@sameerbajaj.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://sameerbajaj.com/tools/pdf-ocr/
|
|
8
|
+
Project-URL: Website, https://sameerbajaj.com
|
|
9
|
+
Project-URL: Repository, https://github.com/sameerbajaj/pdf-ocr
|
|
10
|
+
Keywords: ocr,pdf,tesseract,document-processing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
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: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: pytesseract>=0.3.13
|
|
23
|
+
Requires-Dist: pdf2image>=1.17.0
|
|
24
|
+
Requires-Dist: pillow>=10.0.0
|
|
25
|
+
Requires-Dist: click>=8.1.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Requires-Dist: PyPDF2>=3.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# PDF OCR Tool
|
|
35
|
+
|
|
36
|
+
Convert scanned or poorly OCR'd PDFs into searchable PDFs with selectable text using Tesseract OCR.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- 🎯 High-quality OCR using Tesseract 5.x
|
|
41
|
+
- 📄 Creates searchable PDFs with invisible text layer
|
|
42
|
+
- 🎨 Interactive, user-friendly command-line interface
|
|
43
|
+
- âš¡ Fast processing with configurable DPI (default: 300)
|
|
44
|
+
- 🎠Process entire PDFs or specific page ranges
|
|
45
|
+
|
|
46
|
+
## Prerequisites
|
|
47
|
+
|
|
48
|
+
Install system dependencies first:
|
|
49
|
+
|
|
50
|
+
**macOS:**
|
|
51
|
+
```bash
|
|
52
|
+
brew install tesseract poppler
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Linux (Ubuntu/Debian):**
|
|
56
|
+
```bash
|
|
57
|
+
sudo apt-get install tesseract-ocr poppler-utils
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
### Option 1: Run Directly from GitHub (Easiest - No Installation!)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uvx --from git+https://github.com/sameerbajaj/pdf-ocr pdf-ocr
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
That's it! The tool will run directly without any installation.
|
|
69
|
+
|
|
70
|
+
### Option 2: Clone and Run Locally
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Clone the repository
|
|
74
|
+
git clone https://github.com/sameerbajaj/pdf-ocr.git
|
|
75
|
+
cd pdf-ocr
|
|
76
|
+
|
|
77
|
+
# Run directly
|
|
78
|
+
uvx --from . pdf-ocr
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Option 3: Install Permanently
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Clone and install
|
|
85
|
+
git clone https://github.com/sameerbajaj/pdf-ocr.git
|
|
86
|
+
cd pdf-ocr
|
|
87
|
+
uv pip install -e .
|
|
88
|
+
source .venv/bin/activate
|
|
89
|
+
|
|
90
|
+
# Run from anywhere
|
|
91
|
+
pdf-ocr
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
### Interactive Mode (Recommended)
|
|
97
|
+
|
|
98
|
+
Simply run the command and follow the friendly prompts:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pdf-ocr
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The tool will ask you:
|
|
105
|
+
1. **Which PDF file?** - You can type the path or drag & drop the file
|
|
106
|
+
2. **Which pages?** - Enter a range like `4-5`, a single page like `7`, or press Enter for all pages
|
|
107
|
+
|
|
108
|
+
### Command-Line Mode
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Process entire PDF
|
|
112
|
+
pdf-ocr document.pdf
|
|
113
|
+
|
|
114
|
+
# Process specific pages
|
|
115
|
+
pdf-ocr document.pdf --pages 4-5
|
|
116
|
+
pdf-ocr document.pdf -p 1-10
|
|
117
|
+
|
|
118
|
+
# Custom output
|
|
119
|
+
pdf-ocr document.pdf --output result.pdf
|
|
120
|
+
|
|
121
|
+
# Save text files too
|
|
122
|
+
pdf-ocr document.pdf --save-text
|
|
123
|
+
|
|
124
|
+
# Higher quality (slower)
|
|
125
|
+
pdf-ocr document.pdf --dpi 400
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Examples
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Process pages 4-5 of a book
|
|
132
|
+
pdf-ocr book.pdf --pages 4-5
|
|
133
|
+
|
|
134
|
+
# Process chapter (pages 20-40) with text export
|
|
135
|
+
pdf-ocr textbook.pdf -p 20-40 --save-text
|
|
136
|
+
|
|
137
|
+
# High-quality scan of a single page
|
|
138
|
+
pdf-ocr scan.pdf --pages 7 --dpi 600
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Output
|
|
142
|
+
|
|
143
|
+
The tool generates:
|
|
144
|
+
- **`filename_ocr.pdf`** or **`filename_pages4-5_ocr.pdf`** - Searchable PDF with text layer
|
|
145
|
+
- **`filename_pageX.txt`** (optional) - Plain text files if `--save-text` is used
|
|
146
|
+
|
|
147
|
+
All files are saved in the same directory as the input PDF.
|
|
148
|
+
|
|
149
|
+
## Troubleshooting
|
|
150
|
+
|
|
151
|
+
**Command not found:**
|
|
152
|
+
```bash
|
|
153
|
+
source .venv/bin/activate
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Tesseract not installed:**
|
|
157
|
+
```bash
|
|
158
|
+
brew install tesseract # macOS
|
|
159
|
+
sudo apt-get install tesseract-ocr # Linux
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Poppler not installed:**
|
|
163
|
+
```bash
|
|
164
|
+
brew install poppler # macOS
|
|
165
|
+
sudo apt-get install poppler-utils # Linux
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Setup for development
|
|
172
|
+
git clone https://github.com/yourusername/pdf-ocr-tool.git
|
|
173
|
+
cd pdf-ocr-tool
|
|
174
|
+
uv venv
|
|
175
|
+
source .venv/bin/activate
|
|
176
|
+
uv pip install -e ".[dev]"
|
|
177
|
+
|
|
178
|
+
# Run from source
|
|
179
|
+
python -m pdf_ocr_tool.cli
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Project Structure
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
pdf-ocr-tool/
|
|
186
|
+
├── pdf_ocr_tool/ # Main package
|
|
187
|
+
│ ├── cli.py # Command-line interface
|
|
188
|
+
│ ├── ocr.py # OCR processing
|
|
189
|
+
│ └── pdf_utils.py # PDF generation
|
|
190
|
+
├── pyproject.toml # Package configuration
|
|
191
|
+
├── setup.sh # Auto-installer script
|
|
192
|
+
└── README.md # This file
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT License - see LICENSE file for details.
|
|
198
|
+
|
|
199
|
+
## Acknowledgments
|
|
200
|
+
|
|
201
|
+
- [Tesseract OCR](https://github.com/tesseract-ocr/tesseract)
|
|
202
|
+
- [pdf2image](https://github.com/Belval/pdf2image)
|
|
203
|
+
- [Rich](https://github.com/Textualize/rich)
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
Part of the open-source tools collection by [Sameer Bajaj](https://sameerbajaj.com). Learn more on the [project page](https://sameerbajaj.com/tools/pdf-ocr/).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pdf_ocr_cli.py,sha256=0W2EcuZs3tOv_PHb69LUHatVRvgCj6HMKk-NgBF0WoM,120
|
|
2
|
+
pdf_ocr_cli-1.0.0.dist-info/licenses/LICENSE,sha256=aZ-A1VSgJSeGP6bz-0r83Z8HQsL1JBVJe0uFslVEteY,1069
|
|
3
|
+
pdf_ocr_tool/__init__.py,sha256=-j55rkLwGT5e5MswGfEv7fKSdwsj2zDh2fDP0jCy7d8,79
|
|
4
|
+
pdf_ocr_tool/cli.py,sha256=J2BqPScBXK9ncAZRyyln7g9nTwkE73O8lUAABJZHsDE,11834
|
|
5
|
+
pdf_ocr_tool/ocr.py,sha256=fF9XuGswySWE6aX9WiNzXgVEPioiWNV7ItB05ldQjak,4246
|
|
6
|
+
pdf_ocr_tool/pdf_utils.py,sha256=BcLptPpHh3_nIGKlNxHvsgYG-u7QMHYXj0C0qap6m0E,4666
|
|
7
|
+
pdf_ocr_cli-1.0.0.dist-info/METADATA,sha256=POW_pYAnMlNZ3DfO6rkSbuWlekUGUPi5AxgyJ1oj95k,4995
|
|
8
|
+
pdf_ocr_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
pdf_ocr_cli-1.0.0.dist-info/entry_points.txt,sha256=Ssqm_7BdI8gUC8W4aNmC_ueO9d6vJMLs1EPjKvi9pwI,86
|
|
10
|
+
pdf_ocr_cli-1.0.0.dist-info/top_level.txt,sha256=gajixe6sp_le5k_Qh1ESc4y1_pm1UVVH_mytuxzzE6E,25
|
|
11
|
+
pdf_ocr_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sameer Bajaj
|
|
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.
|
pdf_ocr_cli.py
ADDED
pdf_ocr_tool/__init__.py
ADDED
pdf_ocr_tool/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Command-line interface for PDF OCR tool."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich import print as rprint
|
|
15
|
+
|
|
16
|
+
from .ocr import OCRProcessor
|
|
17
|
+
from .pdf_utils import create_searchable_pdf, save_text_files
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_page_range(page_range_str: str) -> Tuple[int, int]:
|
|
24
|
+
"""
|
|
25
|
+
Parse page range string like '4-5', '1-10', '5' into tuple.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
page_range_str: String like "4-5" or "4" or "1-10"
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (start_page, end_page)
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If format is invalid
|
|
35
|
+
"""
|
|
36
|
+
page_range_str = page_range_str.strip()
|
|
37
|
+
|
|
38
|
+
if '-' in page_range_str:
|
|
39
|
+
parts = page_range_str.split('-')
|
|
40
|
+
if len(parts) != 2:
|
|
41
|
+
raise ValueError("Invalid page range format. Use 'start-end' like '4-5' or '1-10'")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
start = int(parts[0].strip())
|
|
45
|
+
end = int(parts[1].strip())
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise ValueError("Page numbers must be integers")
|
|
48
|
+
|
|
49
|
+
if start < 1 or end < 1:
|
|
50
|
+
raise ValueError("Page numbers must be positive (starting from 1)")
|
|
51
|
+
|
|
52
|
+
if start > end:
|
|
53
|
+
raise ValueError(f"Start page ({start}) must be less than or equal to end page ({end})")
|
|
54
|
+
|
|
55
|
+
return (start, end)
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
page = int(page_range_str)
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise ValueError("Invalid page number. Must be an integer")
|
|
61
|
+
|
|
62
|
+
if page < 1:
|
|
63
|
+
raise ValueError("Page numbers must be positive (starting from 1)")
|
|
64
|
+
|
|
65
|
+
return (page, page)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalize_path(path_str: str) -> Path:
|
|
69
|
+
"""
|
|
70
|
+
Normalize file path, handling backslashes and spaces.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path_str: Input path string (may contain backslashes from copy-paste)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Normalized Path object
|
|
77
|
+
"""
|
|
78
|
+
# Remove backslashes used as escape characters (common when copy-pasting macOS paths)
|
|
79
|
+
path_str = path_str.replace('\\ ', ' ')
|
|
80
|
+
|
|
81
|
+
# Expand user home directory
|
|
82
|
+
path_str = os.path.expanduser(path_str)
|
|
83
|
+
|
|
84
|
+
# Convert to Path and resolve
|
|
85
|
+
return Path(path_str).resolve()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_pdf_path(pdf_path: Path) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Validate that PDF file exists and is readable.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
pdf_path: Path to PDF file
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
click.ClickException: If file is invalid
|
|
97
|
+
"""
|
|
98
|
+
if not pdf_path.exists():
|
|
99
|
+
raise click.ClickException(f"File not found: {pdf_path}")
|
|
100
|
+
|
|
101
|
+
if not pdf_path.is_file():
|
|
102
|
+
raise click.ClickException(f"Not a file: {pdf_path}")
|
|
103
|
+
|
|
104
|
+
if pdf_path.suffix.lower() != '.pdf':
|
|
105
|
+
console.print("[yellow]Warning: File doesn't have .pdf extension[/yellow]")
|
|
106
|
+
|
|
107
|
+
if not os.access(pdf_path, os.R_OK):
|
|
108
|
+
raise click.ClickException(f"File not readable: {pdf_path}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@click.command()
|
|
112
|
+
@click.argument('pdf_path', type=str, required=False)
|
|
113
|
+
@click.option(
|
|
114
|
+
'--pages', '-p',
|
|
115
|
+
type=str,
|
|
116
|
+
help='Page range to process (e.g., "4-5", "1-10", "7"). Leave empty for all pages.'
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
'--output', '-o',
|
|
120
|
+
type=str,
|
|
121
|
+
help='Output PDF path (default: adds "_ocr" suffix to input filename)'
|
|
122
|
+
)
|
|
123
|
+
@click.option(
|
|
124
|
+
'--dpi',
|
|
125
|
+
type=int,
|
|
126
|
+
default=300,
|
|
127
|
+
help='DPI for image conversion (default: 300, higher = better quality but slower)'
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
'--save-text/--no-save-text',
|
|
131
|
+
default=False,
|
|
132
|
+
help='Also save extracted text to .txt files'
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
'--output-dir',
|
|
136
|
+
type=str,
|
|
137
|
+
help='Directory for output files (default: same as input PDF)'
|
|
138
|
+
)
|
|
139
|
+
@click.version_option(version="1.0.0", prog_name="pdf-ocr")
|
|
140
|
+
def main(
|
|
141
|
+
pdf_path: Optional[str],
|
|
142
|
+
pages: Optional[str],
|
|
143
|
+
output: Optional[str],
|
|
144
|
+
dpi: int,
|
|
145
|
+
save_text: bool,
|
|
146
|
+
output_dir: Optional[str]
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
High-quality OCR tool for scanned PDFs using Tesseract.
|
|
150
|
+
|
|
151
|
+
Convert scanned PDFs into searchable PDFs with selectable text.
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
|
|
155
|
+
# Interactive mode
|
|
156
|
+
pdf-ocr
|
|
157
|
+
|
|
158
|
+
# Process entire PDF
|
|
159
|
+
pdf-ocr document.pdf
|
|
160
|
+
|
|
161
|
+
# Process specific pages
|
|
162
|
+
pdf-ocr document.pdf --pages 4-5
|
|
163
|
+
pdf-ocr document.pdf -p 1-10
|
|
164
|
+
|
|
165
|
+
# Process with custom output
|
|
166
|
+
pdf-ocr document.pdf --output result.pdf
|
|
167
|
+
|
|
168
|
+
# Process with text files
|
|
169
|
+
pdf-ocr document.pdf --save-text
|
|
170
|
+
"""
|
|
171
|
+
console.print(Panel.fit(
|
|
172
|
+
"[bold cyan]PDF OCR Tool[/bold cyan]\n"
|
|
173
|
+
"High-quality OCR for scanned PDFs",
|
|
174
|
+
border_style="cyan"
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
# Interactive mode if no PDF path provided
|
|
178
|
+
if not pdf_path:
|
|
179
|
+
console.print("\n[bold cyan]Step 1: Select your PDF file[/bold cyan]")
|
|
180
|
+
console.print(" • You can type the full path to your PDF")
|
|
181
|
+
console.print(" • Or drag and drop the PDF file into this terminal")
|
|
182
|
+
console.print(" • Press Enter when done\n")
|
|
183
|
+
pdf_path = input("📄 PDF file path: ").strip()
|
|
184
|
+
|
|
185
|
+
if not pdf_path:
|
|
186
|
+
console.print("[red]✗ No PDF path provided. Exiting.[/red]")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
# Normalize and validate PDF path
|
|
190
|
+
try:
|
|
191
|
+
pdf_path_obj = normalize_path(pdf_path)
|
|
192
|
+
validate_pdf_path(pdf_path_obj)
|
|
193
|
+
except click.ClickException as e:
|
|
194
|
+
console.print(f"[red]Error: {e.message}[/red]")
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
console.print(f"\n[green]✓[/green] Found PDF: [bold]{pdf_path_obj.name}[/bold]")
|
|
198
|
+
|
|
199
|
+
# Interactive page range if not provided
|
|
200
|
+
page_range = None
|
|
201
|
+
if pages:
|
|
202
|
+
try:
|
|
203
|
+
page_range = parse_page_range(pages)
|
|
204
|
+
console.print(f"[green]✓[/green] Processing pages: [bold]{page_range[0]}-{page_range[1]}[/bold]")
|
|
205
|
+
except ValueError as e:
|
|
206
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
else:
|
|
209
|
+
console.print("\n[bold cyan]Step 2: Choose which pages to process[/bold cyan]")
|
|
210
|
+
console.print(" • Enter a page range like: [bold]4-5[/bold] (pages 4 through 5)")
|
|
211
|
+
console.print(" • Or a single page like: [bold]7[/bold] (just page 7)")
|
|
212
|
+
console.print(" • Or press [bold]Enter[/bold] to process the entire PDF\n")
|
|
213
|
+
console.print("[dim]Examples: 4-5, 1-10, 20-25, or just 7[/dim]\n")
|
|
214
|
+
pages_input = input("📖 Which pages? (press Enter for all): ").strip()
|
|
215
|
+
|
|
216
|
+
if pages_input:
|
|
217
|
+
try:
|
|
218
|
+
page_range = parse_page_range(pages_input)
|
|
219
|
+
console.print(f"\n[green]✓[/green] Will process pages: [bold]{page_range[0]} to {page_range[1]}[/bold]")
|
|
220
|
+
except ValueError as e:
|
|
221
|
+
console.print(f"[red]✗ Error: {e}[/red]")
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
else:
|
|
224
|
+
console.print("\n[green]✓[/green] Will process [bold]all pages[/bold] in the PDF")
|
|
225
|
+
|
|
226
|
+
# Determine output directory
|
|
227
|
+
if output_dir:
|
|
228
|
+
out_dir = Path(output_dir).resolve()
|
|
229
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
else:
|
|
231
|
+
out_dir = pdf_path_obj.parent
|
|
232
|
+
|
|
233
|
+
# Determine output PDF path
|
|
234
|
+
if output:
|
|
235
|
+
output_pdf = normalize_path(output)
|
|
236
|
+
else:
|
|
237
|
+
# Generate output filename
|
|
238
|
+
base_name = pdf_path_obj.stem
|
|
239
|
+
if page_range:
|
|
240
|
+
suffix = f"_pages{page_range[0]}-{page_range[1]}_ocr"
|
|
241
|
+
else:
|
|
242
|
+
suffix = "_ocr"
|
|
243
|
+
output_pdf = out_dir / f"{base_name}{suffix}.pdf"
|
|
244
|
+
|
|
245
|
+
console.print(f"[green]✓[/green] Output PDF: [bold]{output_pdf.name}[/bold]")
|
|
246
|
+
|
|
247
|
+
# Initialize OCR processor
|
|
248
|
+
processor = OCRProcessor(dpi=dpi)
|
|
249
|
+
|
|
250
|
+
# Check dependencies
|
|
251
|
+
console.print("\n[bold]Checking dependencies...[/bold]")
|
|
252
|
+
success, message = processor.check_dependencies()
|
|
253
|
+
if not success:
|
|
254
|
+
console.print(f"[red]✗ {message}[/red]")
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
console.print("[green]✓ All dependencies installed[/green]")
|
|
257
|
+
|
|
258
|
+
# Process PDF
|
|
259
|
+
console.print("\n[bold cyan]Starting OCR processing...[/bold cyan]")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
with Progress(
|
|
263
|
+
SpinnerColumn(),
|
|
264
|
+
TextColumn("[progress.description]{task.description}"),
|
|
265
|
+
BarColumn(),
|
|
266
|
+
TaskProgressColumn(),
|
|
267
|
+
console=console
|
|
268
|
+
) as progress:
|
|
269
|
+
|
|
270
|
+
# Convert PDF to images
|
|
271
|
+
task1 = progress.add_task(
|
|
272
|
+
f"[cyan]Converting PDF to images (DPI: {dpi})...",
|
|
273
|
+
total=None
|
|
274
|
+
)
|
|
275
|
+
images = processor.convert_pdf_to_images(str(pdf_path_obj), page_range)
|
|
276
|
+
progress.update(task1, completed=True)
|
|
277
|
+
console.print(f"[green]✓[/green] Converted [bold]{len(images)}[/bold] page(s) to images")
|
|
278
|
+
|
|
279
|
+
# Perform OCR
|
|
280
|
+
task2 = progress.add_task(
|
|
281
|
+
"[cyan]Performing OCR on pages...",
|
|
282
|
+
total=len(images)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
ocr_results = []
|
|
286
|
+
for idx, image in enumerate(images):
|
|
287
|
+
progress.update(task2, advance=1, description=f"[cyan]OCR on page {idx + 1}/{len(images)}...")
|
|
288
|
+
ocr_text = processor.ocr_image(image)
|
|
289
|
+
ocr_results.append((image, ocr_text))
|
|
290
|
+
|
|
291
|
+
console.print(f"[green]✓[/green] OCR completed for [bold]{len(images)}[/bold] page(s)")
|
|
292
|
+
|
|
293
|
+
# Create searchable PDF
|
|
294
|
+
task3 = progress.add_task(
|
|
295
|
+
"[cyan]Creating searchable PDF...",
|
|
296
|
+
total=len(images)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def pdf_progress(current, total):
|
|
300
|
+
progress.update(task3, completed=current)
|
|
301
|
+
|
|
302
|
+
create_searchable_pdf(images, str(output_pdf), pdf_progress)
|
|
303
|
+
console.print(f"[green]✓[/green] Created searchable PDF: [bold]{output_pdf}[/bold]")
|
|
304
|
+
|
|
305
|
+
# Save text files if requested
|
|
306
|
+
if save_text:
|
|
307
|
+
task4 = progress.add_task("[cyan]Saving text files...", total=None)
|
|
308
|
+
start_page = page_range[0] if page_range else 1
|
|
309
|
+
text_files = save_text_files(
|
|
310
|
+
ocr_results,
|
|
311
|
+
str(out_dir),
|
|
312
|
+
pdf_path_obj.stem,
|
|
313
|
+
start_page
|
|
314
|
+
)
|
|
315
|
+
progress.update(task4, completed=True)
|
|
316
|
+
console.print(f"[green]✓[/green] Saved [bold]{len(text_files)}[/bold] text file(s)")
|
|
317
|
+
|
|
318
|
+
# Summary
|
|
319
|
+
file_size = output_pdf.stat().st_size / (1024 * 1024) # MB
|
|
320
|
+
|
|
321
|
+
console.print("\n" + "="*60)
|
|
322
|
+
console.print(Panel.fit(
|
|
323
|
+
f"[bold green]✓ OCR Complete![/bold green]\n\n"
|
|
324
|
+
f"[bold]Output PDF:[/bold] {output_pdf}\n"
|
|
325
|
+
f"[bold]File size:[/bold] {file_size:.2f} MB\n"
|
|
326
|
+
f"[bold]Pages processed:[/bold] {len(images)}\n"
|
|
327
|
+
f"[bold]Status:[/bold] Searchable & selectable text ✓",
|
|
328
|
+
border_style="green",
|
|
329
|
+
title="Success"
|
|
330
|
+
))
|
|
331
|
+
|
|
332
|
+
# Show sample text
|
|
333
|
+
if ocr_results:
|
|
334
|
+
sample_text = ocr_results[0][1][:200].strip()
|
|
335
|
+
if sample_text:
|
|
336
|
+
console.print(f"\n[dim]Sample text from first page:[/dim]")
|
|
337
|
+
console.print(f"[dim]{sample_text}...[/dim]")
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
console.print(f"\n[red]✗ Error during processing:[/red]")
|
|
341
|
+
console.print(f"[red]{str(e)}[/red]")
|
|
342
|
+
|
|
343
|
+
if "poppler" in str(e).lower():
|
|
344
|
+
console.print("\n[yellow]Install poppler with:[/yellow]")
|
|
345
|
+
console.print(" macOS: [bold]brew install poppler[/bold]")
|
|
346
|
+
console.print(" Linux: [bold]apt-get install poppler-utils[/bold]")
|
|
347
|
+
|
|
348
|
+
sys.exit(1)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == '__main__':
|
|
352
|
+
main()
|
pdf_ocr_tool/ocr.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Core OCR processing functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
from PIL import Image
|
|
7
|
+
import pytesseract
|
|
8
|
+
from pdf2image import convert_from_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OCRProcessor:
|
|
12
|
+
"""Handle OCR processing for PDF pages."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, dpi: int = 300, tesseract_config: str = '--oem 3 --psm 3'):
|
|
15
|
+
"""
|
|
16
|
+
Initialize OCR processor.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
dpi: Resolution for PDF to image conversion (default: 300)
|
|
20
|
+
tesseract_config: Tesseract configuration string
|
|
21
|
+
"""
|
|
22
|
+
self.dpi = dpi
|
|
23
|
+
self.tesseract_config = tesseract_config
|
|
24
|
+
|
|
25
|
+
def check_dependencies(self) -> Tuple[bool, str]:
|
|
26
|
+
"""
|
|
27
|
+
Check if required system dependencies are installed.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Tuple of (success: bool, message: str)
|
|
31
|
+
"""
|
|
32
|
+
# Check Tesseract
|
|
33
|
+
try:
|
|
34
|
+
pytesseract.get_tesseract_version()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
return False, (
|
|
37
|
+
"Tesseract OCR is not installed or not in PATH.\n"
|
|
38
|
+
"Install it with: brew install tesseract (macOS) or apt-get install tesseract-ocr (Linux)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Check poppler (required by pdf2image)
|
|
42
|
+
try:
|
|
43
|
+
# This will fail if poppler is not installed
|
|
44
|
+
from pdf2image.exceptions import PDFInfoNotInstalledError
|
|
45
|
+
# Try a simple conversion to check if poppler works
|
|
46
|
+
# We'll catch the error if it's not installed
|
|
47
|
+
except ImportError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
return True, "All dependencies are installed."
|
|
51
|
+
|
|
52
|
+
def convert_pdf_to_images(
|
|
53
|
+
self,
|
|
54
|
+
pdf_path: str,
|
|
55
|
+
page_range: Tuple[int, int] = None
|
|
56
|
+
) -> List[Image.Image]:
|
|
57
|
+
"""
|
|
58
|
+
Convert PDF pages to images.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
pdf_path: Path to PDF file
|
|
62
|
+
page_range: Tuple of (start_page, end_page) or None for all pages
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of PIL Image objects
|
|
66
|
+
"""
|
|
67
|
+
kwargs = {'dpi': self.dpi}
|
|
68
|
+
|
|
69
|
+
if page_range:
|
|
70
|
+
kwargs['first_page'] = page_range[0]
|
|
71
|
+
kwargs['last_page'] = page_range[1]
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
images = convert_from_path(pdf_path, **kwargs)
|
|
75
|
+
return images
|
|
76
|
+
except Exception as e:
|
|
77
|
+
if "poppler" in str(e).lower() or "pdftoppm" in str(e).lower():
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"Poppler is not installed or not in PATH.\n"
|
|
80
|
+
"Install it with: brew install poppler (macOS) or apt-get install poppler-utils (Linux)"
|
|
81
|
+
)
|
|
82
|
+
raise
|
|
83
|
+
|
|
84
|
+
def ocr_image(self, image: Image.Image) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Perform OCR on a single image.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
image: PIL Image object
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Extracted text as string
|
|
93
|
+
"""
|
|
94
|
+
return pytesseract.image_to_string(image, config=self.tesseract_config)
|
|
95
|
+
|
|
96
|
+
def ocr_image_to_pdf(self, image: Image.Image) -> bytes:
|
|
97
|
+
"""
|
|
98
|
+
Perform OCR on image and return PDF bytes with text layer.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
image: PIL Image object
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
PDF bytes with searchable text layer
|
|
105
|
+
"""
|
|
106
|
+
return pytesseract.image_to_pdf_or_hocr(image, extension='pdf')
|
|
107
|
+
|
|
108
|
+
def process_pdf(
|
|
109
|
+
self,
|
|
110
|
+
pdf_path: str,
|
|
111
|
+
page_range: Tuple[int, int] = None,
|
|
112
|
+
progress_callback=None
|
|
113
|
+
) -> List[Tuple[Image.Image, str]]:
|
|
114
|
+
"""
|
|
115
|
+
Process PDF and return images with OCR text.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
pdf_path: Path to PDF file
|
|
119
|
+
page_range: Tuple of (start_page, end_page) or None for all pages
|
|
120
|
+
progress_callback: Optional callback function for progress updates
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of tuples (image, ocr_text)
|
|
124
|
+
"""
|
|
125
|
+
images = self.convert_pdf_to_images(pdf_path, page_range)
|
|
126
|
+
results = []
|
|
127
|
+
|
|
128
|
+
for idx, image in enumerate(images):
|
|
129
|
+
if progress_callback:
|
|
130
|
+
progress_callback(idx + 1, len(images))
|
|
131
|
+
|
|
132
|
+
ocr_text = self.ocr_image(image)
|
|
133
|
+
results.append((image, ocr_text))
|
|
134
|
+
|
|
135
|
+
return results
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""PDF generation utilities."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
from PIL import Image
|
|
7
|
+
import pytesseract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_searchable_pdf(
|
|
11
|
+
images: List[Image.Image],
|
|
12
|
+
output_path: str,
|
|
13
|
+
progress_callback=None
|
|
14
|
+
) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Create a searchable PDF from images with OCR text layer.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
images: List of PIL Image objects
|
|
20
|
+
output_path: Path for output PDF file
|
|
21
|
+
progress_callback: Optional callback for progress updates
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path to created PDF file
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Use PyPDF2 to merge multiple pages
|
|
28
|
+
from PyPDF2 import PdfMerger
|
|
29
|
+
except ImportError:
|
|
30
|
+
# Fallback: create single-page or multi-page PDF directly
|
|
31
|
+
return _create_pdf_direct(images, output_path, progress_callback)
|
|
32
|
+
|
|
33
|
+
merger = PdfMerger()
|
|
34
|
+
temp_files = []
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
for idx, image in enumerate(images):
|
|
38
|
+
if progress_callback:
|
|
39
|
+
progress_callback(idx + 1, len(images))
|
|
40
|
+
|
|
41
|
+
# Create temporary PDF for each page
|
|
42
|
+
temp_pdf = f".temp_page_{idx}_{os.getpid()}.pdf"
|
|
43
|
+
temp_files.append(temp_pdf)
|
|
44
|
+
|
|
45
|
+
# Generate searchable PDF using pytesseract
|
|
46
|
+
pdf_data = pytesseract.image_to_pdf_or_hocr(image, extension='pdf')
|
|
47
|
+
|
|
48
|
+
with open(temp_pdf, 'wb') as f:
|
|
49
|
+
f.write(pdf_data)
|
|
50
|
+
|
|
51
|
+
merger.append(temp_pdf)
|
|
52
|
+
|
|
53
|
+
# Write the merged PDF
|
|
54
|
+
merger.write(output_path)
|
|
55
|
+
merger.close()
|
|
56
|
+
|
|
57
|
+
finally:
|
|
58
|
+
# Clean up temporary files
|
|
59
|
+
for temp_file in temp_files:
|
|
60
|
+
try:
|
|
61
|
+
if os.path.exists(temp_file):
|
|
62
|
+
os.remove(temp_file)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
return output_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _create_pdf_direct(
|
|
70
|
+
images: List[Image.Image],
|
|
71
|
+
output_path: str,
|
|
72
|
+
progress_callback=None
|
|
73
|
+
) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Create PDF directly when PyPDF2 is not available.
|
|
76
|
+
|
|
77
|
+
For single page PDFs or fallback method.
|
|
78
|
+
"""
|
|
79
|
+
if len(images) == 1:
|
|
80
|
+
# Single page - direct conversion
|
|
81
|
+
if progress_callback:
|
|
82
|
+
progress_callback(1, 1)
|
|
83
|
+
|
|
84
|
+
pdf_data = pytesseract.image_to_pdf_or_hocr(images[0], extension='pdf')
|
|
85
|
+
with open(output_path, 'wb') as f:
|
|
86
|
+
f.write(pdf_data)
|
|
87
|
+
else:
|
|
88
|
+
# Multiple pages - need to combine somehow
|
|
89
|
+
# This is a fallback - try using img2pdf if available
|
|
90
|
+
try:
|
|
91
|
+
import img2pdf
|
|
92
|
+
|
|
93
|
+
# Convert images to bytes
|
|
94
|
+
image_bytes_list = []
|
|
95
|
+
for idx, img in enumerate(images):
|
|
96
|
+
if progress_callback:
|
|
97
|
+
progress_callback(idx + 1, len(images))
|
|
98
|
+
|
|
99
|
+
# Create OCR PDF for this page
|
|
100
|
+
pdf_bytes = pytesseract.image_to_pdf_or_hocr(img, extension='pdf')
|
|
101
|
+
image_bytes_list.append(pdf_bytes)
|
|
102
|
+
|
|
103
|
+
# For now, just save the first one with a warning
|
|
104
|
+
with open(output_path, 'wb') as f:
|
|
105
|
+
f.write(image_bytes_list[0])
|
|
106
|
+
|
|
107
|
+
print("Warning: PyPDF2 not available. Only first page saved.")
|
|
108
|
+
print("Install PyPDF2 for multi-page PDF support: uv pip install PyPDF2")
|
|
109
|
+
|
|
110
|
+
except ImportError:
|
|
111
|
+
# No merge capability - save first page only
|
|
112
|
+
pdf_data = pytesseract.image_to_pdf_or_hocr(images[0], extension='pdf')
|
|
113
|
+
with open(output_path, 'wb') as f:
|
|
114
|
+
f.write(pdf_data)
|
|
115
|
+
|
|
116
|
+
print("Warning: Limited PDF support. Only first page saved.")
|
|
117
|
+
print("Install PyPDF2 for multi-page PDF support: uv pip install PyPDF2")
|
|
118
|
+
|
|
119
|
+
return output_path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_text_files(
|
|
123
|
+
ocr_results: List[tuple],
|
|
124
|
+
output_dir: str,
|
|
125
|
+
base_name: str,
|
|
126
|
+
start_page: int = 1
|
|
127
|
+
) -> List[str]:
|
|
128
|
+
"""
|
|
129
|
+
Save OCR text to individual text files.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
ocr_results: List of (image, text) tuples
|
|
133
|
+
output_dir: Directory to save text files
|
|
134
|
+
base_name: Base name for output files
|
|
135
|
+
start_page: Starting page number for naming
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of created file paths
|
|
139
|
+
"""
|
|
140
|
+
output_dir = Path(output_dir)
|
|
141
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
created_files = []
|
|
144
|
+
|
|
145
|
+
for idx, (image, text) in enumerate(ocr_results):
|
|
146
|
+
page_num = start_page + idx
|
|
147
|
+
text_file = output_dir / f"{base_name}_page{page_num}.txt"
|
|
148
|
+
|
|
149
|
+
with open(text_file, 'w', encoding='utf-8') as f:
|
|
150
|
+
f.write(text)
|
|
151
|
+
|
|
152
|
+
created_files.append(str(text_file))
|
|
153
|
+
|
|
154
|
+
return created_files
|