Spaces:
Sleeping
Sleeping
Restore latest extractor with OCR fallback
Browse files- src/codex_extractor.py +102 -3
src/codex_extractor.py
CHANGED
|
@@ -69,6 +69,7 @@ import re
|
|
| 69 |
import string
|
| 70 |
from collections import Counter
|
| 71 |
from pathlib import Path
|
|
|
|
| 72 |
from typing import Any
|
| 73 |
|
| 74 |
# ββ OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ββββββββββββββββββββββββββββββββββ
|
|
@@ -79,6 +80,18 @@ try:
|
|
| 79 |
except ImportError:
|
| 80 |
PDF_AVAILABLE = False
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
try:
|
| 83 |
import nltk
|
| 84 |
_NLTK_DATA = ["punkt", "punkt_tab", "averaged_perceptron_tagger", "cmudict", "stopwords"]
|
|
@@ -221,6 +234,67 @@ def is_artefact_line(line: str) -> bool:
|
|
| 221 |
|
| 222 |
# ββ TEXT EXTRACTION βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def extract_text_from_pdf(
|
| 225 |
pdf_path: str | Path,
|
| 226 |
start_page: int | None = None,
|
|
@@ -270,7 +344,7 @@ def extract_text_from_pdf(
|
|
| 270 |
|
| 271 |
# Automatic front-matter detection (Fix 2).
|
| 272 |
# Always skip the first page (cover).
|
| 273 |
-
if page_idx == 0:
|
| 274 |
continue
|
| 275 |
if is_front_matter_page(page_text):
|
| 276 |
continue
|
|
@@ -279,6 +353,24 @@ def extract_text_from_pdf(
|
|
| 279 |
|
| 280 |
raw_text = "\n".join(raw_parts)
|
| 281 |
story_text = "\n".join(story_parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
return story_text, raw_text
|
| 283 |
|
| 284 |
|
|
@@ -1303,10 +1395,17 @@ def process_upload(
|
|
| 1303 |
)
|
| 1304 |
|
| 1305 |
if not story_text or len(story_text.split()) < 20:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1306 |
return (
|
| 1307 |
"ERROR: No usable text extracted from file. "
|
| 1308 |
-
"
|
| 1309 |
-
"
|
| 1310 |
{},
|
| 1311 |
)
|
| 1312 |
|
|
|
|
| 69 |
import string
|
| 70 |
from collections import Counter
|
| 71 |
from pathlib import Path
|
| 72 |
+
from shutil import which
|
| 73 |
from typing import Any
|
| 74 |
|
| 75 |
# ββ OPTIONAL IMPORTS WITH GRACEFUL FALLBACK ββββββββββββββββββββββββββββββββββ
|
|
|
|
| 80 |
except ImportError:
|
| 81 |
PDF_AVAILABLE = False
|
| 82 |
|
| 83 |
+
try:
|
| 84 |
+
import pypdfium2 as pdfium
|
| 85 |
+
PDFIUM_AVAILABLE = True
|
| 86 |
+
except Exception:
|
| 87 |
+
PDFIUM_AVAILABLE = False
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
import pytesseract
|
| 91 |
+
PYTESSERACT_AVAILABLE = True
|
| 92 |
+
except Exception:
|
| 93 |
+
PYTESSERACT_AVAILABLE = False
|
| 94 |
+
|
| 95 |
try:
|
| 96 |
import nltk
|
| 97 |
_NLTK_DATA = ["punkt", "punkt_tab", "averaged_perceptron_tagger", "cmudict", "stopwords"]
|
|
|
|
| 234 |
|
| 235 |
# ββ TEXT EXTRACTION βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 236 |
|
| 237 |
+
def _word_count(text: str) -> int:
|
| 238 |
+
return len(SIMPLE_TOKENISE_PATTERN.findall((text or "").lower()))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _ocr_runtime_ready() -> bool:
|
| 242 |
+
return PDFIUM_AVAILABLE and PYTESSERACT_AVAILABLE and which("tesseract") is not None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def extract_text_from_pdf_ocr(
|
| 246 |
+
pdf_path: str | Path,
|
| 247 |
+
start_page: int | None = None,
|
| 248 |
+
end_page: int | None = None,
|
| 249 |
+
) -> tuple[str, str]:
|
| 250 |
+
"""
|
| 251 |
+
OCR fallback for scanned/image-only PDFs.
|
| 252 |
+
Returns (story_text, raw_text) using the same page-filter logic as native extraction.
|
| 253 |
+
"""
|
| 254 |
+
if not PDFIUM_AVAILABLE:
|
| 255 |
+
raise RuntimeError("OCR fallback unavailable: pypdfium2 is not installed.")
|
| 256 |
+
if not PYTESSERACT_AVAILABLE:
|
| 257 |
+
raise RuntimeError("OCR fallback unavailable: pytesseract is not installed.")
|
| 258 |
+
if which("tesseract") is None:
|
| 259 |
+
raise RuntimeError("OCR fallback unavailable: tesseract binary is not installed.")
|
| 260 |
+
|
| 261 |
+
render_scale = float(os.getenv("OCR_RENDER_SCALE", "2.0"))
|
| 262 |
+
ocr_lang = os.getenv("OCR_LANG", "eng")
|
| 263 |
+
ocr_config = os.getenv("OCR_CONFIG", "--oem 1 --psm 6")
|
| 264 |
+
max_pages = int(os.getenv("OCR_MAX_PAGES", "300"))
|
| 265 |
+
|
| 266 |
+
raw_parts: list[str] = []
|
| 267 |
+
story_parts: list[str] = []
|
| 268 |
+
|
| 269 |
+
pdf = pdfium.PdfDocument(str(pdf_path))
|
| 270 |
+
total_pages = min(len(pdf), max_pages)
|
| 271 |
+
|
| 272 |
+
idx_start = (start_page - 1) if start_page is not None else 0
|
| 273 |
+
idx_end = (end_page - 1) if end_page is not None else (total_pages - 1)
|
| 274 |
+
idx_start = max(0, idx_start)
|
| 275 |
+
idx_end = min(total_pages - 1, idx_end)
|
| 276 |
+
|
| 277 |
+
for page_idx in range(total_pages):
|
| 278 |
+
page = pdf[page_idx]
|
| 279 |
+
bitmap = page.render(scale=render_scale)
|
| 280 |
+
pil_image = bitmap.to_pil()
|
| 281 |
+
page_text = pytesseract.image_to_string(pil_image, lang=ocr_lang, config=ocr_config) or ""
|
| 282 |
+
raw_parts.append(page_text)
|
| 283 |
+
|
| 284 |
+
if start_page is not None or end_page is not None:
|
| 285 |
+
if idx_start <= page_idx <= idx_end:
|
| 286 |
+
story_parts.append(page_text)
|
| 287 |
+
continue
|
| 288 |
+
|
| 289 |
+
if total_pages > 1 and page_idx == 0:
|
| 290 |
+
continue
|
| 291 |
+
if is_front_matter_page(page_text):
|
| 292 |
+
continue
|
| 293 |
+
story_parts.append(page_text)
|
| 294 |
+
|
| 295 |
+
return "\n".join(story_parts), "\n".join(raw_parts)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
def extract_text_from_pdf(
|
| 299 |
pdf_path: str | Path,
|
| 300 |
start_page: int | None = None,
|
|
|
|
| 344 |
|
| 345 |
# Automatic front-matter detection (Fix 2).
|
| 346 |
# Always skip the first page (cover).
|
| 347 |
+
if total_pages > 1 and page_idx == 0:
|
| 348 |
continue
|
| 349 |
if is_front_matter_page(page_text):
|
| 350 |
continue
|
|
|
|
| 353 |
|
| 354 |
raw_text = "\n".join(raw_parts)
|
| 355 |
story_text = "\n".join(story_parts)
|
| 356 |
+
|
| 357 |
+
# If native text-layer extraction looks healthy, use it.
|
| 358 |
+
if _word_count(story_text) >= 20:
|
| 359 |
+
return story_text, raw_text
|
| 360 |
+
|
| 361 |
+
# Otherwise, try OCR fallback for scanned/image-only pages.
|
| 362 |
+
try:
|
| 363 |
+
ocr_story_text, ocr_raw_text = extract_text_from_pdf_ocr(
|
| 364 |
+
pdf_path,
|
| 365 |
+
start_page=start_page,
|
| 366 |
+
end_page=end_page,
|
| 367 |
+
)
|
| 368 |
+
if _word_count(ocr_story_text) >= max(20, _word_count(story_text)):
|
| 369 |
+
return ocr_story_text, ocr_raw_text
|
| 370 |
+
except Exception:
|
| 371 |
+
# Fall through: process_upload will raise a clear error if text is still insufficient.
|
| 372 |
+
pass
|
| 373 |
+
|
| 374 |
return story_text, raw_text
|
| 375 |
|
| 376 |
|
|
|
|
| 1395 |
)
|
| 1396 |
|
| 1397 |
if not story_text or len(story_text.split()) < 20:
|
| 1398 |
+
if str(file_path).lower().endswith(".pdf") and not _ocr_runtime_ready():
|
| 1399 |
+
return (
|
| 1400 |
+
"ERROR: No usable text extracted from file and OCR runtime is unavailable. "
|
| 1401 |
+
"Install OCR dependencies (`pypdfium2`, `pytesseract`) and system package "
|
| 1402 |
+
"`tesseract-ocr` in the Space build.",
|
| 1403 |
+
{},
|
| 1404 |
+
)
|
| 1405 |
return (
|
| 1406 |
"ERROR: No usable text extracted from file. "
|
| 1407 |
+
"OCR fallback could not recover enough text. "
|
| 1408 |
+
"Try a cleaner scan, higher resolution pages, or a story page range.",
|
| 1409 |
{},
|
| 1410 |
)
|
| 1411 |
|