Pointf5ive commited on
Commit
c2696f5
Β·
verified Β·
1 Parent(s): ff5a848

Add OCR fallback for scanned PDFs

Browse files
Files changed (1) hide show
  1. src/codex_extractor.py +104 -3
src/codex_extractor.py CHANGED
@@ -30,6 +30,12 @@ Use Prompt 2 (ChatGPT/Gemini) for those β€” see Codex Build Prompts document.
30
  Dependencies (add to requirements.txt):
31
  pdfplumber>=0.10
32
  nltk>=3.8
 
 
 
 
 
 
33
 
34
  NLTK data required (auto-downloaded on first run):
35
  punkt, punkt_tab, averaged_perceptron_tagger, cmudict, stopwords
@@ -46,6 +52,7 @@ import string
46
  import os
47
  from collections import Counter
48
  from pathlib import Path
 
49
  from typing import Any
50
 
51
  # ── OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ──────────────────────────────────
@@ -56,6 +63,20 @@ try:
56
  except ImportError:
57
  PDF_AVAILABLE = False
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  try:
60
  import nltk
61
  # Auto-download required NLTK data if not present
@@ -109,17 +130,81 @@ QUESTION_PATTERN = re.compile(r"\?")
109
 
110
  # ── TEXT EXTRACTION ───────────────────────────────────────────────────────────
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  def extract_text_from_pdf(pdf_path: str | Path) -> str:
113
- """Extract all text from a PDF file using pdfplumber."""
 
 
 
 
114
  if not PDF_AVAILABLE:
115
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
 
116
  text_parts = []
117
  with pdfplumber.open(str(pdf_path)) as pdf:
118
  for page in pdf.pages:
119
  page_text = page.extract_text()
120
  if page_text:
121
  text_parts.append(page_text)
122
- return "\n".join(text_parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
 
125
  def extract_text_from_file(file_path: str | Path) -> str:
@@ -691,7 +776,11 @@ def process_upload(
691
  try:
692
  raw_text = extract_text_from_file(file_path)
693
  if not raw_text or len(raw_text.split()) < 20:
694
- return "ERROR: No usable text extracted from file. Check the PDF contains selectable text (not scanned images).", {}
 
 
 
 
695
  fp = extract_fingerprint(
696
  text=raw_text,
697
  author_name=author_name,
@@ -700,6 +789,18 @@ def process_upload(
700
  )
701
  report = format_fingerprint_report(fp)
702
  return report, fp
 
 
 
 
 
 
 
 
 
 
 
 
703
  except Exception as e:
704
  return f"ERROR: {type(e).__name__}: {str(e)}", {}
705
 
 
30
  Dependencies (add to requirements.txt):
31
  pdfplumber>=0.10
32
  nltk>=3.8
33
+ pypdfium2>=4.30
34
+ pytesseract>=0.3.10
35
+
36
+ System dependency for OCR in Hugging Face Space (packages.txt):
37
+ tesseract-ocr
38
+ tesseract-ocr-eng
39
 
40
  NLTK data required (auto-downloaded on first run):
41
  punkt, punkt_tab, averaged_perceptron_tagger, cmudict, stopwords
 
52
  import os
53
  from collections import Counter
54
  from pathlib import Path
55
+ from shutil import which
56
  from typing import Any
57
 
58
  # ── OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ──────────────────────────────────
 
63
  except ImportError:
64
  PDF_AVAILABLE = False
65
 
66
+ try:
67
+ import pypdfium2 as pdfium
68
+ PDFIUM_AVAILABLE = True
69
+ except Exception:
70
+ PDFIUM_AVAILABLE = False
71
+
72
+ try:
73
+ import pytesseract
74
+ from pytesseract import TesseractNotFoundError
75
+ PYTESSERACT_AVAILABLE = True
76
+ except Exception:
77
+ PYTESSERACT_AVAILABLE = False
78
+ TesseractNotFoundError = RuntimeError # type: ignore[assignment]
79
+
80
  try:
81
  import nltk
82
  # Auto-download required NLTK data if not present
 
130
 
131
  # ── TEXT EXTRACTION ───────────────────────────────────────────────────────────
132
 
133
+ def _word_count(text: str) -> int:
134
+ """Count alpha-ish tokens quickly for extraction health checks."""
135
+ return len(SIMPLE_TOKENISE_PATTERN.findall(text.lower()))
136
+
137
+
138
+ def _ocr_runtime_ready() -> bool:
139
+ """Return True when OCR fallback dependencies and binary are available."""
140
+ if not PDFIUM_AVAILABLE or not PYTESSERACT_AVAILABLE:
141
+ return False
142
+ return which("tesseract") is not None
143
+
144
+
145
+ def extract_text_from_pdf_ocr(pdf_path: str | Path) -> str:
146
+ """
147
+ OCR fallback for image-based PDFs.
148
+ Renders pages to images, then runs Tesseract OCR.
149
+ """
150
+ if not PDFIUM_AVAILABLE:
151
+ raise RuntimeError("OCR fallback unavailable: pypdfium2 is not installed.")
152
+ if not PYTESSERACT_AVAILABLE:
153
+ raise RuntimeError("OCR fallback unavailable: pytesseract is not installed.")
154
+ if which("tesseract") is None:
155
+ raise RuntimeError("OCR fallback unavailable: tesseract binary is not installed on this runtime.")
156
+
157
+ render_scale = float(os.getenv("OCR_RENDER_SCALE", "2.0"))
158
+ max_pages = int(os.getenv("OCR_MAX_PAGES", "200"))
159
+ ocr_lang = os.getenv("OCR_LANG", "eng")
160
+ ocr_config = os.getenv("OCR_CONFIG", "--oem 1 --psm 6")
161
+
162
+ text_parts: list[str] = []
163
+ pdf = pdfium.PdfDocument(str(pdf_path))
164
+ page_count = min(len(pdf), max_pages)
165
+
166
+ for page_index in range(page_count):
167
+ page = pdf[page_index]
168
+ bitmap = page.render(scale=render_scale)
169
+ pil_image = bitmap.to_pil()
170
+ page_text = pytesseract.image_to_string(pil_image, lang=ocr_lang, config=ocr_config)
171
+ if page_text and page_text.strip():
172
+ text_parts.append(page_text)
173
+
174
+ return "\n".join(text_parts)
175
+
176
+
177
  def extract_text_from_pdf(pdf_path: str | Path) -> str:
178
+ """
179
+ Extract text from a PDF:
180
+ 1) native selectable text layer
181
+ 2) OCR fallback if little/no selectable text is present
182
+ """
183
  if not PDF_AVAILABLE:
184
  raise RuntimeError("pdfplumber is not installed. Add it to requirements.txt.")
185
+
186
  text_parts = []
187
  with pdfplumber.open(str(pdf_path)) as pdf:
188
  for page in pdf.pages:
189
  page_text = page.extract_text()
190
  if page_text:
191
  text_parts.append(page_text)
192
+ extracted = "\n".join(text_parts)
193
+
194
+ # If selectable text exists, use it immediately.
195
+ if _word_count(extracted) >= 20:
196
+ return extracted
197
+
198
+ # Scanned/image-only PDFs fall back to OCR.
199
+ try:
200
+ ocr_text = extract_text_from_pdf_ocr(pdf_path)
201
+ except Exception as ocr_error:
202
+ raise RuntimeError(
203
+ "No selectable text detected in PDF and OCR fallback failed. "
204
+ f"Reason: {ocr_error}"
205
+ ) from ocr_error
206
+
207
+ return ocr_text
208
 
209
 
210
  def extract_text_from_file(file_path: str | Path) -> str:
 
776
  try:
777
  raw_text = extract_text_from_file(file_path)
778
  if not raw_text or len(raw_text.split()) < 20:
779
+ return (
780
+ "ERROR: No usable text extracted from file. "
781
+ "If this is a scanned/image PDF, OCR could not recover enough text.",
782
+ {},
783
+ )
784
  fp = extract_fingerprint(
785
  text=raw_text,
786
  author_name=author_name,
 
789
  )
790
  report = format_fingerprint_report(fp)
791
  return report, fp
792
+ except RuntimeError as e:
793
+ msg = str(e)
794
+ if "OCR fallback failed" in msg:
795
+ if not _ocr_runtime_ready():
796
+ return (
797
+ "ERROR: This PDF appears image-based, but OCR runtime is not available yet. "
798
+ "Install OCR dependencies (pytesseract, pypdfium2) and system package "
799
+ "`tesseract-ocr` in the Space, then retry.",
800
+ {},
801
+ )
802
+ return f"ERROR: OCR was attempted but failed: {msg}", {}
803
+ return f"ERROR: {msg}", {}
804
  except Exception as e:
805
  return f"ERROR: {type(e).__name__}: {str(e)}", {}
806