Spaces:
Runtime error
Runtime error
Add PDF extractor app and Dockerfile
Browse files- Dockerfile +35 -0
- LICENSE +21 -0
- README.md +28 -4
- app.py +308 -0
- pipeline/__init__.py +0 -0
- pipeline/analyze.py +312 -0
- pipeline/export_docx.py +96 -0
- pipeline/extractor.py +150 -0
- pipeline/handwriting.py +325 -0
- pipeline/jobs.py +529 -0
- pipeline/ocr_engine.py +251 -0
- pipeline/online_ocr.py +472 -0
- pipeline/postprocess.py +176 -0
- pipeline/preprocess.py +172 -0
- pipeline/textlayer.py +75 -0
- requirements.txt +12 -0
- static/app.js +1114 -0
- static/index.html +258 -0
- static/styles.css +427 -0
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# System libraries PaddleOCR / OpenCV need at runtime.
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
libgl1 \
|
| 6 |
+
libglib2.0-0 \
|
| 7 |
+
libgomp1 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Hugging Face Spaces runs the container as uid 1000. Create that user and a
|
| 11 |
+
# writable home so model weights and caches have somewhere to live.
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
USER user
|
| 14 |
+
ENV HOME=/home/user \
|
| 15 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 16 |
+
PYTHONUNBUFFERED=1
|
| 17 |
+
|
| 18 |
+
WORKDIR /home/user/app
|
| 19 |
+
|
| 20 |
+
# Install Python dependencies first so the layer caches across code changes.
|
| 21 |
+
COPY --chown=user requirements.txt .
|
| 22 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 23 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Pre-download the English PP-OCRv4 weights at build time so the first visitor
|
| 26 |
+
# does not wait for the download. A network blip here is non-fatal: the app
|
| 27 |
+
# downloads them on first use anyway.
|
| 28 |
+
RUN python -c "from paddleocr import PaddleOCR; PaddleOCR(use_angle_cls=True, lang='en', show_log=False)" \
|
| 29 |
+
|| echo "weight prewarm skipped; will download at runtime"
|
| 30 |
+
|
| 31 |
+
# Application code.
|
| 32 |
+
COPY --chown=user . .
|
| 33 |
+
|
| 34 |
+
EXPOSE 7860
|
| 35 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Archit
|
| 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.
|
README.md
CHANGED
|
@@ -1,10 +1,34 @@
|
|
| 1 |
---
|
| 2 |
title: PDF Extractor
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: PDF Extractor
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Extract text from typed, scanned, and handwritten PDFs.
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# PDF Extractor
|
| 14 |
+
|
| 15 |
+
A live demo of a local-first PDF text extractor. Drop in a PDF and it returns a
|
| 16 |
+
clean, searchable, exportable transcript. Pages that already have a text layer
|
| 17 |
+
are read directly; scanned image pages are run through OCR.
|
| 18 |
+
|
| 19 |
+
This Space runs on a free shared CPU, so OCR is slower than it would be on your
|
| 20 |
+
own machine and there can be a short cold-start delay when the Space wakes up.
|
| 21 |
+
The first request after a rebuild may also be slow while model weights load.
|
| 22 |
+
|
| 23 |
+
Source code and instructions for running it locally:
|
| 24 |
+
https://github.com/Archie0099/PDF-Extractor
|
| 25 |
+
|
| 26 |
+
## Notes
|
| 27 |
+
|
| 28 |
+
- Typed or born-digital pages are extracted instantly and exactly, with no OCR.
|
| 29 |
+
- Scanned pages use PaddleOCR (English and Hindi).
|
| 30 |
+
- For handwriting, the optional online OCR path (Google Gemini) is far more
|
| 31 |
+
accurate. It is off by default and needs your own free API key, which stays in
|
| 32 |
+
your browser.
|
| 33 |
+
- Nothing is stored. Uploaded files are held in memory only while a job runs and
|
| 34 |
+
are dropped afterward.
|
app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF text extraction app — FastAPI backend.
|
| 2 |
+
|
| 3 |
+
Run locally with:
|
| 4 |
+
uvicorn app:app --reload
|
| 5 |
+
(serves on http://127.0.0.1:8000)
|
| 6 |
+
|
| 7 |
+
Local and CPU-only by default. An online OCR path (Google Gemini) is optional
|
| 8 |
+
and off unless you supply your own API key.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import io
|
| 12 |
+
import json
|
| 13 |
+
import asyncio
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Body
|
| 17 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 18 |
+
from fastapi.staticfiles import StaticFiles
|
| 19 |
+
|
| 20 |
+
from pipeline.jobs import manager
|
| 21 |
+
from pipeline.ocr_engine import engine
|
| 22 |
+
from pipeline.export_docx import build_docx
|
| 23 |
+
from pipeline.analyze import suggest_settings
|
| 24 |
+
|
| 25 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 26 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 27 |
+
INDEX_HTML = STATIC_DIR / "index.html"
|
| 28 |
+
|
| 29 |
+
app = FastAPI(title="Local PDF Text Extractor")
|
| 30 |
+
|
| 31 |
+
# Serve static assets (CSS/JS) locally — no external CDN/network dependency.
|
| 32 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 33 |
+
|
| 34 |
+
# Hold strong references to fire-and-forget background tasks: asyncio keeps only
|
| 35 |
+
# a weak reference to a Task, so a bare create_task() result can in principle be
|
| 36 |
+
# GC'd mid-flight ("Task was destroyed but it is pending"). Keeping them here and
|
| 37 |
+
# discarding on completion makes the lifetime explicit.
|
| 38 |
+
_BG_TASKS: set = set()
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _spawn(coro) -> None:
|
| 42 |
+
task = asyncio.create_task(coro)
|
| 43 |
+
_BG_TASKS.add(task)
|
| 44 |
+
task.add_done_callback(_BG_TASKS.discard)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _parse_bool(value: str) -> bool:
|
| 48 |
+
"""Parse a form string into a bool. true/1/yes (case-insensitive) -> True."""
|
| 49 |
+
return str(value).strip().lower() in ("true", "1", "yes")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@app.on_event("startup")
|
| 53 |
+
async def _startup() -> None:
|
| 54 |
+
# Preload the English PaddleOCR model at startup so the first request is fast
|
| 55 |
+
# (first ever run still downloads model weights from disk cache). This is
|
| 56 |
+
# best-effort: a failed/slow first-run download must NEVER abort startup —
|
| 57 |
+
# text-layer extraction and the online Gemini path don't need PaddleOCR at
|
| 58 |
+
# all. Run it in the OCR worker thread so a slow download can't block the
|
| 59 |
+
# event loop, and swallow any error (OCR lazy-loads on first real use).
|
| 60 |
+
from pipeline.jobs import _EXECUTOR
|
| 61 |
+
|
| 62 |
+
async def _safe_warmup() -> None:
|
| 63 |
+
try:
|
| 64 |
+
loop = asyncio.get_running_loop()
|
| 65 |
+
await loop.run_in_executor(_EXECUTOR, engine.warmup, "en")
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
|
| 69 |
+
_spawn(_safe_warmup())
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.get("/")
|
| 73 |
+
async def index():
|
| 74 |
+
"""Serve the single-page front-end."""
|
| 75 |
+
return FileResponse(str(INDEX_HTML))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@app.post("/api/upload")
|
| 79 |
+
async def upload(
|
| 80 |
+
file: UploadFile = File(...),
|
| 81 |
+
mode: str = Form("max"),
|
| 82 |
+
lang: str = Form("en"),
|
| 83 |
+
preprocess: str = Form("true"),
|
| 84 |
+
binarize: str = Form("false"),
|
| 85 |
+
handwriting: str = Form("false"),
|
| 86 |
+
online: str = Form("false"),
|
| 87 |
+
online_key: str = Form(""),
|
| 88 |
+
online_model: str = Form(""),
|
| 89 |
+
force_ocr: str = Form("false"),
|
| 90 |
+
remove_headers: str = Form("true"),
|
| 91 |
+
):
|
| 92 |
+
"""Accept a PDF upload, create a job, and kick off background processing."""
|
| 93 |
+
pre = _parse_bool(preprocess)
|
| 94 |
+
binar = _parse_bool(binarize)
|
| 95 |
+
handw = _parse_bool(handwriting)
|
| 96 |
+
onl = _parse_bool(online)
|
| 97 |
+
okey = (online_key or "").strip()
|
| 98 |
+
omodel = (online_model or "").strip()
|
| 99 |
+
force = _parse_bool(force_ocr)
|
| 100 |
+
rm_headers = _parse_bool(remove_headers)
|
| 101 |
+
|
| 102 |
+
# Cheap validations BEFORE buffering the whole upload into memory, so a
|
| 103 |
+
# request that will be rejected anyway doesn't pay a full file read.
|
| 104 |
+
if onl and not okey:
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=400,
|
| 107 |
+
detail=(
|
| 108 |
+
"Online OCR needs a Gemini API key. Enter your free key from "
|
| 109 |
+
"aistudio.google.com, or turn Online OCR off."
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if handw:
|
| 114 |
+
from pipeline.handwriting import is_available
|
| 115 |
+
if not is_available():
|
| 116 |
+
raise HTTPException(
|
| 117 |
+
status_code=400,
|
| 118 |
+
detail=(
|
| 119 |
+
"Handwriting mode needs extra packages. In the project venv "
|
| 120 |
+
"run: pip install transformers torch (first use also "
|
| 121 |
+
"downloads the TrOCR model, a few hundred MB)."
|
| 122 |
+
),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
pdf_bytes = await file.read()
|
| 126 |
+
|
| 127 |
+
# create_job opens the PDF with PyMuPDF to validate + count pages. Run it on
|
| 128 |
+
# the single OCR worker thread so ALL fitz access stays on one thread —
|
| 129 |
+
# PyMuPDF is not thread-safe even across separate Documents, and the event
|
| 130 |
+
# loop could otherwise call fitz.open here concurrently with another job's
|
| 131 |
+
# render on the worker thread (multi-file uploads run concurrently).
|
| 132 |
+
from pipeline.jobs import _EXECUTOR
|
| 133 |
+
loop = asyncio.get_running_loop()
|
| 134 |
+
try:
|
| 135 |
+
job = await loop.run_in_executor(
|
| 136 |
+
_EXECUTOR,
|
| 137 |
+
lambda: manager.create_job(
|
| 138 |
+
file.filename or "document.pdf",
|
| 139 |
+
pdf_bytes,
|
| 140 |
+
mode=mode,
|
| 141 |
+
lang=lang,
|
| 142 |
+
preprocess=pre,
|
| 143 |
+
binarize=binar,
|
| 144 |
+
handwriting=handw,
|
| 145 |
+
online=onl,
|
| 146 |
+
online_key=okey,
|
| 147 |
+
online_model=omodel,
|
| 148 |
+
force_ocr=force,
|
| 149 |
+
remove_headers=rm_headers,
|
| 150 |
+
),
|
| 151 |
+
)
|
| 152 |
+
except ValueError as exc:
|
| 153 |
+
# encrypted / corrupt / otherwise unreadable PDF
|
| 154 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 155 |
+
|
| 156 |
+
# Launch processing as a background task; progress is streamed over SSE.
|
| 157 |
+
_spawn(manager.run(job))
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"job_id": job.job_id,
|
| 161 |
+
"filename": job.filename,
|
| 162 |
+
"total_pages": job.total_pages,
|
| 163 |
+
"status": job.status,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@app.post("/api/online/validate")
|
| 168 |
+
async def online_validate(api_key: str = Form(...)):
|
| 169 |
+
"""Validate a Gemini API key and return the vision models it can use.
|
| 170 |
+
|
| 171 |
+
Lets the UI confirm the key works (and populate the model dropdown) before
|
| 172 |
+
the user runs a whole document. Never stores the key server-side.
|
| 173 |
+
"""
|
| 174 |
+
from pipeline import online_ocr
|
| 175 |
+
|
| 176 |
+
def _check():
|
| 177 |
+
all_models = online_ocr.list_models(api_key)
|
| 178 |
+
supported = [m for m in online_ocr.SUPPORTED_MODELS if m in set(all_models)]
|
| 179 |
+
return {
|
| 180 |
+
"ok": True,
|
| 181 |
+
"supported": supported,
|
| 182 |
+
"recommended": online_ocr.best_available_model(all_models),
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
loop = asyncio.get_running_loop()
|
| 186 |
+
try:
|
| 187 |
+
return await loop.run_in_executor(None, _check)
|
| 188 |
+
except RuntimeError as exc:
|
| 189 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 190 |
+
except Exception: # never surface a bare 500 from a key check
|
| 191 |
+
raise HTTPException(
|
| 192 |
+
status_code=502, detail="Online key check failed unexpectedly."
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@app.post("/api/analyze")
|
| 197 |
+
async def analyze(
|
| 198 |
+
file: UploadFile = File(...),
|
| 199 |
+
lang: str = Form("en"),
|
| 200 |
+
):
|
| 201 |
+
"""Sample 1-2 pages and recommend the best settings for THIS document.
|
| 202 |
+
|
| 203 |
+
Opt-in and fast. Runs the CPU-bound sweep in the OCR worker thread pool so
|
| 204 |
+
it never stalls the event loop / SSE streams, and so it never runs OCR
|
| 205 |
+
concurrently with a live extraction job (PaddleOCR is not thread-safe).
|
| 206 |
+
"""
|
| 207 |
+
pdf_bytes = await file.read()
|
| 208 |
+
if not pdf_bytes:
|
| 209 |
+
raise HTTPException(status_code=400, detail="empty file")
|
| 210 |
+
from pipeline.jobs import _EXECUTOR
|
| 211 |
+
loop = asyncio.get_running_loop()
|
| 212 |
+
result = await loop.run_in_executor(
|
| 213 |
+
_EXECUTOR, lambda: suggest_settings(pdf_bytes, lang=lang)
|
| 214 |
+
)
|
| 215 |
+
return result
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@app.post("/api/export/docx")
|
| 219 |
+
async def export_docx(payload: dict = Body(...)):
|
| 220 |
+
"""Build a .docx from the posted extracted documents and stream it back."""
|
| 221 |
+
documents = payload.get("documents", []) if isinstance(payload, dict) else []
|
| 222 |
+
loop = asyncio.get_running_loop()
|
| 223 |
+
# build_docx is hardened to never raise on a malformed/hostile payload, but
|
| 224 |
+
# keep a belt-and-suspenders guard so a single bad export can't 500.
|
| 225 |
+
try:
|
| 226 |
+
data = await loop.run_in_executor(None, build_docx, documents)
|
| 227 |
+
except Exception:
|
| 228 |
+
raise HTTPException(status_code=400, detail="Could not build the Word document.")
|
| 229 |
+
return StreamingResponse(
|
| 230 |
+
io.BytesIO(data),
|
| 231 |
+
media_type=(
|
| 232 |
+
"application/vnd.openxmlformats-officedocument."
|
| 233 |
+
"wordprocessingml.document"
|
| 234 |
+
),
|
| 235 |
+
headers={"Content-Disposition": 'attachment; filename="extraction.docx"'},
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@app.get("/api/jobs/{job_id}")
|
| 240 |
+
async def get_job(job_id: str):
|
| 241 |
+
"""Return the current full state of a job."""
|
| 242 |
+
job = manager.get(job_id)
|
| 243 |
+
if job is None:
|
| 244 |
+
raise HTTPException(status_code=404, detail="job not found")
|
| 245 |
+
return job.to_dict()
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@app.get("/api/jobs/{job_id}/stream")
|
| 249 |
+
async def stream_job(job_id: str):
|
| 250 |
+
"""Stream per-page progress events as Server-Sent Events."""
|
| 251 |
+
job = manager.get(job_id)
|
| 252 |
+
if job is None:
|
| 253 |
+
raise HTTPException(status_code=404, detail="job not found")
|
| 254 |
+
|
| 255 |
+
async def event_generator():
|
| 256 |
+
async for event in manager.events(job_id):
|
| 257 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 258 |
+
etype = event.get("type")
|
| 259 |
+
if etype in ("done", "error", "cancelled"):
|
| 260 |
+
break
|
| 261 |
+
|
| 262 |
+
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
@app.post("/api/jobs/{job_id}/cancel")
|
| 266 |
+
async def cancel_job(job_id: str):
|
| 267 |
+
"""Request cancellation of a running job."""
|
| 268 |
+
job = manager.get(job_id)
|
| 269 |
+
if job is None:
|
| 270 |
+
raise HTTPException(status_code=404, detail="job not found")
|
| 271 |
+
manager.cancel(job_id)
|
| 272 |
+
return {"status": "cancelling"}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@app.delete("/api/jobs/{job_id}")
|
| 276 |
+
async def delete_job(job_id: str):
|
| 277 |
+
"""Remove a job and free its in-memory PDF bytes. Idempotent (always 200)."""
|
| 278 |
+
manager.delete(job_id)
|
| 279 |
+
return {"status": "deleted"}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@app.get("/api/jobs/{job_id}/pages/{n}/image")
|
| 283 |
+
async def page_image(job_id: str, n: int, dpi: int = 150):
|
| 284 |
+
"""Render a 1-based page to PNG and stream it back."""
|
| 285 |
+
job = manager.get(job_id)
|
| 286 |
+
if job is None:
|
| 287 |
+
raise HTTPException(status_code=404, detail="job not found")
|
| 288 |
+
# Clamp DPI to a sane range so a degenerate ?dpi= (0, negative, or huge)
|
| 289 |
+
# can't crash the renderer (fitz error / giant allocation) into a 500.
|
| 290 |
+
dpi = max(36, min(600, dpi))
|
| 291 |
+
try:
|
| 292 |
+
# PNG raster + encode is CPU-bound; run it off the event loop. Use the
|
| 293 |
+
# SAME single-worker OCR executor so PyMuPDF rendering never runs
|
| 294 |
+
# concurrently with an OCR page render or another image render (fitz is
|
| 295 |
+
# not formally thread-safe even across separate Document objects).
|
| 296 |
+
from pipeline.jobs import _EXECUTOR
|
| 297 |
+
loop = asyncio.get_running_loop()
|
| 298 |
+
png = await loop.run_in_executor(
|
| 299 |
+
_EXECUTOR, manager.render_page_png, job_id, n, dpi
|
| 300 |
+
)
|
| 301 |
+
except (KeyError, IndexError, ValueError):
|
| 302 |
+
raise HTTPException(status_code=404, detail="page not found")
|
| 303 |
+
except Exception:
|
| 304 |
+
# A PyMuPDF render error (e.g. a page with a malformed embedded image
|
| 305 |
+
# raises fitz.FileDataError / RuntimeError) must not surface as a bare
|
| 306 |
+
# 500 — the frontend <img> onerror handles a non-image response.
|
| 307 |
+
raise HTTPException(status_code=404, detail="could not render page")
|
| 308 |
+
return StreamingResponse(io.BytesIO(png), media_type="image/png")
|
pipeline/__init__.py
ADDED
|
File without changes
|
pipeline/analyze.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-document settings recommender.
|
| 2 |
+
|
| 3 |
+
Samples the first 1-2 OCR-eligible pages, sweeps a small grid of already-supported
|
| 4 |
+
configs, scores each by a COMPOSITE signal (mean confidence AND confident-line
|
| 5 |
+
coverage AND text mass AND cross-config agreement), and recommends settings.
|
| 6 |
+
|
| 7 |
+
Why not 'pick highest mean confidence': confidence-based auto-select was
|
| 8 |
+
benchmarked and rejected — binarize can score HIGHER mean confidence on garbage
|
| 9 |
+
while silently dropping faint text. The composite + coverage + a margin-handicap
|
| 10 |
+
toward the validated grayscale default guard against that.
|
| 11 |
+
|
| 12 |
+
This module only MEASURES already-supported, already-benchmarked configs and
|
| 13 |
+
RECOMMENDS one with evidence. It never changes the extraction pipeline, uses the
|
| 14 |
+
existing engine (det_limit_side_len stays 1536), and therefore cannot regress the
|
| 15 |
+
CER benchmark.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import fitz # type: ignore
|
| 24 |
+
|
| 25 |
+
from pipeline.extractor import render_page_image, DPI_MAX, DPI_FAST
|
| 26 |
+
from pipeline.preprocess import preprocess_image
|
| 27 |
+
from pipeline.ocr_engine import engine
|
| 28 |
+
from pipeline.textlayer import has_text_layer
|
| 29 |
+
|
| 30 |
+
# How many OCR-eligible pages to actually sample. Keep tiny: this runs PaddleOCR
|
| 31 |
+
# several times per page on the CPU, so 1-2 pages is the speed/robustness sweet
|
| 32 |
+
# spot. The first eligible page is usually representative of document "type".
|
| 33 |
+
MAX_SAMPLE_PAGES = 2
|
| 34 |
+
|
| 35 |
+
# Lines at/above this recognition confidence count as "real" content. Used for
|
| 36 |
+
# the coverage and text-mass signals (the part confidence-alone ignored).
|
| 37 |
+
CONFIDENT = 0.80
|
| 38 |
+
|
| 39 |
+
# An alternative config must beat the validated grayscale default by at least
|
| 40 |
+
# this much COMPOSITE margin to be recommended. The grayscale+tuned pipeline is
|
| 41 |
+
# the benchmarked baseline; don't flip off it on noise.
|
| 42 |
+
DECISIVE_MARGIN = 0.08
|
| 43 |
+
MARGINAL_MARGIN = 0.03
|
| 44 |
+
|
| 45 |
+
_WORD_RE = re.compile(r"[0-9a-zऀ-ॿ]+") # latin digits/letters + devanagari
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _tokens(text: str) -> set:
|
| 49 |
+
return set(_WORD_RE.findall((text or "").lower()))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _config_grid(lang: str) -> list:
|
| 53 |
+
"""The SMALL grid we sweep. All are already-supported, already-benchmarked
|
| 54 |
+
knobs — no new pipeline behavior, no upscaling, no fused ensemble.
|
| 55 |
+
|
| 56 |
+
Order matters only for tie-stability; 'baseline' marks the validated default.
|
| 57 |
+
"""
|
| 58 |
+
return [
|
| 59 |
+
# name, mode, preprocess, binarize, baseline?
|
| 60 |
+
{"name": "max_gray", "mode": "max", "preprocess": True, "binarize": False, "baseline": True},
|
| 61 |
+
{"name": "max_gray_raw", "mode": "max", "preprocess": False, "binarize": False, "baseline": False},
|
| 62 |
+
{"name": "max_binarize", "mode": "max", "preprocess": True, "binarize": True, "baseline": False},
|
| 63 |
+
{"name": "fast_gray", "mode": "fast", "preprocess": True, "binarize": False, "baseline": False},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _ocr_one(img_bgr, *, lang: str, use_angle_cls: bool):
|
| 68 |
+
"""Run the SAME engine the real pipeline uses and return rich per-line stats.
|
| 69 |
+
|
| 70 |
+
Returns (text, mean_conf, n_confident_lines, confident_char_mass, confident_text).
|
| 71 |
+
"""
|
| 72 |
+
lines = engine.ocr_lines(img_bgr, lang=lang, use_angle_cls=use_angle_cls)
|
| 73 |
+
if not lines:
|
| 74 |
+
return "", None, 0, 0, ""
|
| 75 |
+
text = "\n".join(ln["text"] for ln in lines)
|
| 76 |
+
mean_conf = sum(ln["confidence"] for ln in lines) / len(lines)
|
| 77 |
+
conf_lines = [ln for ln in lines if ln["confidence"] >= CONFIDENT]
|
| 78 |
+
n_conf = len(conf_lines)
|
| 79 |
+
mass = sum(len(ln["text"].strip()) for ln in conf_lines)
|
| 80 |
+
conf_text = " ".join(ln["text"] for ln in conf_lines)
|
| 81 |
+
return text, float(mean_conf), n_conf, mass, conf_text
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _score_page(results: dict) -> dict:
|
| 85 |
+
"""Given {config_name: per-config raw stats} for ONE page, compute a
|
| 86 |
+
normalized composite score per config.
|
| 87 |
+
|
| 88 |
+
Signals (all normalized to the best config ON THIS PAGE so absolute scale
|
| 89 |
+
doesn't matter), then weighted:
|
| 90 |
+
conf 0.30 mean line confidence
|
| 91 |
+
coverage 0.30 # confident lines (catches binarize dropping faint text)
|
| 92 |
+
mass 0.20 confident char count (don't reward tiny fragments)
|
| 93 |
+
agree 0.20 token overlap vs the union of all configs' confident text
|
| 94 |
+
(a config that hallucinates differently is down-weighted)
|
| 95 |
+
"""
|
| 96 |
+
names = list(results.keys())
|
| 97 |
+
confs = {n: (results[n]["mean_conf"] or 0.0) for n in names}
|
| 98 |
+
covs = {n: results[n]["n_conf"] for n in names}
|
| 99 |
+
mass = {n: results[n]["mass"] for n in names}
|
| 100 |
+
|
| 101 |
+
# cross-config agreement: the fraction of THIS config's confident tokens
|
| 102 |
+
# that are corroborated by at least one OTHER config. (Comparing against the
|
| 103 |
+
# union of ALL configs — including this one — is mathematically inert: it
|
| 104 |
+
# reduces to |toks[n]| / |union|, which actually REWARDS a config that
|
| 105 |
+
# hallucinates many unique tokens. We want the opposite: down-weight a config
|
| 106 |
+
# whose confident output nobody else agrees with.)
|
| 107 |
+
toks = {n: _tokens(results[n]["conf_text"]) for n in names}
|
| 108 |
+
agree = {}
|
| 109 |
+
for n in names:
|
| 110 |
+
others = set()
|
| 111 |
+
for m in names:
|
| 112 |
+
if m != n:
|
| 113 |
+
others |= toks[m]
|
| 114 |
+
agree[n] = (len(toks[n] & others) / len(toks[n])) if toks[n] else 0.0
|
| 115 |
+
|
| 116 |
+
def _norm(d):
|
| 117 |
+
hi = max(d.values()) if d else 0
|
| 118 |
+
if not hi:
|
| 119 |
+
return {k: 0.0 for k in d}
|
| 120 |
+
return {k: v / hi for k, v in d.items()}
|
| 121 |
+
|
| 122 |
+
nconf, ncov, nmass = _norm(confs), _norm(covs), _norm(mass)
|
| 123 |
+
out = {}
|
| 124 |
+
for n in names:
|
| 125 |
+
out[n] = (0.30 * nconf[n] + 0.30 * ncov[n] +
|
| 126 |
+
0.20 * nmass[n] + 0.20 * agree[n])
|
| 127 |
+
return out
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def suggest_settings(pdf_bytes: bytes, *, lang: str = "en",
|
| 131 |
+
max_pages: int = MAX_SAMPLE_PAGES) -> dict:
|
| 132 |
+
"""Analyze a PDF and recommend extraction settings for THIS document.
|
| 133 |
+
|
| 134 |
+
Fast + opt-in: only renders/OCRs up to ``max_pages`` OCR-eligible pages.
|
| 135 |
+
Born-digital pages short-circuit to a 'no OCR needed' recommendation.
|
| 136 |
+
|
| 137 |
+
Returns a JSON-serializable dict. Never raises on a bad page; on total
|
| 138 |
+
failure returns a safe 'use defaults' recommendation.
|
| 139 |
+
"""
|
| 140 |
+
t0 = time.time()
|
| 141 |
+
try:
|
| 142 |
+
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
| 143 |
+
except Exception as exc:
|
| 144 |
+
return _fallback(f"Could not open PDF ({exc}); using defaults.", lang)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
if doc.needs_pass:
|
| 148 |
+
return _fallback("PDF is encrypted; using defaults.", lang)
|
| 149 |
+
total = doc.page_count
|
| 150 |
+
if total <= 0:
|
| 151 |
+
return _fallback("PDF has no pages; using defaults.", lang)
|
| 152 |
+
|
| 153 |
+
# --- Pass 1: classify pages cheaply (text-layer vs needs-OCR). -------
|
| 154 |
+
# Cap the scan to a small leading window so a 500-page born-digital PDF
|
| 155 |
+
# (which never accumulates OCR pages) isn't fully scanned — the first
|
| 156 |
+
# few pages define the document's type for "recommend settings".
|
| 157 |
+
text_pages, ocr_page_indices = 0, []
|
| 158 |
+
scan_cap = max_pages * 4
|
| 159 |
+
for i in range(total):
|
| 160 |
+
page = doc.load_page(i)
|
| 161 |
+
if has_text_layer(page):
|
| 162 |
+
text_pages += 1
|
| 163 |
+
else:
|
| 164 |
+
ocr_page_indices.append(i)
|
| 165 |
+
if len(ocr_page_indices) >= max_pages or i >= min(total - 1, scan_cap):
|
| 166 |
+
break
|
| 167 |
+
|
| 168 |
+
# --- Born-digital short-circuit: no OCR needed. ----------------------
|
| 169 |
+
if not ocr_page_indices:
|
| 170 |
+
return {
|
| 171 |
+
"ok": True,
|
| 172 |
+
"needs_ocr": False,
|
| 173 |
+
"decision": "decisive",
|
| 174 |
+
"rationale": "This PDF already has a real text layer on the sampled pages — "
|
| 175 |
+
"it is extracted exactly, no OCR required.",
|
| 176 |
+
"total_pages": total,
|
| 177 |
+
"sampled_pages": [],
|
| 178 |
+
"text_layer_pages": text_pages,
|
| 179 |
+
"recommended": {
|
| 180 |
+
"mode": "max", "lang": lang,
|
| 181 |
+
"preprocess": True, "binarize": False, "handwriting": False,
|
| 182 |
+
},
|
| 183 |
+
"evidence": [],
|
| 184 |
+
"elapsed_sec": round(time.time() - t0, 2),
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
# --- Pass 2: sweep the small grid on up to max_pages OCR pages. ------
|
| 188 |
+
sample = ocr_page_indices[:max_pages]
|
| 189 |
+
if not sample: # guards a direct max_pages<=0 call -> no ZeroDivision
|
| 190 |
+
return _fallback("No OCR-eligible pages sampled; using defaults.", lang)
|
| 191 |
+
grid = _config_grid(lang)
|
| 192 |
+
per_config_totals = {g["name"]: 0.0 for g in grid}
|
| 193 |
+
evidence_rows = {g["name"]: dict(g, mean_conf=[], n_conf=[], mass=[]) for g in grid}
|
| 194 |
+
|
| 195 |
+
for pidx in sample:
|
| 196 |
+
page = doc.load_page(pidx)
|
| 197 |
+
renders = {}
|
| 198 |
+
page_raw = {}
|
| 199 |
+
for g in grid:
|
| 200 |
+
dpi = DPI_MAX if g["mode"] == "max" else DPI_FAST
|
| 201 |
+
if dpi not in renders:
|
| 202 |
+
renders[dpi] = render_page_image(page, dpi)
|
| 203 |
+
img = renders[dpi]
|
| 204 |
+
if g["preprocess"]:
|
| 205 |
+
img = preprocess_image(img, mode=g["mode"], binarize=g["binarize"])
|
| 206 |
+
use_cls = (g["mode"] == "max")
|
| 207 |
+
text, mconf, ncf, mass, ctext = _ocr_one(
|
| 208 |
+
img, lang=lang, use_angle_cls=use_cls
|
| 209 |
+
)
|
| 210 |
+
page_raw[g["name"]] = {
|
| 211 |
+
"mean_conf": mconf, "n_conf": ncf, "mass": mass, "conf_text": ctext,
|
| 212 |
+
}
|
| 213 |
+
evidence_rows[g["name"]]["mean_conf"].append(mconf)
|
| 214 |
+
evidence_rows[g["name"]]["n_conf"].append(ncf)
|
| 215 |
+
evidence_rows[g["name"]]["mass"].append(mass)
|
| 216 |
+
|
| 217 |
+
page_scores = _score_page(page_raw)
|
| 218 |
+
for name, s in page_scores.items():
|
| 219 |
+
per_config_totals[name] += s
|
| 220 |
+
|
| 221 |
+
# Average composite across sampled pages.
|
| 222 |
+
n = len(sample)
|
| 223 |
+
composite = {k: v / n for k, v in per_config_totals.items()}
|
| 224 |
+
|
| 225 |
+
baseline_name = next(g["name"] for g in grid if g["baseline"])
|
| 226 |
+
base_score = composite[baseline_name]
|
| 227 |
+
alt_name = max((g["name"] for g in grid if not g["baseline"]),
|
| 228 |
+
key=lambda k: composite[k])
|
| 229 |
+
alt_score = composite[alt_name]
|
| 230 |
+
margin = alt_score - base_score
|
| 231 |
+
|
| 232 |
+
if margin >= DECISIVE_MARGIN:
|
| 233 |
+
winner, decision = alt_name, "decisive"
|
| 234 |
+
elif margin >= MARGINAL_MARGIN:
|
| 235 |
+
winner, decision = alt_name, "marginal"
|
| 236 |
+
else:
|
| 237 |
+
winner, decision = baseline_name, "inconclusive"
|
| 238 |
+
|
| 239 |
+
win_cfg = next(g for g in grid if g["name"] == winner)
|
| 240 |
+
recommended = {
|
| 241 |
+
"mode": win_cfg["mode"], "lang": lang,
|
| 242 |
+
"preprocess": win_cfg["preprocess"],
|
| 243 |
+
"binarize": win_cfg["binarize"], "handwriting": False,
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# Build a readable evidence table.
|
| 247 |
+
def _avg(xs):
|
| 248 |
+
xs = [x for x in xs if x is not None]
|
| 249 |
+
return round(sum(xs) / len(xs), 3) if xs else None
|
| 250 |
+
evidence = []
|
| 251 |
+
for g in grid:
|
| 252 |
+
r = evidence_rows[g["name"]]
|
| 253 |
+
evidence.append({
|
| 254 |
+
"name": g["name"],
|
| 255 |
+
"mode": g["mode"], "preprocess": g["preprocess"], "binarize": g["binarize"],
|
| 256 |
+
"baseline": g["baseline"],
|
| 257 |
+
"composite": round(composite[g["name"]], 3),
|
| 258 |
+
"mean_conf": _avg(r["mean_conf"]),
|
| 259 |
+
"confident_lines": round(sum(r["n_conf"]) / max(1, len(r["n_conf"])), 1),
|
| 260 |
+
"recommended": g["name"] == winner,
|
| 261 |
+
})
|
| 262 |
+
evidence.sort(key=lambda e: e["composite"], reverse=True)
|
| 263 |
+
|
| 264 |
+
rationale = _rationale(decision, winner, baseline_name, margin, win_cfg)
|
| 265 |
+
return {
|
| 266 |
+
"ok": True,
|
| 267 |
+
"needs_ocr": True,
|
| 268 |
+
"decision": decision,
|
| 269 |
+
"rationale": rationale,
|
| 270 |
+
"total_pages": total,
|
| 271 |
+
"sampled_pages": [p + 1 for p in sample],
|
| 272 |
+
"text_layer_pages": text_pages,
|
| 273 |
+
"recommended": recommended,
|
| 274 |
+
"evidence": evidence,
|
| 275 |
+
"elapsed_sec": round(time.time() - t0, 2),
|
| 276 |
+
}
|
| 277 |
+
except Exception as exc: # never let analysis crash the request
|
| 278 |
+
return _fallback(f"Analysis failed ({exc}); using defaults.", lang)
|
| 279 |
+
finally:
|
| 280 |
+
try:
|
| 281 |
+
doc.close()
|
| 282 |
+
except Exception:
|
| 283 |
+
pass
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _rationale(decision, winner, baseline_name, margin, cfg) -> str:
|
| 287 |
+
if decision == "inconclusive":
|
| 288 |
+
return ("No alternative clearly beat the validated default on the sampled "
|
| 289 |
+
"page(s); recommending the default (grayscale, max accuracy).")
|
| 290 |
+
knob = []
|
| 291 |
+
if cfg["binarize"]:
|
| 292 |
+
knob.append("binarize ON")
|
| 293 |
+
if not cfg["preprocess"]:
|
| 294 |
+
knob.append("preprocessing OFF")
|
| 295 |
+
if cfg["mode"] == "fast":
|
| 296 |
+
knob.append("Faster mode")
|
| 297 |
+
desc = ", ".join(knob) if knob else "the default settings"
|
| 298 |
+
strength = "clearly" if decision == "decisive" else "slightly"
|
| 299 |
+
return (f"On the sampled page(s), {desc} {strength} outscored the default "
|
| 300 |
+
f"(composite +{margin:.2f}: higher confident-line coverage/agreement, "
|
| 301 |
+
f"not just mean confidence). Verify against the original before trusting it.")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _fallback(msg: str, lang: str = "en") -> dict:
|
| 305 |
+
return {
|
| 306 |
+
"ok": True, "needs_ocr": True, "decision": "inconclusive",
|
| 307 |
+
"rationale": msg, "total_pages": None, "sampled_pages": [],
|
| 308 |
+
"text_layer_pages": None,
|
| 309 |
+
"recommended": {"mode": "max", "lang": lang,
|
| 310 |
+
"preprocess": True, "binarize": False, "handwriting": False},
|
| 311 |
+
"evidence": [],
|
| 312 |
+
}
|
pipeline/export_docx.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build a Word (.docx) document from extracted results.
|
| 2 |
+
|
| 3 |
+
Uses python-docx (already a dependency). Pure-local, no network. Each uploaded
|
| 4 |
+
document becomes a section with a filename heading, and each page a "Page N"
|
| 5 |
+
heading (annotated with its source / OCR confidence) followed by its text.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import io
|
| 9 |
+
import math
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
# Characters that are illegal in XML 1.0 (and so rejected by python-docx/lxml
|
| 13 |
+
# with "All strings must be XML compatible"). We keep the only valid C0 controls
|
| 14 |
+
# (tab, newline, carriage return) and strip the rest. A form-feed (U+000C) is the
|
| 15 |
+
# common offender: PDF text layers emit it as a page/section separator, so it
|
| 16 |
+
# reaches Word export organically and would otherwise 500 the whole export.
|
| 17 |
+
_XML_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _xml_safe(value) -> str:
|
| 21 |
+
"""Coerce any value to an XML-safe string for python-docx."""
|
| 22 |
+
if value is None:
|
| 23 |
+
s = ""
|
| 24 |
+
elif isinstance(value, str):
|
| 25 |
+
s = value
|
| 26 |
+
else:
|
| 27 |
+
s = str(value)
|
| 28 |
+
return _XML_ILLEGAL.sub("", s)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def build_docx(documents: list) -> bytes:
|
| 32 |
+
"""Render extracted documents to .docx bytes.
|
| 33 |
+
|
| 34 |
+
``documents`` is a list of ``{"filename": str, "pages": [
|
| 35 |
+
{"page": int, "source": "text"|"ocr"|None, "confidence": float|None,
|
| 36 |
+
"text": str}, ...]}``.
|
| 37 |
+
|
| 38 |
+
The whole payload is untrusted (it comes from an HTTP body), so this never
|
| 39 |
+
raises on a malformed shape, an XML-illegal control character in any string,
|
| 40 |
+
or a non-finite (NaN/Infinity) confidence — it degrades gracefully instead.
|
| 41 |
+
"""
|
| 42 |
+
from docx import Document # imported lazily so import of this module is cheap
|
| 43 |
+
|
| 44 |
+
doc = Document()
|
| 45 |
+
# Be defensive about the posted payload: the body comes from an untrusted
|
| 46 |
+
# HTTP request, so anything that isn't the expected shape (a list of dicts of
|
| 47 |
+
# dicts) is coerced/skipped instead of raising a 500. The frontend always
|
| 48 |
+
# sends well-formed data; a hand-crafted request must not crash the handler.
|
| 49 |
+
if not isinstance(documents, list):
|
| 50 |
+
documents = []
|
| 51 |
+
first = True
|
| 52 |
+
for d in documents:
|
| 53 |
+
if not isinstance(d, dict):
|
| 54 |
+
continue
|
| 55 |
+
if not first:
|
| 56 |
+
doc.add_page_break()
|
| 57 |
+
first = False
|
| 58 |
+
doc.add_heading(_xml_safe(d.get("filename") or "Document"), level=1)
|
| 59 |
+
|
| 60 |
+
pages = d.get("pages")
|
| 61 |
+
if not isinstance(pages, list):
|
| 62 |
+
pages = []
|
| 63 |
+
for p in pages:
|
| 64 |
+
if not isinstance(p, dict):
|
| 65 |
+
continue
|
| 66 |
+
source = p.get("source")
|
| 67 |
+
conf = p.get("confidence")
|
| 68 |
+
# Guard finiteness: json.loads accepts bare NaN/Infinity, and
|
| 69 |
+
# round(nan)/round(inf) raise — which would 500 the export.
|
| 70 |
+
if source == "ocr":
|
| 71 |
+
if (
|
| 72 |
+
isinstance(conf, (int, float))
|
| 73 |
+
and not isinstance(conf, bool)
|
| 74 |
+
and math.isfinite(conf)
|
| 75 |
+
):
|
| 76 |
+
tag = " — OCR ({}%)".format(round(conf * 100))
|
| 77 |
+
else:
|
| 78 |
+
tag = " — OCR"
|
| 79 |
+
elif source == "text":
|
| 80 |
+
tag = " — Text layer"
|
| 81 |
+
else:
|
| 82 |
+
tag = ""
|
| 83 |
+
doc.add_heading(_xml_safe("Page {}{}".format(p.get("page"), tag)), level=2)
|
| 84 |
+
|
| 85 |
+
text = _xml_safe(p.get("text"))
|
| 86 |
+
if text.strip():
|
| 87 |
+
# Preserve line structure: one paragraph per line.
|
| 88 |
+
for line in text.split("\n"):
|
| 89 |
+
doc.add_paragraph(line)
|
| 90 |
+
else:
|
| 91 |
+
empty = doc.add_paragraph()
|
| 92 |
+
empty.add_run("(no text on this page)").italic = True
|
| 93 |
+
|
| 94 |
+
buf = io.BytesIO()
|
| 95 |
+
doc.save(buf)
|
| 96 |
+
return buf.getvalue()
|
pipeline/extractor.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-page extraction: text-layer when present, else render + OCR.
|
| 2 |
+
|
| 3 |
+
Implements the project contract exactly:
|
| 4 |
+
DPI_MAX, DPI_FAST
|
| 5 |
+
render_page_image(page, dpi) -> BGR uint8 ndarray
|
| 6 |
+
extract_page(page, *, mode, lang, preprocess, min_chars=50) -> dict
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from pipeline.preprocess import preprocess_image
|
| 12 |
+
from pipeline.ocr_engine import engine
|
| 13 |
+
from pipeline.textlayer import has_text_layer, extract_text_layer
|
| 14 |
+
|
| 15 |
+
# fitz is PyMuPDF; imported lazily-safe at module import.
|
| 16 |
+
import fitz # type: ignore
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
DPI_MAX = 300
|
| 20 |
+
DPI_FAST = 150
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def render_page_image(page, dpi: int) -> np.ndarray:
|
| 24 |
+
"""Render a fitz.Page to an HxWx3 BGR uint8 ndarray at the given DPI.
|
| 25 |
+
|
| 26 |
+
Uses a pixmap with zoom = dpi/72. Handles RGB, grayscale and alpha
|
| 27 |
+
channels, always returning a 3-channel BGR image (OpenCV/PaddleOCR
|
| 28 |
+
convention).
|
| 29 |
+
"""
|
| 30 |
+
zoom = float(dpi) / 72.0
|
| 31 |
+
matrix = fitz.Matrix(zoom, zoom)
|
| 32 |
+
# alpha=False keeps things simple, but some pages/pixmaps can still carry
|
| 33 |
+
# channel counts other than 3; normalise defensively below.
|
| 34 |
+
pix = page.get_pixmap(matrix=matrix, alpha=False)
|
| 35 |
+
|
| 36 |
+
# Build an ndarray view from the pixmap samples.
|
| 37 |
+
buf = np.frombuffer(pix.samples, dtype=np.uint8)
|
| 38 |
+
n = pix.n # bytes per pixel (channels)
|
| 39 |
+
arr = buf.reshape(pix.height, pix.width, n)
|
| 40 |
+
|
| 41 |
+
if n == 1:
|
| 42 |
+
# Grayscale -> BGR
|
| 43 |
+
bgr = np.repeat(arr, 3, axis=2)
|
| 44 |
+
elif n == 3:
|
| 45 |
+
# PyMuPDF emits RGB; convert to BGR for OpenCV/PaddleOCR.
|
| 46 |
+
bgr = arr[:, :, ::-1]
|
| 47 |
+
elif n == 4:
|
| 48 |
+
# RGBA -> drop alpha, RGB -> BGR.
|
| 49 |
+
rgb = arr[:, :, :3]
|
| 50 |
+
bgr = rgb[:, :, ::-1]
|
| 51 |
+
else:
|
| 52 |
+
# CMYK or other exotic layouts: re-render forcing an RGB colorspace.
|
| 53 |
+
pix = page.get_pixmap(matrix=matrix, alpha=False, colorspace=fitz.csRGB)
|
| 54 |
+
buf = np.frombuffer(pix.samples, dtype=np.uint8)
|
| 55 |
+
arr = buf.reshape(pix.height, pix.width, pix.n)
|
| 56 |
+
bgr = arr[:, :, :3][:, :, ::-1]
|
| 57 |
+
|
| 58 |
+
# Ensure a contiguous, owned uint8 array (the buffer view is read-only).
|
| 59 |
+
return np.ascontiguousarray(bgr, dtype=np.uint8)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def extract_page(
|
| 63 |
+
page,
|
| 64 |
+
*,
|
| 65 |
+
mode: str,
|
| 66 |
+
lang: str,
|
| 67 |
+
preprocess: bool,
|
| 68 |
+
binarize: bool = False,
|
| 69 |
+
handwriting: bool = False,
|
| 70 |
+
online: bool = False,
|
| 71 |
+
online_key: str = "",
|
| 72 |
+
online_model: str = "",
|
| 73 |
+
force_ocr: bool = False,
|
| 74 |
+
min_chars: int = 50,
|
| 75 |
+
) -> dict:
|
| 76 |
+
"""Extract text from a single page.
|
| 77 |
+
|
| 78 |
+
If the page has a usable text layer, return it directly (exact, so no
|
| 79 |
+
confidence is reported). Otherwise render the page to an image, optionally
|
| 80 |
+
preprocess (deskew + CLAHE + denoise), optionally binarize, and OCR it.
|
| 81 |
+
|
| 82 |
+
``preprocess`` and ``binarize`` are independent: ``preprocess`` enables the
|
| 83 |
+
grayscale clean-up pipeline; ``binarize`` additionally Sauvola-thresholds
|
| 84 |
+
the image (only meaningful when ``preprocess`` is on).
|
| 85 |
+
|
| 86 |
+
Routing precedence for a page that needs reading: text-layer (unless
|
| 87 |
+
``force_ocr``) -> online Gemini (if ``online`` + key) -> local handwriting
|
| 88 |
+
(if ``handwriting``) -> local OCR.
|
| 89 |
+
|
| 90 |
+
Returns a uniform dict from EVERY branch:
|
| 91 |
+
``{"source": "text"|"ocr"|"online"|"handwriting", "text": str,
|
| 92 |
+
"confidence": float|None, "lines": list|None}``. ``confidence`` and
|
| 93 |
+
``lines`` (per-line ``[{text, confidence}]``) are populated only for local
|
| 94 |
+
``"ocr"``; the other sources set both to ``None`` so consumers never have to
|
| 95 |
+
guard for a missing key.
|
| 96 |
+
"""
|
| 97 |
+
# "Force OCR" skips the text-layer shortcut so even born-digital pages are
|
| 98 |
+
# rendered + OCR'd (or sent online). Use it when the embedded text is
|
| 99 |
+
# garbled/wrong, or when text is trapped inside images on a text page.
|
| 100 |
+
if not force_ocr and has_text_layer(page, min_chars=min_chars):
|
| 101 |
+
return {
|
| 102 |
+
"source": "text",
|
| 103 |
+
"text": extract_text_layer(page),
|
| 104 |
+
"confidence": None,
|
| 105 |
+
"lines": None,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
dpi = DPI_MAX if mode == "max" else DPI_FAST
|
| 109 |
+
img = render_page_image(page, dpi)
|
| 110 |
+
|
| 111 |
+
# Online vision path (opt-in): send the RAW page image to Google Gemini and
|
| 112 |
+
# let it transcribe (handwriting + printed). Takes precedence over the local
|
| 113 |
+
# OCR/handwriting engines for any page that needs OCR. The page bytes leave
|
| 114 |
+
# this machine — gated on an explicit api key, never the default. Errors
|
| 115 |
+
# propagate to the per-page handler with an actionable message.
|
| 116 |
+
if online:
|
| 117 |
+
from pipeline import online_ocr
|
| 118 |
+
if online_ocr.is_configured(online_key):
|
| 119 |
+
text = online_ocr.transcribe_image_bgr(
|
| 120 |
+
img, api_key=online_key, model=(online_model or None)
|
| 121 |
+
)
|
| 122 |
+
return {"source": "online", "text": text, "confidence": None, "lines": None}
|
| 123 |
+
|
| 124 |
+
# Handwriting path: detect lines with PaddleOCR, recognise with TrOCR.
|
| 125 |
+
# Crops are taken from the natural render (no binarization, which TrOCR
|
| 126 |
+
# dislikes). Slow on CPU; opt-in per document.
|
| 127 |
+
if handwriting:
|
| 128 |
+
from pipeline.handwriting import get_engine
|
| 129 |
+
# Line DETECTION stays on the script-agnostic "en" DB detector (it finds
|
| 130 |
+
# text regions regardless of script, and avoids pulling an extra
|
| 131 |
+
# PaddleOCR language model). Only the RECOGNISER is language-aware.
|
| 132 |
+
boxes = engine.detect_boxes(img, lang="en")
|
| 133 |
+
hw = get_engine(lang=lang)
|
| 134 |
+
text = hw.ocr_text(img, boxes)
|
| 135 |
+
return {"source": "handwriting", "text": text, "confidence": None, "lines": None}
|
| 136 |
+
|
| 137 |
+
if preprocess:
|
| 138 |
+
img = preprocess_image(img, mode=mode, binarize=binarize)
|
| 139 |
+
|
| 140 |
+
use_angle_cls = (mode == "max")
|
| 141 |
+
lines = engine.ocr_lines(img, lang=lang, use_angle_cls=use_angle_cls)
|
| 142 |
+
text = "\n".join(ln["text"] for ln in lines)
|
| 143 |
+
conf = (sum(ln["confidence"] for ln in lines) / len(lines)) if lines else None
|
| 144 |
+
# Per-line confidence so the UI can flag exactly which lines to double-check.
|
| 145 |
+
# Order matches the joined text (one entry per "\n"-separated line).
|
| 146 |
+
line_conf = [
|
| 147 |
+
{"text": ln["text"], "confidence": round(float(ln["confidence"]), 4)}
|
| 148 |
+
for ln in lines
|
| 149 |
+
]
|
| 150 |
+
return {"source": "ocr", "text": text, "confidence": conf, "lines": line_conf}
|
pipeline/handwriting.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local handwriting OCR using HuggingFace VisionEncoderDecoder models (offline, free).
|
| 2 |
+
|
| 3 |
+
PaddleOCR's printed-text recogniser is weak on handwriting. This module pairs
|
| 4 |
+
PaddleOCR's *detector* (to find text-line boxes) with a TrOCR-family handwriting
|
| 5 |
+
*recogniser* (a local HuggingFace transformers model) to read each line.
|
| 6 |
+
|
| 7 |
+
The recogniser is selected by language + an optional 'large' quality flag:
|
| 8 |
+
|
| 9 |
+
en (default) -> microsoft/trocr-base-handwritten (English, fast-ish)
|
| 10 |
+
en + large -> microsoft/trocr-large-handwritten (English, slower, more accurate)
|
| 11 |
+
hi (Hindi) -> sabaridsnfuji/Hindi_Offline_Handwritten_OCR (Hindi, EXPERIMENTAL)
|
| 12 |
+
ne (Nepali / generic Devanagari) -> aayushpuri01/TrOCR-Devanagari
|
| 13 |
+
|
| 14 |
+
It is OPTIONAL and heavy: it needs ``transformers`` + ``torch`` installed and a
|
| 15 |
+
one-time model download (hundreds of MB). It is slow on CPU and opt-in per
|
| 16 |
+
document. If the deps/model are missing it raises a clear, actionable error
|
| 17 |
+
instead of crashing, and unknown languages fall back to the English model.
|
| 18 |
+
|
| 19 |
+
Backward compatibility: ``lang='en'`` + ``large=False`` (the defaults, and the
|
| 20 |
+
module-level ``hw_engine`` singleton) behave byte-for-byte like the original
|
| 21 |
+
single-model implementation.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
import threading
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
# ----------------------------------------------------------------------------
|
| 31 |
+
# Model registry. Each entry describes one recogniser.
|
| 32 |
+
#
|
| 33 |
+
# id : HuggingFace repo for the VisionEncoderDecoderModel weights.
|
| 34 |
+
# processor : how to build the TrOCRProcessor:
|
| 35 |
+
# "bundled" -> TrOCRProcessor.from_pretrained(id) (id ships the
|
| 36 |
+
# preprocessor_config + tokenizer)
|
| 37 |
+
# ("manual", feat_repo, tok_repo) -> build from a ViT feature
|
| 38 |
+
# extractor repo + a decoder tokenizer repo, because
|
| 39 |
+
# the weights repo ships ONLY config + safetensors.
|
| 40 |
+
# max_new_tokens : decode cap (the Hindi model is trained for <=64 chars/crop).
|
| 41 |
+
# experimental : True for models whose quality on this task is unvalidated.
|
| 42 |
+
# ----------------------------------------------------------------------------
|
| 43 |
+
_MODELS = {
|
| 44 |
+
"en": {
|
| 45 |
+
"id": "microsoft/trocr-base-handwritten",
|
| 46 |
+
"processor": "bundled",
|
| 47 |
+
"max_new_tokens": 96,
|
| 48 |
+
"experimental": False,
|
| 49 |
+
},
|
| 50 |
+
"en_large": {
|
| 51 |
+
"id": "microsoft/trocr-large-handwritten",
|
| 52 |
+
"processor": "bundled",
|
| 53 |
+
"max_new_tokens": 96,
|
| 54 |
+
"experimental": False,
|
| 55 |
+
},
|
| 56 |
+
"hi": {
|
| 57 |
+
# Only genuinely Hindi-trained option found. Repo ships weights only, so
|
| 58 |
+
# the processor must be assembled from the encoder + decoder base repos.
|
| 59 |
+
# Quality is modest and UNVALIDATED here — treat as experimental.
|
| 60 |
+
"id": "sabaridsnfuji/Hindi_Offline_Handwritten_OCR",
|
| 61 |
+
"processor": ("manual",
|
| 62 |
+
"google/vit-base-patch16-224-in21k",
|
| 63 |
+
"surajp/RoBERTa-hindi-guj-san"),
|
| 64 |
+
"max_new_tokens": 64, # model is trained for <=64 chars per crop
|
| 65 |
+
"experimental": True,
|
| 66 |
+
},
|
| 67 |
+
"ne": {
|
| 68 |
+
# Nepali Devanagari; bundled processor, clean drop-in. Usable as a
|
| 69 |
+
# generic Devanagari fallback (reads many Hindi glyphs, Nepali-biased).
|
| 70 |
+
"id": "aayushpuri01/TrOCR-Devanagari",
|
| 71 |
+
"processor": "bundled",
|
| 72 |
+
"max_new_tokens": 64,
|
| 73 |
+
"experimental": True,
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# Default fallback when a requested language has no handwriting model.
|
| 78 |
+
_DEFAULT_KEY = "en"
|
| 79 |
+
|
| 80 |
+
# Backwards-compat constant some callers/tests may import.
|
| 81 |
+
MODEL_NAME = _MODELS[_DEFAULT_KEY]["id"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _resolve_key(lang: str, large: bool) -> str:
|
| 85 |
+
"""Map a (lang, large) request to a registry key, falling back to English.
|
| 86 |
+
|
| 87 |
+
Devanagari-family languages share script; 'hi'/'ne'/'devanagari' all route
|
| 88 |
+
to a Devanagari model. 'large' currently only applies to English (no large
|
| 89 |
+
Devanagari model exists), so it is ignored for non-English.
|
| 90 |
+
"""
|
| 91 |
+
lang = (lang or "en").strip().lower()
|
| 92 |
+
if lang in ("en", "english"):
|
| 93 |
+
return "en_large" if large else "en"
|
| 94 |
+
if lang in ("hi", "hin", "hindi", "mr", "marathi", "sa"):
|
| 95 |
+
return "hi"
|
| 96 |
+
if lang in ("ne", "nep", "nepali", "devanagari"):
|
| 97 |
+
return "ne"
|
| 98 |
+
return _DEFAULT_KEY
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def is_available() -> bool:
|
| 102 |
+
"""True if the optional handwriting dependencies are importable."""
|
| 103 |
+
try:
|
| 104 |
+
import torch # noqa: F401
|
| 105 |
+
import transformers # noqa: F401
|
| 106 |
+
return True
|
| 107 |
+
except Exception:
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _dist(a, b) -> float:
|
| 112 |
+
return float(np.hypot(a[0] - b[0], a[1] - b[1]))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _crop_quad(img: np.ndarray, box) -> np.ndarray:
|
| 116 |
+
"""Perspective-warp a 4-point text box to an upright rectangle crop."""
|
| 117 |
+
pts = np.array(box, dtype="float32")
|
| 118 |
+
w = int(max(_dist(pts[0], pts[1]), _dist(pts[2], pts[3])))
|
| 119 |
+
h = int(max(_dist(pts[1], pts[2]), _dist(pts[3], pts[0])))
|
| 120 |
+
w = max(w, 1)
|
| 121 |
+
h = max(h, 1)
|
| 122 |
+
dst = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype="float32")
|
| 123 |
+
matrix = cv2.getPerspectiveTransform(pts, dst)
|
| 124 |
+
return cv2.warpPerspective(img, matrix, (w, h))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class HandwritingEngine:
|
| 128 |
+
"""Lazy-loaded TrOCR-family wrapper for ONE registry entry.
|
| 129 |
+
|
| 130 |
+
Thread-safe; one model instance reused. Construct via the module-level
|
| 131 |
+
``get_engine(lang, large)`` factory so identical models are shared.
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
def __init__(self, key: str = _DEFAULT_KEY) -> None:
|
| 135 |
+
self._key = key if key in _MODELS else _DEFAULT_KEY
|
| 136 |
+
self._cfg = _MODELS[self._key]
|
| 137 |
+
self._lock = threading.Lock()
|
| 138 |
+
self._model = None
|
| 139 |
+
self._processor = None
|
| 140 |
+
self._loaded = False
|
| 141 |
+
self._error = None
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def model_id(self) -> str:
|
| 145 |
+
return self._cfg["id"]
|
| 146 |
+
|
| 147 |
+
@property
|
| 148 |
+
def experimental(self) -> bool:
|
| 149 |
+
return bool(self._cfg.get("experimental"))
|
| 150 |
+
|
| 151 |
+
def _build_processor(self):
|
| 152 |
+
from transformers import TrOCRProcessor
|
| 153 |
+
|
| 154 |
+
proc = self._cfg["processor"]
|
| 155 |
+
if proc == "bundled":
|
| 156 |
+
return TrOCRProcessor.from_pretrained(self._cfg["id"])
|
| 157 |
+
# ("manual", feature_extractor_repo, tokenizer_repo)
|
| 158 |
+
_, feat_repo, tok_repo = proc
|
| 159 |
+
from transformers import AutoTokenizer
|
| 160 |
+
|
| 161 |
+
# AutoImageProcessor is the modern name; in transformers 4.40.2
|
| 162 |
+
# AutoFeatureExtractor still resolves it fine as a fallback.
|
| 163 |
+
try:
|
| 164 |
+
from transformers import AutoImageProcessor as _FeatLoader
|
| 165 |
+
except Exception: # very old fallback
|
| 166 |
+
from transformers import AutoFeatureExtractor as _FeatLoader
|
| 167 |
+
|
| 168 |
+
feature_extractor = _FeatLoader.from_pretrained(feat_repo)
|
| 169 |
+
tokenizer = AutoTokenizer.from_pretrained(tok_repo)
|
| 170 |
+
# TrOCRProcessor accepts an image processor as `image_processor=`; older
|
| 171 |
+
# transformers used `feature_extractor=`. 4.40.2 accepts both kwargs.
|
| 172 |
+
try:
|
| 173 |
+
return TrOCRProcessor(image_processor=feature_extractor,
|
| 174 |
+
tokenizer=tokenizer)
|
| 175 |
+
except TypeError:
|
| 176 |
+
return TrOCRProcessor(feature_extractor=feature_extractor,
|
| 177 |
+
tokenizer=tokenizer)
|
| 178 |
+
|
| 179 |
+
def _ensure(self) -> None:
|
| 180 |
+
if self._loaded:
|
| 181 |
+
if self._error:
|
| 182 |
+
raise RuntimeError(self._error)
|
| 183 |
+
return
|
| 184 |
+
with self._lock:
|
| 185 |
+
if self._loaded:
|
| 186 |
+
if self._error:
|
| 187 |
+
raise RuntimeError(self._error)
|
| 188 |
+
return
|
| 189 |
+
try:
|
| 190 |
+
import torch
|
| 191 |
+
from transformers import VisionEncoderDecoderModel
|
| 192 |
+
|
| 193 |
+
torch.set_num_threads(max(1, os.cpu_count() or 1))
|
| 194 |
+
self._processor = self._build_processor()
|
| 195 |
+
self._model = VisionEncoderDecoderModel.from_pretrained(
|
| 196 |
+
self._cfg["id"]
|
| 197 |
+
)
|
| 198 |
+
self._model.eval()
|
| 199 |
+
self._loaded = True
|
| 200 |
+
except (ImportError, ModuleNotFoundError) as exc:
|
| 201 |
+
# Missing deps are DETERMINISTIC — they won't fix themselves at
|
| 202 |
+
# runtime, so cache the failure permanently (fail fast).
|
| 203 |
+
self._error = (
|
| 204 |
+
"Handwriting model '{}' unavailable ({}). Enable it with: "
|
| 205 |
+
"pip install transformers torch (first use downloads the "
|
| 206 |
+
"model, a few hundred MB; non-English models also fetch a "
|
| 207 |
+
"tokenizer/feature-extractor repo).".format(
|
| 208 |
+
self._cfg["id"], exc
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
self._loaded = True
|
| 212 |
+
raise RuntimeError(self._error)
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
# TRANSIENT failure (network drop / partial HF download / HF
|
| 215 |
+
# outage / disk full). Do NOT poison the engine: leave _loaded
|
| 216 |
+
# False so the next request retries from_pretrained (which can
|
| 217 |
+
# resume the partial download). Otherwise the engine would be
|
| 218 |
+
# dead for the whole process until a server restart.
|
| 219 |
+
raise RuntimeError(
|
| 220 |
+
"Handwriting model '{}' could not be loaded ({}). This is "
|
| 221 |
+
"often a transient network/download issue — try again; a "
|
| 222 |
+
"later request will retry the download.".format(
|
| 223 |
+
self._cfg["id"], exc
|
| 224 |
+
)
|
| 225 |
+
) from exc
|
| 226 |
+
|
| 227 |
+
def _recognize(self, crop_bgr: np.ndarray) -> str:
|
| 228 |
+
import torch
|
| 229 |
+
from PIL import Image
|
| 230 |
+
|
| 231 |
+
rgb = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB)
|
| 232 |
+
pil = Image.fromarray(rgb)
|
| 233 |
+
pixel_values = self._processor(
|
| 234 |
+
images=pil, return_tensors="pt"
|
| 235 |
+
).pixel_values
|
| 236 |
+
with torch.no_grad():
|
| 237 |
+
generated = self._model.generate(
|
| 238 |
+
pixel_values, max_new_tokens=self._cfg["max_new_tokens"]
|
| 239 |
+
)
|
| 240 |
+
return self._processor.batch_decode(
|
| 241 |
+
generated, skip_special_tokens=True
|
| 242 |
+
)[0]
|
| 243 |
+
|
| 244 |
+
def ocr_text(self, img_bgr: np.ndarray, boxes: list) -> str:
|
| 245 |
+
"""Recognize handwriting in ``img_bgr`` given detected line ``boxes``.
|
| 246 |
+
|
| 247 |
+
Boxes are ordered into reading order (top-to-bottom, left-to-right),
|
| 248 |
+
each cropped and passed through the recogniser, joined by newlines.
|
| 249 |
+
"""
|
| 250 |
+
self._ensure()
|
| 251 |
+
if not boxes:
|
| 252 |
+
return ""
|
| 253 |
+
|
| 254 |
+
items = []
|
| 255 |
+
for box in boxes:
|
| 256 |
+
# Skip any malformed box (not a 4-point quad) so a single bad
|
| 257 |
+
# detection can't abort the whole page's handwriting OCR.
|
| 258 |
+
if not box or len(box) != 4:
|
| 259 |
+
continue
|
| 260 |
+
try:
|
| 261 |
+
ys = [p[1] for p in box]
|
| 262 |
+
xs = [p[0] for p in box]
|
| 263 |
+
except (TypeError, IndexError):
|
| 264 |
+
continue
|
| 265 |
+
items.append({
|
| 266 |
+
"box": box,
|
| 267 |
+
"cy": sum(ys) / len(ys),
|
| 268 |
+
"cx": sum(xs) / len(xs),
|
| 269 |
+
"h": max(ys) - min(ys),
|
| 270 |
+
})
|
| 271 |
+
if not items:
|
| 272 |
+
return ""
|
| 273 |
+
heights = [it["h"] for it in items if it["h"] > 0]
|
| 274 |
+
tol = max((float(np.median(heights)) if heights else 12.0) * 0.6, 8.0)
|
| 275 |
+
items.sort(key=lambda it: (it["cy"], it["cx"]))
|
| 276 |
+
|
| 277 |
+
rows = [[items[0]]]
|
| 278 |
+
cy = items[0]["cy"]
|
| 279 |
+
for it in items[1:]:
|
| 280 |
+
if abs(it["cy"] - cy) <= tol:
|
| 281 |
+
rows[-1].append(it)
|
| 282 |
+
else:
|
| 283 |
+
rows.append([it])
|
| 284 |
+
cy = it["cy"]
|
| 285 |
+
|
| 286 |
+
lines = []
|
| 287 |
+
for row in rows:
|
| 288 |
+
for it in sorted(row, key=lambda x: x["cx"]):
|
| 289 |
+
try:
|
| 290 |
+
crop = _crop_quad(img_bgr, it["box"])
|
| 291 |
+
if crop.size == 0:
|
| 292 |
+
continue
|
| 293 |
+
text = self._recognize(crop).strip()
|
| 294 |
+
except Exception:
|
| 295 |
+
text = ""
|
| 296 |
+
if text:
|
| 297 |
+
lines.append(text)
|
| 298 |
+
return "\n".join(lines)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ----------------------------------------------------------------------------
|
| 302 |
+
# Factory: one cached engine per registry key (so 'en', 'en_large' and a
|
| 303 |
+
# Devanagari model can coexist without reloading). Thread-safe.
|
| 304 |
+
# ----------------------------------------------------------------------------
|
| 305 |
+
_engines: dict = {}
|
| 306 |
+
_engines_lock = threading.Lock()
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def get_engine(lang: str = "en", large: bool = False) -> "HandwritingEngine":
|
| 310 |
+
"""Return a cached handwriting engine for the requested (lang, large)."""
|
| 311 |
+
key = _resolve_key(lang, large)
|
| 312 |
+
eng = _engines.get(key)
|
| 313 |
+
if eng is not None:
|
| 314 |
+
return eng
|
| 315 |
+
with _engines_lock:
|
| 316 |
+
eng = _engines.get(key)
|
| 317 |
+
if eng is None:
|
| 318 |
+
eng = HandwritingEngine(key)
|
| 319 |
+
_engines[key] = eng
|
| 320 |
+
return eng
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# Backwards-compatible module-level singleton (English base), so existing
|
| 324 |
+
# `from pipeline.handwriting import hw_engine` callers keep working unchanged.
|
| 325 |
+
hw_engine = get_engine("en", large=False)
|
pipeline/jobs.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async job manager for PDF extraction.
|
| 2 |
+
|
| 3 |
+
One job per uploaded file. Heavy CPU work (rendering + OCR) is serialized
|
| 4 |
+
through a SINGLE-worker ThreadPoolExecutor via loop.run_in_executor so OCR
|
| 5 |
+
never runs concurrently. Per-job progress is pushed to an asyncio.Queue that
|
| 6 |
+
is consumed by the SSE endpoint.
|
| 7 |
+
|
| 8 |
+
Implements the project contract exactly:
|
| 9 |
+
Job dataclass + to_dict()
|
| 10 |
+
JobManager.create_job / get / run / cancel / events / render_page_png
|
| 11 |
+
manager = JobManager() module-level singleton
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
import uuid
|
| 18 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import fitz # type: ignore
|
| 23 |
+
|
| 24 |
+
from pipeline.extractor import extract_page
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Single-worker executor: serializes all heavy CPU work (OCR is not
|
| 28 |
+
# thread-safe and we want deterministic, low-memory CPU usage).
|
| 29 |
+
_EXECUTOR = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ocr-worker")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# --- Job retention / eviction policy (in-memory only; single-user local) ------
|
| 33 |
+
# A finished job keeps its full pdf_bytes in RAM. To bound memory across a long
|
| 34 |
+
# session we evict the oldest / expired *finished, unsubscribed* jobs. Jobs that
|
| 35 |
+
# are still pending/processing, or that have a live SSE subscriber, are NEVER
|
| 36 |
+
# eviction candidates regardless of these limits.
|
| 37 |
+
MAX_JOBS = 50 # keep at most this many evictable jobs
|
| 38 |
+
JOB_TTL_SECONDS = 2 * 60 * 60 # evict finished+idle jobs older than this
|
| 39 |
+
_SWEEP_MIN_INTERVAL = 5.0 # throttle: don't sweep on get() more than
|
| 40 |
+
# once per this many seconds (monotonic)
|
| 41 |
+
|
| 42 |
+
_TERMINAL_STATES = ("done", "error", "cancelled")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class PageResult:
|
| 47 |
+
page: int # 1-based
|
| 48 |
+
source: Optional[str] = None # "text" | "ocr" | None
|
| 49 |
+
text: str = ""
|
| 50 |
+
status: str = "pending" # "pending" | "done" | "error"
|
| 51 |
+
error: Optional[str] = None
|
| 52 |
+
confidence: Optional[float] = None # mean OCR confidence [0,1]; None for text-layer
|
| 53 |
+
lines: Optional[list] = None # per-line [{text, confidence}] for OCR pages (else None)
|
| 54 |
+
|
| 55 |
+
def to_dict(self) -> dict:
|
| 56 |
+
return {
|
| 57 |
+
"page": self.page,
|
| 58 |
+
"source": self.source,
|
| 59 |
+
"text": self.text,
|
| 60 |
+
"status": self.status,
|
| 61 |
+
"error": self.error,
|
| 62 |
+
"confidence": self.confidence,
|
| 63 |
+
"lines": self.lines,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class Job:
|
| 69 |
+
job_id: str
|
| 70 |
+
filename: str
|
| 71 |
+
pdf_bytes: bytes
|
| 72 |
+
mode: str
|
| 73 |
+
lang: str
|
| 74 |
+
preprocess: bool
|
| 75 |
+
binarize: bool
|
| 76 |
+
handwriting: bool
|
| 77 |
+
online: bool
|
| 78 |
+
online_key: str # NOT serialized in to_dict() — never leaked to the API
|
| 79 |
+
online_model: str
|
| 80 |
+
force_ocr: bool
|
| 81 |
+
remove_headers: bool
|
| 82 |
+
total_pages: int
|
| 83 |
+
pages: list = field(default_factory=list) # list[PageResult]
|
| 84 |
+
status: str = "pending" # pending|processing|done|error|cancelled
|
| 85 |
+
processed_pages: int = 0
|
| 86 |
+
error: Optional[str] = None
|
| 87 |
+
|
| 88 |
+
# Internals (not serialized). One asyncio.Queue per live SSE subscriber so
|
| 89 |
+
# progress is broadcast (fan-out), not consumed once — reconnects are safe.
|
| 90 |
+
subscribers: list = field(default_factory=list, repr=False)
|
| 91 |
+
cancel_event: "threading.Event" = field(default_factory=threading.Event, repr=False)
|
| 92 |
+
# Eviction bookkeeping (monotonic clock; immune to wall-clock changes).
|
| 93 |
+
created_at: float = field(default_factory=time.monotonic, repr=False)
|
| 94 |
+
last_access: float = field(default_factory=time.monotonic, repr=False)
|
| 95 |
+
|
| 96 |
+
def to_dict(self) -> dict:
|
| 97 |
+
return {
|
| 98 |
+
"job_id": self.job_id,
|
| 99 |
+
"filename": self.filename,
|
| 100 |
+
"status": self.status,
|
| 101 |
+
"total_pages": self.total_pages,
|
| 102 |
+
"processed_pages": self.processed_pages,
|
| 103 |
+
"pages": [p.to_dict() for p in self.pages],
|
| 104 |
+
"error": self.error,
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _try_repair(pdf_bytes: bytes):
|
| 109 |
+
"""Attempt to repair a PDF fitz can't open, using pikepdf. Returns clean
|
| 110 |
+
bytes on success, or None if repair is unavailable/failed."""
|
| 111 |
+
try:
|
| 112 |
+
import io as _io
|
| 113 |
+
import pikepdf # optional; only used as a fallback
|
| 114 |
+
|
| 115 |
+
out = _io.BytesIO()
|
| 116 |
+
with pikepdf.open(_io.BytesIO(pdf_bytes)) as pdf:
|
| 117 |
+
pdf.save(out)
|
| 118 |
+
return out.getvalue()
|
| 119 |
+
except Exception:
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class JobManager:
|
| 124 |
+
def __init__(self) -> None:
|
| 125 |
+
self._jobs: dict[str, Job] = {}
|
| 126 |
+
self._lock = threading.Lock()
|
| 127 |
+
# Throttle for get()-triggered sweeps (guarded by self._lock).
|
| 128 |
+
self._last_sweep: float = 0.0
|
| 129 |
+
|
| 130 |
+
# --- eviction internals --------------------------------------------------
|
| 131 |
+
@staticmethod
|
| 132 |
+
def _is_evictable(job: "Job") -> bool:
|
| 133 |
+
"""A job may be evicted only if it has reached a terminal state AND has
|
| 134 |
+
no live SSE subscriber. pending/processing or subscribed jobs are never
|
| 135 |
+
touched, so eviction can't break an in-flight run or an open stream.
|
| 136 |
+
|
| 137 |
+
``job.subscribers`` is mutated only on the event loop (events()); reading
|
| 138 |
+
its length here under self._lock is a plain, atomic CPython list read.
|
| 139 |
+
A momentarily-stale read is safe: worst case we *skip* evicting a job
|
| 140 |
+
that just lost its last subscriber, deferring it to the next sweep.
|
| 141 |
+
"""
|
| 142 |
+
return job.status in _TERMINAL_STATES and not job.subscribers
|
| 143 |
+
|
| 144 |
+
def _evict_locked(self, now: float) -> None:
|
| 145 |
+
"""Evict expired, then over-cap, evictable jobs. MUST hold self._lock.
|
| 146 |
+
|
| 147 |
+
1. TTL: drop evictable jobs whose last_access is older than the TTL.
|
| 148 |
+
2. Cap: if more than MAX_JOBS evictable jobs remain, drop the
|
| 149 |
+
least-recently-accessed ones until at most MAX_JOBS are left.
|
| 150 |
+
Non-evictable (active/subscribed) jobs are never removed and do not
|
| 151 |
+
count against the cap as targets — they're simply left in place.
|
| 152 |
+
"""
|
| 153 |
+
self._last_sweep = now
|
| 154 |
+
|
| 155 |
+
# Pass 1 — TTL on evictable jobs.
|
| 156 |
+
if JOB_TTL_SECONDS > 0:
|
| 157 |
+
expired = [
|
| 158 |
+
jid
|
| 159 |
+
for jid, job in self._jobs.items()
|
| 160 |
+
if self._is_evictable(job)
|
| 161 |
+
and (now - job.last_access) > JOB_TTL_SECONDS
|
| 162 |
+
]
|
| 163 |
+
for jid in expired:
|
| 164 |
+
del self._jobs[jid]
|
| 165 |
+
|
| 166 |
+
# Pass 2 — count cap on remaining evictable jobs (LRU by last_access).
|
| 167 |
+
if MAX_JOBS >= 0:
|
| 168 |
+
evictable = [
|
| 169 |
+
(job.last_access, jid)
|
| 170 |
+
for jid, job in self._jobs.items()
|
| 171 |
+
if self._is_evictable(job)
|
| 172 |
+
]
|
| 173 |
+
overflow = len(evictable) - MAX_JOBS
|
| 174 |
+
if overflow > 0:
|
| 175 |
+
evictable.sort() # oldest last_access first
|
| 176 |
+
for _, jid in evictable[:overflow]:
|
| 177 |
+
job = self._jobs.get(jid)
|
| 178 |
+
if job is not None and self._is_evictable(job):
|
| 179 |
+
del self._jobs[jid]
|
| 180 |
+
|
| 181 |
+
def create_job(
|
| 182 |
+
self,
|
| 183 |
+
filename: str,
|
| 184 |
+
pdf_bytes: bytes,
|
| 185 |
+
*,
|
| 186 |
+
mode: str,
|
| 187 |
+
lang: str,
|
| 188 |
+
preprocess: bool,
|
| 189 |
+
binarize: bool = False,
|
| 190 |
+
handwriting: bool = False,
|
| 191 |
+
online: bool = False,
|
| 192 |
+
online_key: str = "",
|
| 193 |
+
online_model: str = "",
|
| 194 |
+
force_ocr: bool = False,
|
| 195 |
+
remove_headers: bool = True,
|
| 196 |
+
) -> Job:
|
| 197 |
+
"""Open the PDF to validate it and count pages, then register a Job.
|
| 198 |
+
|
| 199 |
+
Raises ValueError("encrypted") for password-protected PDFs and
|
| 200 |
+
ValueError(<msg>) for corrupt / unreadable PDFs.
|
| 201 |
+
"""
|
| 202 |
+
usable_bytes = pdf_bytes
|
| 203 |
+
try:
|
| 204 |
+
doc = fitz.open(stream=usable_bytes, filetype="pdf")
|
| 205 |
+
except Exception as exc: # corrupt / not a PDF — try to repair once
|
| 206 |
+
repaired = _try_repair(pdf_bytes)
|
| 207 |
+
if repaired is None:
|
| 208 |
+
raise ValueError(f"corrupt: {exc}") from exc
|
| 209 |
+
usable_bytes = repaired
|
| 210 |
+
try:
|
| 211 |
+
doc = fitz.open(stream=usable_bytes, filetype="pdf")
|
| 212 |
+
except Exception as exc2:
|
| 213 |
+
raise ValueError(f"corrupt: {exc2}") from exc2
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
if doc.needs_pass:
|
| 217 |
+
raise ValueError("encrypted")
|
| 218 |
+
try:
|
| 219 |
+
total_pages = doc.page_count
|
| 220 |
+
except Exception as exc:
|
| 221 |
+
raise ValueError(f"corrupt: {exc}") from exc
|
| 222 |
+
if total_pages <= 0:
|
| 223 |
+
raise ValueError("corrupt: no pages")
|
| 224 |
+
finally:
|
| 225 |
+
doc.close()
|
| 226 |
+
|
| 227 |
+
job_id = uuid.uuid4().hex
|
| 228 |
+
job = Job(
|
| 229 |
+
job_id=job_id,
|
| 230 |
+
filename=filename,
|
| 231 |
+
pdf_bytes=usable_bytes,
|
| 232 |
+
mode=mode,
|
| 233 |
+
lang=lang,
|
| 234 |
+
preprocess=preprocess,
|
| 235 |
+
binarize=binarize,
|
| 236 |
+
handwriting=handwriting,
|
| 237 |
+
online=online,
|
| 238 |
+
online_key=online_key,
|
| 239 |
+
online_model=online_model,
|
| 240 |
+
force_ocr=force_ocr,
|
| 241 |
+
remove_headers=remove_headers,
|
| 242 |
+
total_pages=total_pages,
|
| 243 |
+
pages=[PageResult(page=i + 1) for i in range(total_pages)],
|
| 244 |
+
status="pending",
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
with self._lock:
|
| 248 |
+
self._evict_locked(time.monotonic()) # reclaim stale jobs first
|
| 249 |
+
self._jobs[job_id] = job
|
| 250 |
+
return job
|
| 251 |
+
|
| 252 |
+
def get(self, job_id: str) -> Optional[Job]:
|
| 253 |
+
now = time.monotonic()
|
| 254 |
+
with self._lock:
|
| 255 |
+
job = self._jobs.get(job_id)
|
| 256 |
+
if job is not None:
|
| 257 |
+
# Touch: keeps actively-used jobs (polling, image render, SSE
|
| 258 |
+
# reconnect) from aging out under the TTL.
|
| 259 |
+
job.last_access = now
|
| 260 |
+
# Throttled TTL sweep so idle sessions still reclaim memory even
|
| 261 |
+
# with no new uploads, without sweeping on every single get().
|
| 262 |
+
if now - self._last_sweep >= _SWEEP_MIN_INTERVAL:
|
| 263 |
+
self._evict_locked(now)
|
| 264 |
+
return job
|
| 265 |
+
|
| 266 |
+
@staticmethod
|
| 267 |
+
def _broadcast(job: Job, event: dict) -> None:
|
| 268 |
+
"""Fan an event out to every live SSE subscriber's queue.
|
| 269 |
+
|
| 270 |
+
Runs on the event loop; queues are unbounded so put_nowait never
|
| 271 |
+
blocks. Each SSE connection drains its own queue independently.
|
| 272 |
+
"""
|
| 273 |
+
for q in list(job.subscribers):
|
| 274 |
+
try:
|
| 275 |
+
q.put_nowait(event)
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
async def run(self, job: Job) -> None:
|
| 280 |
+
"""Process every page of the job sequentially.
|
| 281 |
+
|
| 282 |
+
Each page's extraction runs in the single-worker executor. The cancel
|
| 283 |
+
flag is checked between pages. Progress and terminal events are
|
| 284 |
+
broadcast to all subscriber queues for SSE consumption. The whole body
|
| 285 |
+
is guarded so the job always reaches a terminal state (no hung SSE).
|
| 286 |
+
"""
|
| 287 |
+
loop = asyncio.get_running_loop()
|
| 288 |
+
job.status = "processing"
|
| 289 |
+
|
| 290 |
+
try:
|
| 291 |
+
# Open one document for the lifetime of the run. Do it ON the single
|
| 292 |
+
# OCR worker thread (not the event loop) so that every fitz call in
|
| 293 |
+
# the app — open, load_page, get_pixmap, close — happens on that one
|
| 294 |
+
# thread. PyMuPDF/MuPDF is not thread-safe even across separate
|
| 295 |
+
# Document objects, and concurrent multi-file uploads would otherwise
|
| 296 |
+
# let this run() open a doc on the event loop while another job
|
| 297 |
+
# renders on the worker thread.
|
| 298 |
+
try:
|
| 299 |
+
doc = await loop.run_in_executor(
|
| 300 |
+
_EXECUTOR,
|
| 301 |
+
lambda: fitz.open(stream=job.pdf_bytes, filetype="pdf"),
|
| 302 |
+
)
|
| 303 |
+
except Exception as exc:
|
| 304 |
+
job.status = "error"
|
| 305 |
+
job.error = f"corrupt: {exc}"
|
| 306 |
+
self._broadcast(job, {"type": "error", "error": job.error})
|
| 307 |
+
return
|
| 308 |
+
|
| 309 |
+
try:
|
| 310 |
+
for idx in range(job.total_pages):
|
| 311 |
+
if job.cancel_event.is_set():
|
| 312 |
+
job.status = "cancelled"
|
| 313 |
+
self._broadcast(job, {"type": "cancelled"})
|
| 314 |
+
return
|
| 315 |
+
|
| 316 |
+
page_result = job.pages[idx]
|
| 317 |
+
|
| 318 |
+
def _work(page_index: int = idx) -> dict:
|
| 319 |
+
page = doc.load_page(page_index)
|
| 320 |
+
return extract_page(
|
| 321 |
+
page,
|
| 322 |
+
mode=job.mode,
|
| 323 |
+
lang=job.lang,
|
| 324 |
+
preprocess=job.preprocess,
|
| 325 |
+
binarize=job.binarize,
|
| 326 |
+
handwriting=job.handwriting,
|
| 327 |
+
online=job.online,
|
| 328 |
+
online_key=job.online_key,
|
| 329 |
+
online_model=job.online_model,
|
| 330 |
+
force_ocr=job.force_ocr,
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
try:
|
| 334 |
+
result = await loop.run_in_executor(_EXECUTOR, _work)
|
| 335 |
+
page_result.source = result.get("source")
|
| 336 |
+
page_result.text = result.get("text", "") or ""
|
| 337 |
+
page_result.confidence = result.get("confidence")
|
| 338 |
+
page_result.lines = result.get("lines")
|
| 339 |
+
page_result.status = "done"
|
| 340 |
+
page_result.error = None
|
| 341 |
+
except Exception as exc: # per-page failure: keep going
|
| 342 |
+
page_result.source = None
|
| 343 |
+
page_result.text = ""
|
| 344 |
+
page_result.confidence = None
|
| 345 |
+
page_result.lines = None
|
| 346 |
+
page_result.status = "error"
|
| 347 |
+
page_result.error = str(exc)
|
| 348 |
+
|
| 349 |
+
job.processed_pages += 1
|
| 350 |
+
|
| 351 |
+
self._broadcast(
|
| 352 |
+
job,
|
| 353 |
+
{
|
| 354 |
+
"type": "progress",
|
| 355 |
+
"processed": job.processed_pages,
|
| 356 |
+
"total": job.total_pages,
|
| 357 |
+
"page": page_result.to_dict(),
|
| 358 |
+
},
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Cross-page cleanup: strip running headers/footers + page
|
| 362 |
+
# numbers now that every page's text is available. Best-effort;
|
| 363 |
+
# never fail the job over it. Updated pages are re-broadcast so
|
| 364 |
+
# the live UI (and reconnect snapshot/exports) show cleaned text.
|
| 365 |
+
if job.remove_headers and not job.cancel_event.is_set():
|
| 366 |
+
try:
|
| 367 |
+
from pipeline.postprocess import (
|
| 368 |
+
strip_running_headers_footers,
|
| 369 |
+
realign_line_confidences,
|
| 370 |
+
)
|
| 371 |
+
originals = [p.text or "" for p in job.pages]
|
| 372 |
+
cleaned, kept_indices = strip_running_headers_footers(
|
| 373 |
+
originals, return_kept=True
|
| 374 |
+
)
|
| 375 |
+
for p, new_text, kept in zip(
|
| 376 |
+
job.pages, cleaned, kept_indices
|
| 377 |
+
):
|
| 378 |
+
if p.status == "done" and new_text != (p.text or ""):
|
| 379 |
+
if p.lines:
|
| 380 |
+
# An OCR page's per-line list is 1:1 with its
|
| 381 |
+
# text lines, so map confidences to the EXACT
|
| 382 |
+
# surviving lines by their original index
|
| 383 |
+
# (correct even for identical-text duplicate
|
| 384 |
+
# lines). Fall back to the text matcher only
|
| 385 |
+
# if the indices don't line up with the
|
| 386 |
+
# cleaned text (rare blank-detection edge).
|
| 387 |
+
mapped = [
|
| 388 |
+
p.lines[i]
|
| 389 |
+
for i in kept
|
| 390 |
+
if 0 <= i < len(p.lines)
|
| 391 |
+
]
|
| 392 |
+
if [
|
| 393 |
+
str(m.get("text", "")) for m in mapped
|
| 394 |
+
] == new_text.split("\n"):
|
| 395 |
+
p.lines = mapped
|
| 396 |
+
else:
|
| 397 |
+
p.lines = realign_line_confidences(
|
| 398 |
+
new_text, p.lines
|
| 399 |
+
)
|
| 400 |
+
p.text = new_text
|
| 401 |
+
self._broadcast(
|
| 402 |
+
job,
|
| 403 |
+
{
|
| 404 |
+
"type": "progress",
|
| 405 |
+
"processed": job.processed_pages,
|
| 406 |
+
"total": job.total_pages,
|
| 407 |
+
"page": p.to_dict(),
|
| 408 |
+
},
|
| 409 |
+
)
|
| 410 |
+
except Exception:
|
| 411 |
+
pass
|
| 412 |
+
|
| 413 |
+
# Completed all pages (unless cancelled above).
|
| 414 |
+
if job.cancel_event.is_set():
|
| 415 |
+
job.status = "cancelled"
|
| 416 |
+
self._broadcast(job, {"type": "cancelled"})
|
| 417 |
+
else:
|
| 418 |
+
# If every page errored we still report done; the page
|
| 419 |
+
# records carry the per-page error state. A whole-job error
|
| 420 |
+
# is only for fatal failures handled above.
|
| 421 |
+
job.status = "done"
|
| 422 |
+
self._broadcast(job, {"type": "done"})
|
| 423 |
+
finally:
|
| 424 |
+
# Close on the worker thread too (same single-thread invariant),
|
| 425 |
+
# and never let a close error overwrite the terminal status.
|
| 426 |
+
try:
|
| 427 |
+
await loop.run_in_executor(_EXECUTOR, doc.close)
|
| 428 |
+
except Exception:
|
| 429 |
+
pass
|
| 430 |
+
except Exception as exc: # never strand subscribers in a hung stream
|
| 431 |
+
job.status = "error"
|
| 432 |
+
job.error = str(exc)
|
| 433 |
+
self._broadcast(job, {"type": "error", "error": job.error})
|
| 434 |
+
|
| 435 |
+
def cancel(self, job_id: str) -> None:
|
| 436 |
+
job = self.get(job_id)
|
| 437 |
+
if job is not None:
|
| 438 |
+
job.cancel_event.set()
|
| 439 |
+
|
| 440 |
+
def delete(self, job_id: str) -> bool:
|
| 441 |
+
"""Drop a job from the registry and request cancellation.
|
| 442 |
+
|
| 443 |
+
Removes the job from ``_jobs`` immediately (so it's no longer findable)
|
| 444 |
+
and sets its cancel flag. The PDF bytes are reclaimed once any in-flight
|
| 445 |
+
page finishes — cancellation is cooperative and checked between pages, so
|
| 446 |
+
a long page already running (e.g. a 120 s online-OCR call) keeps the
|
| 447 |
+
bytes alive until it returns. Idempotent — returns False if unknown.
|
| 448 |
+
"""
|
| 449 |
+
with self._lock:
|
| 450 |
+
job = self._jobs.pop(job_id, None)
|
| 451 |
+
if job is not None:
|
| 452 |
+
job.cancel_event.set()
|
| 453 |
+
return True
|
| 454 |
+
return False
|
| 455 |
+
|
| 456 |
+
async def events(self, job_id: str):
|
| 457 |
+
"""Async generator yielding SSE event dicts for a job.
|
| 458 |
+
|
| 459 |
+
On connect, replays a snapshot of every already-completed page so a
|
| 460 |
+
fresh OR reconnecting client catches up without losing progress. Then
|
| 461 |
+
streams live events from its own private subscriber queue until a
|
| 462 |
+
terminal {"type":"done"|"error"|"cancelled"} event. Duplicate page
|
| 463 |
+
events (snapshot + live) are harmless: the frontend keys pages by
|
| 464 |
+
number and re-renders idempotently.
|
| 465 |
+
"""
|
| 466 |
+
job = self.get(job_id)
|
| 467 |
+
if job is None:
|
| 468 |
+
yield {"type": "error", "error": "not found"}
|
| 469 |
+
return
|
| 470 |
+
|
| 471 |
+
terminal_types = {"done", "error", "cancelled"}
|
| 472 |
+
|
| 473 |
+
# Subscribe BEFORE snapshotting so no event slips through the gap.
|
| 474 |
+
q: "asyncio.Queue" = asyncio.Queue()
|
| 475 |
+
job.subscribers.append(q)
|
| 476 |
+
try:
|
| 477 |
+
# Snapshot: replay completed pages for catch-up / reconnect.
|
| 478 |
+
for p in job.pages:
|
| 479 |
+
if p.status in ("done", "error"):
|
| 480 |
+
yield {
|
| 481 |
+
"type": "progress",
|
| 482 |
+
"processed": job.processed_pages,
|
| 483 |
+
"total": job.total_pages,
|
| 484 |
+
"page": p.to_dict(),
|
| 485 |
+
}
|
| 486 |
+
if job.status in terminal_types:
|
| 487 |
+
yield {"type": job.status, "error": job.error}
|
| 488 |
+
return
|
| 489 |
+
|
| 490 |
+
while True:
|
| 491 |
+
try:
|
| 492 |
+
event = await asyncio.wait_for(q.get(), timeout=1.0)
|
| 493 |
+
except asyncio.TimeoutError:
|
| 494 |
+
# If the job finished while we waited and our queue drained,
|
| 495 |
+
# synthesize the terminal event so the client closes.
|
| 496 |
+
if job.status in terminal_types and q.empty():
|
| 497 |
+
yield {"type": job.status, "error": job.error}
|
| 498 |
+
return
|
| 499 |
+
continue
|
| 500 |
+
|
| 501 |
+
yield event
|
| 502 |
+
if event.get("type") in terminal_types:
|
| 503 |
+
return
|
| 504 |
+
finally:
|
| 505 |
+
try:
|
| 506 |
+
job.subscribers.remove(q)
|
| 507 |
+
except ValueError:
|
| 508 |
+
pass
|
| 509 |
+
|
| 510 |
+
def render_page_png(self, job_id: str, page_no: int, dpi: int = 150) -> bytes:
|
| 511 |
+
"""Render a 1-based page number to PNG bytes on demand."""
|
| 512 |
+
job = self.get(job_id)
|
| 513 |
+
if job is None:
|
| 514 |
+
raise KeyError("job not found")
|
| 515 |
+
|
| 516 |
+
doc = fitz.open(stream=job.pdf_bytes, filetype="pdf")
|
| 517 |
+
try:
|
| 518 |
+
if page_no < 1 or page_no > doc.page_count:
|
| 519 |
+
raise KeyError("page out of range")
|
| 520 |
+
page = doc.load_page(page_no - 1)
|
| 521 |
+
zoom = float(dpi) / 72.0
|
| 522 |
+
matrix = fitz.Matrix(zoom, zoom)
|
| 523 |
+
pix = page.get_pixmap(matrix=matrix, alpha=False)
|
| 524 |
+
return pix.tobytes("png")
|
| 525 |
+
finally:
|
| 526 |
+
doc.close()
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
manager = JobManager()
|
pipeline/ocr_engine.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PaddleOCR engine wrapper.
|
| 2 |
+
|
| 3 |
+
Caches one PaddleOCR instance per ``(lang, use_angle_cls)`` key and reuses it.
|
| 4 |
+
All OCR predict calls are serialized with a threading.Lock because PaddleOCR's
|
| 5 |
+
predictors are not safe to call concurrently from multiple threads.
|
| 6 |
+
|
| 7 |
+
Targets the PaddleOCR 2.7.3 API:
|
| 8 |
+
PaddleOCR(use_angle_cls=<bool>, lang=<code>, show_log=False)
|
| 9 |
+
result = instance.ocr(img_bgr, cls=use_angle_cls)
|
| 10 |
+
|
| 11 |
+
Result structure (PaddleOCR 2.7.x):
|
| 12 |
+
[
|
| 13 |
+
[
|
| 14 |
+
[box, (text, conf)],
|
| 15 |
+
...
|
| 16 |
+
]
|
| 17 |
+
]
|
| 18 |
+
where ``result[0]`` may be ``None`` when no text is detected.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import threading
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class OCREngine:
|
| 27 |
+
"""Singleton-style PaddleOCR wrapper with per-(lang, angle) caching."""
|
| 28 |
+
|
| 29 |
+
def __init__(self) -> None:
|
| 30 |
+
self._instances = {}
|
| 31 |
+
self._instances_lock = threading.Lock()
|
| 32 |
+
# Serializes all .ocr() predict calls across threads.
|
| 33 |
+
self._predict_lock = threading.Lock()
|
| 34 |
+
|
| 35 |
+
# ------------------------------------------------------------------
|
| 36 |
+
# Instance management
|
| 37 |
+
# ------------------------------------------------------------------
|
| 38 |
+
def _get_instance(self, lang: str, use_angle_cls: bool):
|
| 39 |
+
"""Return a cached PaddleOCR instance, creating it on first use."""
|
| 40 |
+
key = (lang, bool(use_angle_cls))
|
| 41 |
+
# Fast path: already created.
|
| 42 |
+
instance = self._instances.get(key)
|
| 43 |
+
if instance is not None:
|
| 44 |
+
return instance
|
| 45 |
+
|
| 46 |
+
with self._instances_lock:
|
| 47 |
+
# Re-check inside the lock to avoid double construction.
|
| 48 |
+
instance = self._instances.get(key)
|
| 49 |
+
if instance is None:
|
| 50 |
+
# Imported lazily so importing this module is cheap and does
|
| 51 |
+
# not trigger PaddleOCR/Paddle initialization at import time.
|
| 52 |
+
from paddleocr import PaddleOCR
|
| 53 |
+
|
| 54 |
+
# Accuracy-oriented inference tuning (all valid PaddleOCR
|
| 55 |
+
# 2.7.3 args, no extra model downloads):
|
| 56 |
+
# - det_limit_side_len 1536 (vs default 960): lets the
|
| 57 |
+
# detector use our high-DPI render instead of shrinking it,
|
| 58 |
+
# so small/dense text is found.
|
| 59 |
+
# - det_db_unclip_ratio 1.8 (vs 1.5): expands detected boxes
|
| 60 |
+
# so characters at box edges aren't clipped before recog.
|
| 61 |
+
# - det_db_box_thresh 0.5 (vs 0.6): recovers fainter text.
|
| 62 |
+
# - use_dilation: connects broken strokes in noisy scans.
|
| 63 |
+
instance = PaddleOCR(
|
| 64 |
+
use_angle_cls=bool(use_angle_cls),
|
| 65 |
+
lang=lang,
|
| 66 |
+
show_log=False,
|
| 67 |
+
det_limit_side_len=1536,
|
| 68 |
+
det_limit_type="max",
|
| 69 |
+
det_db_unclip_ratio=1.8,
|
| 70 |
+
det_db_box_thresh=0.5,
|
| 71 |
+
use_dilation=True,
|
| 72 |
+
)
|
| 73 |
+
self._instances[key] = instance
|
| 74 |
+
return instance
|
| 75 |
+
|
| 76 |
+
def detect_boxes(self, img_bgr: np.ndarray, *, lang: str = "en") -> list:
|
| 77 |
+
"""Return text-line boxes (4-point polygons) for the image.
|
| 78 |
+
|
| 79 |
+
Used by the handwriting pipeline, which detects lines with PaddleOCR but
|
| 80 |
+
recognizes them with a handwriting model. We reuse the normal OCR call
|
| 81 |
+
and keep only its boxes — PaddleOCR 2.7.3's detection-only (``rec=False``)
|
| 82 |
+
path has a numpy truth-value bug, so we avoid it.
|
| 83 |
+
"""
|
| 84 |
+
lines = self.ocr_lines(img_bgr, lang=lang, use_angle_cls=False)
|
| 85 |
+
return [line["box"] for line in lines]
|
| 86 |
+
|
| 87 |
+
def warmup(self, lang: str = "en") -> None:
|
| 88 |
+
"""Preload a PaddleOCR instance (downloads weights on first run).
|
| 89 |
+
|
| 90 |
+
Called at application startup so the first real request does not pay
|
| 91 |
+
the model-load / weight-download cost. Runs a tiny dummy inference to
|
| 92 |
+
force lazy predictor initialization.
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
# Construction (which on first run downloads model weights from the
|
| 96 |
+
# network) is the failure-prone step, so it MUST be inside the guard:
|
| 97 |
+
# a first-run download failure / corrupt model cache must not abort
|
| 98 |
+
# server startup. Lazy construction then retries on the first real
|
| 99 |
+
# OCR request, where jobs.py turns any failure into a per-page error.
|
| 100 |
+
instance = self._get_instance(lang, use_angle_cls=False)
|
| 101 |
+
dummy = np.full((32, 32, 3), 255, dtype=np.uint8)
|
| 102 |
+
with self._predict_lock:
|
| 103 |
+
instance.ocr(dummy, cls=False)
|
| 104 |
+
except Exception:
|
| 105 |
+
# Warmup is best-effort; a failure here must not crash startup.
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
# ------------------------------------------------------------------
|
| 109 |
+
# OCR
|
| 110 |
+
# ------------------------------------------------------------------
|
| 111 |
+
def _run(self, img_bgr: np.ndarray, lang: str, use_angle_cls: bool):
|
| 112 |
+
"""Run PaddleOCR under the predict lock and return the raw lines list.
|
| 113 |
+
|
| 114 |
+
Returns the list of ``[box, (text, conf)]`` entries (possibly empty).
|
| 115 |
+
"""
|
| 116 |
+
instance = self._get_instance(lang, use_angle_cls)
|
| 117 |
+
with self._predict_lock:
|
| 118 |
+
result = instance.ocr(img_bgr, cls=bool(use_angle_cls))
|
| 119 |
+
|
| 120 |
+
if not result:
|
| 121 |
+
return []
|
| 122 |
+
page = result[0]
|
| 123 |
+
if page is None:
|
| 124 |
+
return []
|
| 125 |
+
return page
|
| 126 |
+
|
| 127 |
+
@staticmethod
|
| 128 |
+
def _line_y(box) -> float:
|
| 129 |
+
"""Top-y coordinate of a detection box (4 [x, y] points)."""
|
| 130 |
+
return min(float(pt[1]) for pt in box)
|
| 131 |
+
|
| 132 |
+
@staticmethod
|
| 133 |
+
def _line_x(box) -> float:
|
| 134 |
+
"""Left-x coordinate of a detection box (4 [x, y] points)."""
|
| 135 |
+
return min(float(pt[0]) for pt in box)
|
| 136 |
+
|
| 137 |
+
@staticmethod
|
| 138 |
+
def _line_height(box) -> float:
|
| 139 |
+
ys = [float(pt[1]) for pt in box]
|
| 140 |
+
return max(ys) - min(ys)
|
| 141 |
+
|
| 142 |
+
def ocr_lines(
|
| 143 |
+
self, img_bgr: np.ndarray, *, lang: str, use_angle_cls: bool
|
| 144 |
+
) -> list:
|
| 145 |
+
"""Return detected text lines in reading order.
|
| 146 |
+
|
| 147 |
+
Each element is ``{"text": str, "confidence": float, "box": list}``.
|
| 148 |
+
Lines are grouped into rows by their vertical position, and within a
|
| 149 |
+
row sorted left-to-right, approximating natural reading order.
|
| 150 |
+
|
| 151 |
+
NOTE: this assumes a SINGLE-column layout. On a multi-column scan,
|
| 152 |
+
side-by-side lines at the same vertical position are merged into one row
|
| 153 |
+
and emitted left-to-right, so the two columns come out interleaved. Real
|
| 154 |
+
column reconstruction (XY-cut / gutter detection) is not implemented yet.
|
| 155 |
+
Born-digital multi-column PDFs take the text-layer path (textlayer.py,
|
| 156 |
+
same single-column caveat) or the Gemini path.
|
| 157 |
+
"""
|
| 158 |
+
raw = self._run(img_bgr, lang, use_angle_cls)
|
| 159 |
+
|
| 160 |
+
items = []
|
| 161 |
+
for entry in raw:
|
| 162 |
+
try:
|
| 163 |
+
box, payload = entry[0], entry[1]
|
| 164 |
+
text, conf = payload[0], payload[1]
|
| 165 |
+
if text is None or not box:
|
| 166 |
+
continue
|
| 167 |
+
# Compute geometry inside the guard so a single malformed
|
| 168 |
+
# detection box is skipped rather than aborting the whole page.
|
| 169 |
+
y = self._line_y(box)
|
| 170 |
+
x = self._line_x(box)
|
| 171 |
+
h = self._line_height(box)
|
| 172 |
+
except (TypeError, IndexError, ValueError):
|
| 173 |
+
continue
|
| 174 |
+
items.append(
|
| 175 |
+
{
|
| 176 |
+
"text": str(text),
|
| 177 |
+
"confidence": float(conf),
|
| 178 |
+
"box": box,
|
| 179 |
+
"_y": y,
|
| 180 |
+
"_x": x,
|
| 181 |
+
"_h": h,
|
| 182 |
+
}
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
if not items:
|
| 186 |
+
return []
|
| 187 |
+
|
| 188 |
+
# Group items into rows: two items share a row if their top-y values
|
| 189 |
+
# are within a tolerance derived from the median glyph height.
|
| 190 |
+
heights = [it["_h"] for it in items if it["_h"] > 0]
|
| 191 |
+
median_h = float(np.median(heights)) if heights else 12.0
|
| 192 |
+
tol = max(median_h * 0.6, 6.0)
|
| 193 |
+
|
| 194 |
+
# Sort primarily by y so we can sweep rows top-to-bottom.
|
| 195 |
+
items.sort(key=lambda it: (it["_y"], it["_x"]))
|
| 196 |
+
|
| 197 |
+
rows = []
|
| 198 |
+
current = [items[0]]
|
| 199 |
+
current_y = items[0]["_y"]
|
| 200 |
+
for it in items[1:]:
|
| 201 |
+
if abs(it["_y"] - current_y) <= tol:
|
| 202 |
+
current.append(it)
|
| 203 |
+
else:
|
| 204 |
+
rows.append(current)
|
| 205 |
+
current = [it]
|
| 206 |
+
current_y = it["_y"]
|
| 207 |
+
rows.append(current)
|
| 208 |
+
|
| 209 |
+
ordered = []
|
| 210 |
+
for row in rows:
|
| 211 |
+
row.sort(key=lambda it: it["_x"])
|
| 212 |
+
for it in row:
|
| 213 |
+
ordered.append(
|
| 214 |
+
{
|
| 215 |
+
"text": it["text"],
|
| 216 |
+
"confidence": it["confidence"],
|
| 217 |
+
"box": it["box"],
|
| 218 |
+
}
|
| 219 |
+
)
|
| 220 |
+
return ordered
|
| 221 |
+
|
| 222 |
+
def ocr_text(
|
| 223 |
+
self, img_bgr: np.ndarray, *, lang: str, use_angle_cls: bool
|
| 224 |
+
) -> str:
|
| 225 |
+
"""Return all detected text joined in reading order by newlines."""
|
| 226 |
+
text, _ = self.ocr_text_conf(
|
| 227 |
+
img_bgr, lang=lang, use_angle_cls=use_angle_cls
|
| 228 |
+
)
|
| 229 |
+
return text
|
| 230 |
+
|
| 231 |
+
def ocr_text_conf(
|
| 232 |
+
self, img_bgr: np.ndarray, *, lang: str, use_angle_cls: bool
|
| 233 |
+
):
|
| 234 |
+
"""Return ``(text, mean_confidence)`` for the image.
|
| 235 |
+
|
| 236 |
+
``mean_confidence`` is the average per-line recognition confidence in
|
| 237 |
+
``[0, 1]`` (or ``None`` if nothing was detected). It lets the UI flag
|
| 238 |
+
pages the OCR engine itself is unsure about.
|
| 239 |
+
"""
|
| 240 |
+
lines = self.ocr_lines(
|
| 241 |
+
img_bgr, lang=lang, use_angle_cls=use_angle_cls
|
| 242 |
+
)
|
| 243 |
+
text = "\n".join(line["text"] for line in lines)
|
| 244 |
+
if not lines:
|
| 245 |
+
return text, None
|
| 246 |
+
conf = sum(line["confidence"] for line in lines) / len(lines)
|
| 247 |
+
return text, float(conf)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# Module-level singleton.
|
| 251 |
+
engine = OCREngine()
|
pipeline/online_ocr.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional ONLINE OCR via Google Gemini (handwriting + printed transcription).
|
| 2 |
+
|
| 3 |
+
This is an OPT-IN, accuracy-first fallback for pages the local engines
|
| 4 |
+
(PaddleOCR / TrOCR) read poorly. It sends a rendered PAGE IMAGE to Google's
|
| 5 |
+
Gemini API and returns a verbatim transcription.
|
| 6 |
+
|
| 7 |
+
PRIVACY: enabling this uploads page images to Google's servers — the bytes
|
| 8 |
+
leave this machine. It is OFF by default and only runs when the caller passes
|
| 9 |
+
a Gemini API key. Do not enable it for confidential documents you cannot send
|
| 10 |
+
to a third party.
|
| 11 |
+
|
| 12 |
+
SETUP: get a free API key from https://aistudio.google.com/ (Google AI Studio)
|
| 13 |
+
and pass it as ``api_key`` (or wire it to a ``GEMINI_API_KEY`` env var in the
|
| 14 |
+
caller). No extra pip dependency is needed: this module talks to the REST API
|
| 15 |
+
with the Python standard library only (``urllib.request`` + ``json`` +
|
| 16 |
+
``base64``). ``cv2`` is used to PNG-encode the OpenCV image.
|
| 17 |
+
|
| 18 |
+
Importing this module performs NO network call and has no import-time side
|
| 19 |
+
effects.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import base64
|
| 23 |
+
import json
|
| 24 |
+
import urllib.error
|
| 25 |
+
import urllib.request
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Verified Gemini REST contract.
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Generation endpoint (non-streaming). The canonical reference path is
|
| 34 |
+
# /v1beta/{model=models/*}:generateContent; the model name on the wire is
|
| 35 |
+
# "models/<id>". {model} below is the BARE model id (e.g. "gemini-2.5-pro").
|
| 36 |
+
_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 37 |
+
_GENERATE_URL = _API_BASE + "/{model}:generateContent"
|
| 38 |
+
|
| 39 |
+
# Auth header preferred over the legacy ?key= query param (keeps the key out of
|
| 40 |
+
# URLs/logs). Header name is literally "x-goog-api-key".
|
| 41 |
+
_API_KEY_HEADER = "x-goog-api-key"
|
| 42 |
+
|
| 43 |
+
# Strong handwriting-capable models, FREE-TIER-FRIENDLY first. As of 2026 the
|
| 44 |
+
# Pro models are NOT available on the API free tier (free-tier limit 0), so the
|
| 45 |
+
# default auto-pick must be a Flash model — Flash is already excellent at
|
| 46 |
+
# handwriting (benchmarked ~95% char-acc on a real cursive page). Users with
|
| 47 |
+
# paid billing can still select a Pro model from the dropdown.
|
| 48 |
+
SUPPORTED_MODELS = (
|
| 49 |
+
"gemini-2.5-flash", # free-tier workhorse; strong handwriting
|
| 50 |
+
"gemini-3.5-flash", # newer flash if the key exposes it
|
| 51 |
+
"gemini-2.5-flash-lite", # free-tier, lighter/faster, slightly weaker
|
| 52 |
+
"gemini-3.1-flash-lite",
|
| 53 |
+
"gemini-2.5-pro", # better accuracy but needs PAID billing (not free tier)
|
| 54 |
+
"gemini-3.1-pro-preview",
|
| 55 |
+
)
|
| 56 |
+
DEFAULT_MODEL = SUPPORTED_MODELS[0]
|
| 57 |
+
|
| 58 |
+
# Verbatim-transcription instruction. The online path runs for ANY page that
|
| 59 |
+
# needs OCR (handwritten OR printed), so this must NOT exclude printed text —
|
| 60 |
+
# it covers both. Still ignores pre-printed page furniture (Date/Page stamps,
|
| 61 |
+
# margin rules) which would otherwise leak into the output.
|
| 62 |
+
DEFAULT_PROMPT = (
|
| 63 |
+
"Transcribe all text in this image verbatim — both printed and handwritten. "
|
| 64 |
+
"Preserve line breaks and spelling exactly; do not correct, summarize, or "
|
| 65 |
+
"add commentary. Ignore pre-printed page furniture such as 'Date'/'Page' "
|
| 66 |
+
"header labels, margin rules and ruled lines. Output only the transcription."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Inline image payload cap. The TOTAL request (image + prompt, base64-expanded)
|
| 70 |
+
# must stay under 20 MB; above that the Files API is required. We guard on the
|
| 71 |
+
# base64 string length and surface an actionable error well before the wire.
|
| 72 |
+
_MAX_INLINE_B64_BYTES = 20 * 1024 * 1024
|
| 73 |
+
|
| 74 |
+
# Cap the longest image side sent to Gemini. Gemini internally tiles images
|
| 75 |
+
# (~768 px tiles), so a huge 300-DPI render only inflates the upload with no
|
| 76 |
+
# accuracy gain — and a multi-MB PNG over a slow connection causes "write
|
| 77 |
+
# operation timed out" errors. Downscaling + JPEG keeps requests small and fast.
|
| 78 |
+
_MAX_IMAGE_SIDE = 2048
|
| 79 |
+
_JPEG_QUALITY = 90
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def is_configured(api_key) -> bool:
|
| 83 |
+
"""True if a usable (non-empty) Gemini API key has been supplied.
|
| 84 |
+
|
| 85 |
+
Does NOT validate the key against the network — it only checks presence,
|
| 86 |
+
so calling it is free and side-effect-free.
|
| 87 |
+
"""
|
| 88 |
+
return bool(api_key and str(api_key).strip())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _clean_key(api_key) -> str:
|
| 92 |
+
"""Return the trimmed key, or raise a clean, KEY-FREE RuntimeError.
|
| 93 |
+
|
| 94 |
+
A key with an interior newline (pasted wrapped across two lines) or a
|
| 95 |
+
non-ASCII/control character would otherwise make http.client raise a raw
|
| 96 |
+
ValueError — whose message INCLUDES the full key value — or a
|
| 97 |
+
UnicodeEncodeError, neither of which subclasses HTTPError/URLError, so they
|
| 98 |
+
escape the documented "only ever raise a single-line RuntimeError" contract
|
| 99 |
+
and (for the ValueError case) echo the key into the per-page error text that
|
| 100 |
+
jobs.py broadcasts/stores. Validate up front and never put the key in the
|
| 101 |
+
message.
|
| 102 |
+
"""
|
| 103 |
+
key = str(api_key).strip()
|
| 104 |
+
if not key.isascii() or any(ord(c) < 0x20 or ord(c) == 0x7F for c in key):
|
| 105 |
+
raise RuntimeError(
|
| 106 |
+
"The Gemini API key contains invalid characters (control or "
|
| 107 |
+
"non-ASCII). Re-copy it from https://aistudio.google.com/."
|
| 108 |
+
)
|
| 109 |
+
return key
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _resolve_model(model) -> str:
|
| 113 |
+
"""Return a clean bare model id, defaulting when unset.
|
| 114 |
+
|
| 115 |
+
Accepts either a bare id ("gemini-2.5-pro") or the "models/<id>" form and
|
| 116 |
+
normalizes to the bare id used in the URL placeholder.
|
| 117 |
+
"""
|
| 118 |
+
name = (model or DEFAULT_MODEL).strip()
|
| 119 |
+
if name.startswith("models/"):
|
| 120 |
+
name = name[len("models/"):]
|
| 121 |
+
return name or DEFAULT_MODEL
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _encode_image_b64(img_bgr: np.ndarray):
|
| 125 |
+
"""Downscale a huge image, JPEG-encode it, return ``(base64_str, mime_type)``.
|
| 126 |
+
|
| 127 |
+
Caps the longest side at ``_MAX_IMAGE_SIDE`` and uses JPEG (much smaller than
|
| 128 |
+
PNG for scans/photos) so the request uploads quickly even on slow links.
|
| 129 |
+
"""
|
| 130 |
+
if img_bgr is None or getattr(img_bgr, "size", 0) == 0:
|
| 131 |
+
raise RuntimeError(
|
| 132 |
+
"Online OCR received an empty image; nothing to transcribe."
|
| 133 |
+
)
|
| 134 |
+
h, w = img_bgr.shape[:2]
|
| 135 |
+
longest = max(h, w)
|
| 136 |
+
if longest > _MAX_IMAGE_SIDE:
|
| 137 |
+
scale = _MAX_IMAGE_SIDE / float(longest)
|
| 138 |
+
img_bgr = cv2.resize(
|
| 139 |
+
img_bgr,
|
| 140 |
+
(max(1, int(round(w * scale))), max(1, int(round(h * scale)))),
|
| 141 |
+
interpolation=cv2.INTER_AREA,
|
| 142 |
+
)
|
| 143 |
+
ok, buf = cv2.imencode(
|
| 144 |
+
".jpg", img_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), _JPEG_QUALITY]
|
| 145 |
+
)
|
| 146 |
+
if not ok:
|
| 147 |
+
raise RuntimeError(
|
| 148 |
+
"Online OCR could not encode the page image (cv2.imencode failed). "
|
| 149 |
+
"Check that the image is a valid HxWx3 uint8 array."
|
| 150 |
+
)
|
| 151 |
+
# Standard base64 of the raw JPEG file bytes — NOT a data: URI.
|
| 152 |
+
return base64.standard_b64encode(buf.tobytes()).decode("ascii"), "image/jpeg"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _build_request_body(image_b64: str, mime_type: str, prompt: str) -> dict:
|
| 156 |
+
"""Assemble the generateContent request body per the verified contract."""
|
| 157 |
+
return {
|
| 158 |
+
"contents": [
|
| 159 |
+
{
|
| 160 |
+
"parts": [
|
| 161 |
+
{
|
| 162 |
+
"inline_data": {
|
| 163 |
+
"mime_type": mime_type,
|
| 164 |
+
"data": image_b64,
|
| 165 |
+
}
|
| 166 |
+
},
|
| 167 |
+
{"text": prompt},
|
| 168 |
+
]
|
| 169 |
+
}
|
| 170 |
+
],
|
| 171 |
+
"generationConfig": {
|
| 172 |
+
"temperature": 0,
|
| 173 |
+
"responseMimeType": "text/plain",
|
| 174 |
+
},
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _extract_text(payload: dict) -> str:
|
| 179 |
+
"""Pull the transcription out of a generateContent response.
|
| 180 |
+
|
| 181 |
+
Concatenates the ``text`` of every part in ``candidates[0].content.parts``
|
| 182 |
+
(text output can be split across parts). Raises an actionable RuntimeError
|
| 183 |
+
when the response carries no usable text — e.g. a prompt-level block
|
| 184 |
+
(``promptFeedback.blockReason``) or a non-STOP ``finishReason`` such as
|
| 185 |
+
SAFETY / MAX_TOKENS / RECITATION.
|
| 186 |
+
"""
|
| 187 |
+
# Prompt blocked before any candidate was produced.
|
| 188 |
+
feedback = payload.get("promptFeedback") or {}
|
| 189 |
+
block_reason = feedback.get("blockReason")
|
| 190 |
+
candidates = payload.get("candidates") or []
|
| 191 |
+
if not candidates:
|
| 192 |
+
if block_reason:
|
| 193 |
+
raise RuntimeError(
|
| 194 |
+
"Gemini blocked the request (promptFeedback.blockReason="
|
| 195 |
+
"{}). The page image or prompt tripped a safety filter; "
|
| 196 |
+
"online OCR is unavailable for this page.".format(block_reason)
|
| 197 |
+
)
|
| 198 |
+
raise RuntimeError(
|
| 199 |
+
"Gemini returned no candidates and no text. The response was "
|
| 200 |
+
"empty; try again or switch models."
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
candidate = candidates[0] or {}
|
| 204 |
+
finish_reason = candidate.get("finishReason")
|
| 205 |
+
content = candidate.get("content") or {}
|
| 206 |
+
parts = content.get("parts") or []
|
| 207 |
+
# ``part.get("text", "")`` only defaults when the key is ABSENT; an explicit
|
| 208 |
+
# ``{"text": null}`` would yield None and break the join. Coerce defensively.
|
| 209 |
+
text = "".join(
|
| 210 |
+
str(part.get("text") or "") for part in parts if isinstance(part, dict)
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if not text.strip():
|
| 214 |
+
if finish_reason and finish_reason != "STOP":
|
| 215 |
+
raise RuntimeError(
|
| 216 |
+
"Gemini produced no text (finishReason={}). This usually "
|
| 217 |
+
"means the output was cut off (MAX_TOKENS) or filtered "
|
| 218 |
+
"(SAFETY/RECITATION); try a different page or model.".format(
|
| 219 |
+
finish_reason
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
raise RuntimeError(
|
| 223 |
+
"Gemini returned an empty transcription for this page."
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# On a clean STOP we return the text as-is; if truncated but non-empty we
|
| 227 |
+
# still return what we have (a partial transcription beats failing hard).
|
| 228 |
+
return text
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _friendly_http_error(err: urllib.error.HTTPError) -> RuntimeError:
|
| 232 |
+
"""Translate an HTTPError into an actionable RuntimeError.
|
| 233 |
+
|
| 234 |
+
Reads the JSON ``error`` body (code/message/status) to surface the API's
|
| 235 |
+
own message and maps the common status codes to plain guidance.
|
| 236 |
+
"""
|
| 237 |
+
code = err.code
|
| 238 |
+
api_message = ""
|
| 239 |
+
api_status = ""
|
| 240 |
+
try:
|
| 241 |
+
raw = err.read()
|
| 242 |
+
if raw:
|
| 243 |
+
body = json.loads(raw.decode("utf-8", "replace"))
|
| 244 |
+
error_obj = body.get("error") or {}
|
| 245 |
+
api_message = str(error_obj.get("message") or "").strip()
|
| 246 |
+
api_status = str(error_obj.get("status") or "").strip()
|
| 247 |
+
except Exception:
|
| 248 |
+
# Body wasn't JSON or couldn't be read; fall back to the status code.
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
detail = api_message or err.reason or "no detail provided"
|
| 252 |
+
|
| 253 |
+
if code in (401, 403):
|
| 254 |
+
return RuntimeError(
|
| 255 |
+
"Gemini rejected the API key (HTTP {} {}): {}. Check that "
|
| 256 |
+
"GEMINI_API_KEY is a valid, enabled key from "
|
| 257 |
+
"https://aistudio.google.com/ and has access to this model."
|
| 258 |
+
.format(code, api_status or "PERMISSION_DENIED", detail)
|
| 259 |
+
)
|
| 260 |
+
if code == 429:
|
| 261 |
+
return RuntimeError(
|
| 262 |
+
"Gemini rate limit / quota exceeded (HTTP 429 {}): {}. Slow down "
|
| 263 |
+
"requests, wait and retry, switch to a lighter model, or request "
|
| 264 |
+
"a quota increase in Google AI Studio."
|
| 265 |
+
.format(api_status or "RESOURCE_EXHAUSTED", detail)
|
| 266 |
+
)
|
| 267 |
+
if code == 400:
|
| 268 |
+
return RuntimeError(
|
| 269 |
+
"Gemini rejected the request (HTTP 400 {}): {}. This is usually a "
|
| 270 |
+
"malformed body, an unsupported model id, or (FAILED_PRECONDITION) "
|
| 271 |
+
"billing not enabled for your region."
|
| 272 |
+
.format(api_status or "INVALID_ARGUMENT", detail)
|
| 273 |
+
)
|
| 274 |
+
if code == 404:
|
| 275 |
+
return RuntimeError(
|
| 276 |
+
"Gemini model or resource not found (HTTP 404 {}): {}. Check the "
|
| 277 |
+
"model id ({}).".format(
|
| 278 |
+
api_status or "NOT_FOUND", detail, ", ".join(SUPPORTED_MODELS)
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
if code in (500, 503, 504):
|
| 282 |
+
return RuntimeError(
|
| 283 |
+
"Gemini server error (HTTP {} {}): {}. The service is overloaded "
|
| 284 |
+
"or timed out; retry in a moment or switch to another model."
|
| 285 |
+
.format(code, api_status or "UNAVAILABLE", detail)
|
| 286 |
+
)
|
| 287 |
+
return RuntimeError(
|
| 288 |
+
"Gemini request failed (HTTP {} {}): {}.".format(
|
| 289 |
+
code, api_status or "ERROR", detail
|
| 290 |
+
)
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def list_models(api_key, *, timeout=30) -> list:
|
| 295 |
+
"""Validate the key and return available bare model ids (no network on import).
|
| 296 |
+
|
| 297 |
+
Calls ``GET /v1beta/models`` and returns the bare ids (``models/`` prefix
|
| 298 |
+
stripped) of models that support ``generateContent``. Doubles as a cheap
|
| 299 |
+
key-validation call for the UI. Raises an actionable RuntimeError on a bad
|
| 300 |
+
key / network failure, mirroring ``transcribe_image_bgr``.
|
| 301 |
+
"""
|
| 302 |
+
if not is_configured(api_key):
|
| 303 |
+
raise RuntimeError(
|
| 304 |
+
"Online OCR needs a Gemini API key but none was provided. Get a "
|
| 305 |
+
"free key from https://aistudio.google.com/."
|
| 306 |
+
)
|
| 307 |
+
api_key = _clean_key(api_key)
|
| 308 |
+
request = urllib.request.Request(
|
| 309 |
+
_API_BASE, # GET https://generativelanguage.googleapis.com/v1beta/models
|
| 310 |
+
method="GET",
|
| 311 |
+
headers={_API_KEY_HEADER: api_key},
|
| 312 |
+
)
|
| 313 |
+
try:
|
| 314 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 315 |
+
raw = response.read()
|
| 316 |
+
except urllib.error.HTTPError as err:
|
| 317 |
+
raise _friendly_http_error(err) from None
|
| 318 |
+
except urllib.error.URLError as err:
|
| 319 |
+
raise RuntimeError(
|
| 320 |
+
"Could not reach the Gemini API ({}). Check your internet "
|
| 321 |
+
"connection.".format(err.reason)
|
| 322 |
+
) from None
|
| 323 |
+
except TimeoutError:
|
| 324 |
+
raise RuntimeError(
|
| 325 |
+
"The Gemini request timed out after {}s.".format(timeout)
|
| 326 |
+
) from None
|
| 327 |
+
try:
|
| 328 |
+
payload = json.loads(raw.decode("utf-8", "replace"))
|
| 329 |
+
except (ValueError, AttributeError) as err:
|
| 330 |
+
raise RuntimeError(
|
| 331 |
+
"Gemini returned a non-JSON model list ({}).".format(err)
|
| 332 |
+
) from None
|
| 333 |
+
if not isinstance(payload, dict):
|
| 334 |
+
raise RuntimeError(
|
| 335 |
+
"Gemini returned an unexpected model-list response (not a JSON object)."
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
out = []
|
| 339 |
+
for entry in payload.get("models") or []:
|
| 340 |
+
if not isinstance(entry, dict):
|
| 341 |
+
continue
|
| 342 |
+
methods = entry.get("supportedGenerationMethods") or []
|
| 343 |
+
if methods and "generateContent" not in methods:
|
| 344 |
+
continue
|
| 345 |
+
name = str(entry.get("name") or "")
|
| 346 |
+
if name.startswith("models/"):
|
| 347 |
+
name = name[len("models/"):]
|
| 348 |
+
if name:
|
| 349 |
+
out.append(name)
|
| 350 |
+
return out
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def best_available_model(available) -> str:
|
| 354 |
+
"""Pick the strongest handwriting model we support that the key can access.
|
| 355 |
+
|
| 356 |
+
Falls back to :data:`DEFAULT_MODEL` if none of ``available`` is recognized.
|
| 357 |
+
"""
|
| 358 |
+
avail = set(available or ())
|
| 359 |
+
for m in SUPPORTED_MODELS:
|
| 360 |
+
if m in avail:
|
| 361 |
+
return m
|
| 362 |
+
return DEFAULT_MODEL
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def transcribe_image_bgr(
|
| 366 |
+
img_bgr: np.ndarray,
|
| 367 |
+
*,
|
| 368 |
+
api_key,
|
| 369 |
+
model=None,
|
| 370 |
+
prompt=None,
|
| 371 |
+
timeout=120,
|
| 372 |
+
) -> str:
|
| 373 |
+
"""Transcribe an OpenCV BGR page image with Gemini and return the text.
|
| 374 |
+
|
| 375 |
+
Parameters
|
| 376 |
+
----------
|
| 377 |
+
img_bgr : np.ndarray
|
| 378 |
+
An OpenCV ``HxWx3`` BGR ``uint8`` image (a rendered PDF page).
|
| 379 |
+
api_key : str
|
| 380 |
+
A Gemini API key from https://aistudio.google.com/. Required.
|
| 381 |
+
model : str, optional
|
| 382 |
+
A model id from :data:`SUPPORTED_MODELS` (bare id or "models/<id>").
|
| 383 |
+
Defaults to :data:`DEFAULT_MODEL`.
|
| 384 |
+
prompt : str, optional
|
| 385 |
+
Override the verbatim-transcription instruction
|
| 386 |
+
(:data:`DEFAULT_PROMPT`).
|
| 387 |
+
timeout : float, optional
|
| 388 |
+
Per-request socket timeout in seconds (default 120).
|
| 389 |
+
|
| 390 |
+
Returns
|
| 391 |
+
-------
|
| 392 |
+
str
|
| 393 |
+
The verbatim transcription (line breaks preserved).
|
| 394 |
+
|
| 395 |
+
Raises
|
| 396 |
+
------
|
| 397 |
+
RuntimeError
|
| 398 |
+
With an actionable, single-line message (never a raw stack trace) for
|
| 399 |
+
a missing/empty key, an invalid key, a rate limit, a network/timeout
|
| 400 |
+
failure, or an empty/safety-blocked response.
|
| 401 |
+
"""
|
| 402 |
+
if not is_configured(api_key):
|
| 403 |
+
raise RuntimeError(
|
| 404 |
+
"Online OCR needs a Gemini API key but none was provided. Get a "
|
| 405 |
+
"free key from https://aistudio.google.com/ and pass it as the "
|
| 406 |
+
"api_key argument (or set GEMINI_API_KEY)."
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
api_key = _clean_key(api_key)
|
| 410 |
+
model_id = _resolve_model(model)
|
| 411 |
+
prompt_text = prompt if (prompt and prompt.strip()) else DEFAULT_PROMPT
|
| 412 |
+
|
| 413 |
+
image_b64, mime_type = _encode_image_b64(img_bgr)
|
| 414 |
+
if len(image_b64) > _MAX_INLINE_B64_BYTES:
|
| 415 |
+
raise RuntimeError(
|
| 416 |
+
"Page image is too large for an inline Gemini request "
|
| 417 |
+
"(~{:.1f} MB base64; the 20 MB inline limit applies to the whole "
|
| 418 |
+
"request). Render the page at a lower DPI, or use Gemini's Files "
|
| 419 |
+
"API for payloads this big.".format(
|
| 420 |
+
len(image_b64) / (1024 * 1024)
|
| 421 |
+
)
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
body = _build_request_body(image_b64, mime_type, prompt_text)
|
| 425 |
+
data = json.dumps(body).encode("utf-8")
|
| 426 |
+
url = _GENERATE_URL.format(model=model_id)
|
| 427 |
+
|
| 428 |
+
request = urllib.request.Request(
|
| 429 |
+
url,
|
| 430 |
+
data=data,
|
| 431 |
+
method="POST",
|
| 432 |
+
headers={
|
| 433 |
+
"Content-Type": "application/json",
|
| 434 |
+
_API_KEY_HEADER: api_key, # already cleaned/validated above
|
| 435 |
+
},
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
try:
|
| 439 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 440 |
+
raw = response.read()
|
| 441 |
+
except urllib.error.HTTPError as err:
|
| 442 |
+
# Non-2xx: surface the API's own error message via a friendly mapping.
|
| 443 |
+
raise _friendly_http_error(err) from None
|
| 444 |
+
except urllib.error.URLError as err:
|
| 445 |
+
# DNS failure, connection refused, TLS error, or a socket timeout
|
| 446 |
+
# (which arrives wrapped as URLError(reason=timeout)).
|
| 447 |
+
raise RuntimeError(
|
| 448 |
+
"Could not reach the Gemini API ({}). Check your internet "
|
| 449 |
+
"connection and that generativelanguage.googleapis.com is "
|
| 450 |
+
"reachable; the request may also have timed out (timeout={}s)."
|
| 451 |
+
.format(err.reason, timeout)
|
| 452 |
+
) from None
|
| 453 |
+
except TimeoutError as err: # bare socket timeout on some Python versions
|
| 454 |
+
raise RuntimeError(
|
| 455 |
+
"The Gemini request timed out after {}s. Try a smaller image, a "
|
| 456 |
+
"lighter model, or a longer timeout.".format(timeout)
|
| 457 |
+
) from None
|
| 458 |
+
|
| 459 |
+
try:
|
| 460 |
+
payload = json.loads(raw.decode("utf-8", "replace"))
|
| 461 |
+
except (ValueError, AttributeError) as err:
|
| 462 |
+
raise RuntimeError(
|
| 463 |
+
"Gemini returned a response that was not valid JSON ({}). The "
|
| 464 |
+
"service may be having problems; retry shortly.".format(err)
|
| 465 |
+
) from None
|
| 466 |
+
if not isinstance(payload, dict):
|
| 467 |
+
raise RuntimeError(
|
| 468 |
+
"Gemini returned an unexpected response (not a JSON object); "
|
| 469 |
+
"retry shortly."
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
return _extract_text(payload)
|
pipeline/postprocess.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-page cleanup: strip running headers / footers and page numbers.
|
| 2 |
+
|
| 3 |
+
Operates on the FULL list of per-page texts after extraction. A line is treated
|
| 4 |
+
as a running header/footer only if a normalized version of it recurs in the top
|
| 5 |
+
OR bottom band of a strong majority of pages — and it is removed ONLY from that
|
| 6 |
+
band, never from a page body.
|
| 7 |
+
|
| 8 |
+
Safety properties (each guards a real failure mode found in review):
|
| 9 |
+
* The top and bottom bands are DISJOINT and capped at half the page's
|
| 10 |
+
non-blank lines, so on short/sparse pages a body line can never be counted
|
| 11 |
+
as both header and footer (which previously emptied invoice/form pages).
|
| 12 |
+
* Digit normalization (so "Page 1"/"Page 2"/bare "12" match) is applied ONLY
|
| 13 |
+
to page-number-like lines; every other line is compared verbatim, so a
|
| 14 |
+
numbered heading ("Section 3 …") or a numeric data row ("Total: 1,234.56")
|
| 15 |
+
is never collapsed and stripped.
|
| 16 |
+
* A line must recur on a strong majority of pages (default 70%), so a real
|
| 17 |
+
heading that merely repeats on a couple of pages of a short doc is kept.
|
| 18 |
+
* A page is never reduced to nothing: if every candidate line would be
|
| 19 |
+
stripped, the page is left untouched.
|
| 20 |
+
* No-ops entirely on documents with fewer than ``min_pages`` pages.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
import math
|
| 24 |
+
import re
|
| 25 |
+
from collections import Counter
|
| 26 |
+
|
| 27 |
+
_DIGITS = re.compile(r"\d+")
|
| 28 |
+
_WS = re.compile(r"\s+")
|
| 29 |
+
# Words that commonly accompany a page number ("Page 1 of 10", "pg. 3").
|
| 30 |
+
_PAGEWORDS = re.compile(r"\b(?:page|pg|p|of|no)\b")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _key(line: str) -> str:
|
| 34 |
+
"""Normalized match key.
|
| 35 |
+
|
| 36 |
+
Page-number-like lines (only page-words + digits + punctuation remain) get
|
| 37 |
+
their digits mapped to ``#`` so "Page 1"/"Page 2"/bare "12" all match. Every
|
| 38 |
+
other line is compared verbatim — so numbered headings and numeric data rows
|
| 39 |
+
are NOT collapsed together and erased.
|
| 40 |
+
|
| 41 |
+
The digit→``#`` collapse is gated to lines that are *plausibly a page
|
| 42 |
+
number*: at most ONE digit group, OR an explicit page-word ("Page", "of",
|
| 43 |
+
…). A bare MULTI-group value — an ISO date ``2026-06-01`` (``#-#-#``), a
|
| 44 |
+
currency amount ``1,234.56`` (``#,#.#``), a numeric code — is genuinely
|
| 45 |
+
distinct per page; collapsing its skeleton would let the recurrence test
|
| 46 |
+
delete real data from every page (e.g. a per-page date header or an invoice
|
| 47 |
+
amount footer). Those are compared verbatim so they are never stripped.
|
| 48 |
+
"""
|
| 49 |
+
base = _WS.sub(" ", line.strip().lower()).strip()
|
| 50 |
+
probe = re.sub(r"[^\w\s]", "", _PAGEWORDS.sub(" ", base))
|
| 51 |
+
if _DIGITS.sub("", probe).strip() == "": # nothing but page-words + digits
|
| 52 |
+
if len(_DIGITS.findall(base)) <= 1 or _PAGEWORDS.search(base):
|
| 53 |
+
return _WS.sub(" ", _DIGITS.sub("#", base)).strip()
|
| 54 |
+
return base
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def realign_line_confidences(cleaned_text, orig_lines):
|
| 58 |
+
"""Map a stripped page's surviving lines back to their confidences.
|
| 59 |
+
|
| 60 |
+
Stripping only REMOVES whole lines (it never edits or reorders the survivors),
|
| 61 |
+
so a two-pointer walk over the original per-line list recovers each surviving
|
| 62 |
+
line's confidence. Returns a new ``[{text, confidence}]`` list aligned to
|
| 63 |
+
``cleaned_text`` (lines that can't be matched get ``confidence=None``), or
|
| 64 |
+
``None`` if there were no original lines.
|
| 65 |
+
"""
|
| 66 |
+
if not orig_lines:
|
| 67 |
+
return None
|
| 68 |
+
cleaned = (cleaned_text or "").split("\n")
|
| 69 |
+
out, oi = [], 0
|
| 70 |
+
for cl in cleaned:
|
| 71 |
+
while oi < len(orig_lines) and (orig_lines[oi].get("text", "") != cl):
|
| 72 |
+
oi += 1
|
| 73 |
+
if oi < len(orig_lines):
|
| 74 |
+
out.append(orig_lines[oi])
|
| 75 |
+
oi += 1
|
| 76 |
+
else:
|
| 77 |
+
out.append({"text": cl, "confidence": None})
|
| 78 |
+
return out
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _bands(nonblank, band):
|
| 82 |
+
"""Disjoint (top, bottom) index lists, capped at half the non-blank lines.
|
| 83 |
+
|
| 84 |
+
Capping at ``len // 2`` guarantees the two bands never share an index, so
|
| 85 |
+
the page interior is never a header/footer candidate.
|
| 86 |
+
"""
|
| 87 |
+
b = min(band, len(nonblank) // 2)
|
| 88 |
+
if b <= 0:
|
| 89 |
+
return [], []
|
| 90 |
+
return nonblank[:b], nonblank[len(nonblank) - b:]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def strip_running_headers_footers(
|
| 94 |
+
page_texts,
|
| 95 |
+
*,
|
| 96 |
+
band: int = 3,
|
| 97 |
+
min_frac: float = 0.7,
|
| 98 |
+
min_pages: int = 3,
|
| 99 |
+
return_kept: bool = False,
|
| 100 |
+
):
|
| 101 |
+
"""Return cleaned copies of ``page_texts`` with running headers/footers gone.
|
| 102 |
+
|
| 103 |
+
Parameters
|
| 104 |
+
----------
|
| 105 |
+
page_texts : list[str]
|
| 106 |
+
One extracted-text string per page, in page order.
|
| 107 |
+
band : int
|
| 108 |
+
Max non-blank lines at the top and bottom of each page to consider as
|
| 109 |
+
header/footer candidates (capped per page so top/bottom never overlap).
|
| 110 |
+
min_frac : float
|
| 111 |
+
A line must recur in its band on at least ``ceil(min_frac * n_pages)``
|
| 112 |
+
pages (min 2) to count as a running header/footer.
|
| 113 |
+
min_pages : int
|
| 114 |
+
Documents shorter than this are returned unchanged.
|
| 115 |
+
return_kept : bool
|
| 116 |
+
If True, return ``(cleaned_texts, kept_indices)`` where
|
| 117 |
+
``kept_indices[p]`` is the list of ORIGINAL line indices (into
|
| 118 |
+
``page_texts[p].split("\\n")``) that survived on page ``p``. This lets a
|
| 119 |
+
caller realign per-line metadata (OCR confidences) to the EXACT
|
| 120 |
+
surviving line — re-deriving the mapping by text equality misbinds
|
| 121 |
+
identical-text duplicate lines. Default False keeps the legacy
|
| 122 |
+
list-of-strings return.
|
| 123 |
+
"""
|
| 124 |
+
n = len(page_texts)
|
| 125 |
+
|
| 126 |
+
def _all_indices():
|
| 127 |
+
return [list(range(len((t or "").split("\n")))) for t in page_texts]
|
| 128 |
+
|
| 129 |
+
if n < min_pages:
|
| 130 |
+
cleaned = list(page_texts)
|
| 131 |
+
return (cleaned, _all_indices()) if return_kept else cleaned
|
| 132 |
+
|
| 133 |
+
pages_lines = []
|
| 134 |
+
for txt in page_texts:
|
| 135 |
+
lines = (txt or "").split("\n")
|
| 136 |
+
nonblank = [i for i, ln in enumerate(lines) if ln.strip()]
|
| 137 |
+
pages_lines.append((lines, nonblank))
|
| 138 |
+
|
| 139 |
+
top_counter, bot_counter = Counter(), Counter()
|
| 140 |
+
for lines, nonblank in pages_lines:
|
| 141 |
+
top, bot = _bands(nonblank, band)
|
| 142 |
+
for k in {_key(lines[i]) for i in top}:
|
| 143 |
+
if k:
|
| 144 |
+
top_counter[k] += 1
|
| 145 |
+
for k in {_key(lines[i]) for i in bot}:
|
| 146 |
+
if k:
|
| 147 |
+
bot_counter[k] += 1
|
| 148 |
+
|
| 149 |
+
thresh = max(2, math.ceil(min_frac * n))
|
| 150 |
+
running_top = {k for k, c in top_counter.items() if c >= thresh}
|
| 151 |
+
running_bot = {k for k, c in bot_counter.items() if c >= thresh}
|
| 152 |
+
if not running_top and not running_bot:
|
| 153 |
+
cleaned = list(page_texts)
|
| 154 |
+
return (cleaned, _all_indices()) if return_kept else cleaned
|
| 155 |
+
|
| 156 |
+
out, kept_all = [], []
|
| 157 |
+
for lines, nonblank in pages_lines:
|
| 158 |
+
top, bot = _bands(nonblank, band)
|
| 159 |
+
strip_idx = set()
|
| 160 |
+
for i in top:
|
| 161 |
+
if _key(lines[i]) in running_top:
|
| 162 |
+
strip_idx.add(i)
|
| 163 |
+
for i in bot:
|
| 164 |
+
if _key(lines[i]) in running_bot:
|
| 165 |
+
strip_idx.add(i)
|
| 166 |
+
# Never reduce a page to nothing — if everything would go, keep it as-is.
|
| 167 |
+
if nonblank and set(nonblank).issubset(strip_idx):
|
| 168 |
+
out.append("\n".join(lines).strip("\n"))
|
| 169 |
+
kept_all.append(list(range(len(lines)))) # nothing actually removed
|
| 170 |
+
continue
|
| 171 |
+
kept_idx = [i for i in range(len(lines)) if i not in strip_idx]
|
| 172 |
+
kept = [lines[i] for i in kept_idx]
|
| 173 |
+
cleaned = re.sub(r"\n{3,}", "\n\n", "\n".join(kept).strip("\n"))
|
| 174 |
+
out.append(cleaned)
|
| 175 |
+
kept_all.append(kept_idx)
|
| 176 |
+
return (out, kept_all) if return_kept else out
|
pipeline/preprocess.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image preprocessing utilities for the OCR pipeline.
|
| 2 |
+
|
| 3 |
+
All functions operate on OpenCV-style ndarrays. ``preprocess_image`` always
|
| 4 |
+
returns a 3-channel BGR uint8 image so that PaddleOCR accepts it directly.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import cv2
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def estimate_skew_angle(gray: np.ndarray) -> float:
|
| 12 |
+
"""Estimate the page skew angle (degrees) of a 2D uint8 grayscale image.
|
| 13 |
+
|
| 14 |
+
Uses the minimum-area rectangle enclosing the foreground (dark) pixels and
|
| 15 |
+
the OpenCV >= 4.5 angle convention (0, 90]. Clamped to ``[-15, 15]``; returns
|
| 16 |
+
``0.0`` when there isn't enough foreground to estimate reliably.
|
| 17 |
+
"""
|
| 18 |
+
if gray is None or gray.ndim != 2:
|
| 19 |
+
return 0.0
|
| 20 |
+
if gray.dtype != np.uint8:
|
| 21 |
+
gray = gray.astype(np.uint8)
|
| 22 |
+
h, w = gray.shape[:2]
|
| 23 |
+
if h == 0 or w == 0:
|
| 24 |
+
return 0.0
|
| 25 |
+
|
| 26 |
+
# Otsu on the inverted image: dark glyph pixels become nonzero coordinates.
|
| 27 |
+
_, thresh = cv2.threshold(
|
| 28 |
+
gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
|
| 29 |
+
)
|
| 30 |
+
coords = cv2.findNonZero(thresh)
|
| 31 |
+
if coords is None or len(coords) < 10:
|
| 32 |
+
return 0.0
|
| 33 |
+
|
| 34 |
+
angle = cv2.minAreaRect(coords)[-1]
|
| 35 |
+
# OpenCV >= 4.5 returns the angle in (0, 90]; map to a small signed
|
| 36 |
+
# deviation from horizontal in (-45, 45].
|
| 37 |
+
if angle > 45:
|
| 38 |
+
angle -= 90.0
|
| 39 |
+
return float(max(-15.0, min(15.0, angle)))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _rotate(gray: np.ndarray, angle: float) -> np.ndarray:
|
| 43 |
+
"""Rotate a 2D grayscale image by ``angle`` degrees, white-filling borders."""
|
| 44 |
+
h, w = gray.shape[:2]
|
| 45 |
+
rot_mat = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 1.0)
|
| 46 |
+
return cv2.warpAffine(
|
| 47 |
+
gray, rot_mat, (w, h),
|
| 48 |
+
flags=cv2.INTER_CUBIC,
|
| 49 |
+
borderMode=cv2.BORDER_CONSTANT,
|
| 50 |
+
borderValue=255,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def deskew(gray: np.ndarray) -> np.ndarray:
|
| 55 |
+
"""Estimate and correct small page skew on a 2D uint8 grayscale image.
|
| 56 |
+
|
| 57 |
+
Only small angles are corrected (estimate clamped to ``[-15, 15]``) to avoid
|
| 58 |
+
catastrophic rotations on noisy inputs. Rotation fills exposed borders white.
|
| 59 |
+
"""
|
| 60 |
+
if gray is None or gray.ndim != 2:
|
| 61 |
+
raise ValueError("deskew expects a 2D grayscale uint8 array")
|
| 62 |
+
if gray.dtype != np.uint8:
|
| 63 |
+
gray = gray.astype(np.uint8)
|
| 64 |
+
if gray.shape[0] == 0 or gray.shape[1] == 0:
|
| 65 |
+
return gray
|
| 66 |
+
|
| 67 |
+
angle = estimate_skew_angle(gray)
|
| 68 |
+
if abs(angle) < 0.1:
|
| 69 |
+
return gray
|
| 70 |
+
return _rotate(gray, angle)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _sauvola_binarize(gray: np.ndarray) -> np.ndarray:
|
| 74 |
+
"""Binarize a grayscale image with Sauvola's local thresholding.
|
| 75 |
+
|
| 76 |
+
Sauvola adapts the threshold per-pixel using the local mean and standard
|
| 77 |
+
deviation, which handles uneven lighting and faint text far better than a
|
| 78 |
+
single global or simple adaptive-Gaussian threshold — the usual choice for
|
| 79 |
+
document OCR. Falls back to adaptive-Gaussian if scikit-image is missing.
|
| 80 |
+
"""
|
| 81 |
+
try:
|
| 82 |
+
from skimage.filters import threshold_sauvola
|
| 83 |
+
|
| 84 |
+
# window_size must be odd; 25 suits body text at ~300 DPI.
|
| 85 |
+
thresh = threshold_sauvola(gray, window_size=25, k=0.2)
|
| 86 |
+
binary = (gray > thresh).astype(np.uint8) * 255
|
| 87 |
+
return binary
|
| 88 |
+
except Exception:
|
| 89 |
+
return cv2.adaptiveThreshold(
|
| 90 |
+
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 91 |
+
cv2.THRESH_BINARY, blockSize=31, C=15,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def preprocess_image(
|
| 96 |
+
img_bgr: np.ndarray, *, mode: str, binarize: bool
|
| 97 |
+
) -> np.ndarray:
|
| 98 |
+
"""Preprocess a BGR image for OCR and return a 3-channel BGR uint8 image.
|
| 99 |
+
|
| 100 |
+
Steps (in order):
|
| 101 |
+
1. Convert to grayscale.
|
| 102 |
+
2. Deskew (small-angle correction with white border).
|
| 103 |
+
3. CLAHE contrast equalization (rescues faint / unevenly-lit scans).
|
| 104 |
+
4. Denoise via ``cv2.fastNlMeansDenoising`` ONLY when ``mode == "max"``.
|
| 105 |
+
5. Sauvola binarization ONLY when ``binarize`` is True.
|
| 106 |
+
6. Pad a white border so text touching the page edge is still detected.
|
| 107 |
+
|
| 108 |
+
The result is always converted back to 3-channel BGR so PaddleOCR accepts
|
| 109 |
+
it; this function never returns a 2D array.
|
| 110 |
+
|
| 111 |
+
Parameters
|
| 112 |
+
----------
|
| 113 |
+
img_bgr:
|
| 114 |
+
HxWx3 BGR uint8 image.
|
| 115 |
+
mode:
|
| 116 |
+
``"max"`` or ``"fast"``.
|
| 117 |
+
binarize:
|
| 118 |
+
Whether to apply Sauvola binarization.
|
| 119 |
+
"""
|
| 120 |
+
if img_bgr is None:
|
| 121 |
+
raise ValueError("preprocess_image received None")
|
| 122 |
+
|
| 123 |
+
if img_bgr.dtype != np.uint8:
|
| 124 |
+
img_bgr = img_bgr.astype(np.uint8)
|
| 125 |
+
|
| 126 |
+
# 1. Grayscale.
|
| 127 |
+
if img_bgr.ndim == 2:
|
| 128 |
+
gray = img_bgr
|
| 129 |
+
elif img_bgr.ndim == 3 and img_bgr.shape[2] == 3:
|
| 130 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
| 131 |
+
elif img_bgr.ndim == 3 and img_bgr.shape[2] == 4:
|
| 132 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGRA2GRAY)
|
| 133 |
+
else:
|
| 134 |
+
raise ValueError("preprocess_image expects a BGR(A) or grayscale image")
|
| 135 |
+
|
| 136 |
+
# 2. Deskew — estimate the angle once so we can both correct it and decide
|
| 137 |
+
# whether sharpening is safe below.
|
| 138 |
+
skew = estimate_skew_angle(gray)
|
| 139 |
+
if abs(skew) >= 0.1:
|
| 140 |
+
gray = _rotate(gray, skew)
|
| 141 |
+
|
| 142 |
+
# 3. CLAHE — local contrast equalization. Mild clip so clean renders are
|
| 143 |
+
# barely touched while faint/grey scans get a real lift.
|
| 144 |
+
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 145 |
+
gray = clahe.apply(gray)
|
| 146 |
+
|
| 147 |
+
# 4. Denoise (only in max mode — it is the slow step).
|
| 148 |
+
if mode == "max":
|
| 149 |
+
gray = cv2.fastNlMeansDenoising(gray, h=10)
|
| 150 |
+
|
| 151 |
+
# 4b. Unsharp-mask sharpening to crisp thin strokes — improves glyph
|
| 152 |
+
# separation (e.g. capital "I" vs lowercase "l") on soft scans, which
|
| 153 |
+
# measurably helped a real ID-card scan. ONLY when the page is near-flat:
|
| 154 |
+
# on a heavily-skewed page, deskew's rotation leaves interpolation blur
|
| 155 |
+
# that sharpening would amplify, hurting recognition. Benchmarked: gated
|
| 156 |
+
# this way it helps flat scans with no regression on rotated pages.
|
| 157 |
+
if abs(skew) < 4.0:
|
| 158 |
+
_blur = cv2.GaussianBlur(gray, (0, 0), 3)
|
| 159 |
+
gray = cv2.addWeighted(gray, 1.5, _blur, -0.5, 0)
|
| 160 |
+
|
| 161 |
+
# 5. Sauvola binarize (only when requested).
|
| 162 |
+
if binarize:
|
| 163 |
+
gray = _sauvola_binarize(gray)
|
| 164 |
+
|
| 165 |
+
# 6. White border padding so edge-touching text isn't clipped by detection.
|
| 166 |
+
gray = cv2.copyMakeBorder(
|
| 167 |
+
gray, 16, 16, 16, 16, cv2.BORDER_CONSTANT, value=255
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Always return 3-channel BGR uint8.
|
| 171 |
+
bgr = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
|
| 172 |
+
return bgr
|
pipeline/textlayer.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PyMuPDF text-layer extraction with reading-order reconstruction.
|
| 2 |
+
|
| 3 |
+
These helpers operate on ``fitz.Page`` objects. ``has_text_layer`` decides
|
| 4 |
+
whether the embedded text layer is rich enough to use directly, and
|
| 5 |
+
``extract_text_layer`` reconstructs paragraphs in natural reading order from
|
| 6 |
+
the page's structured ``"dict"`` output.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def has_text_layer(page, *, min_chars: int = 50) -> bool:
|
| 11 |
+
"""Return True if the page's text layer has enough alphanumeric content.
|
| 12 |
+
|
| 13 |
+
Counts alphanumeric characters in the stripped text layer and compares
|
| 14 |
+
against ``min_chars``. Whitespace and punctuation-only layers (common in
|
| 15 |
+
scanned PDFs with stray marks) are treated as having no usable text.
|
| 16 |
+
"""
|
| 17 |
+
try:
|
| 18 |
+
text = page.get_text("text") or ""
|
| 19 |
+
except Exception:
|
| 20 |
+
return False
|
| 21 |
+
|
| 22 |
+
alnum = sum(1 for ch in text if ch.isalnum())
|
| 23 |
+
return alnum >= min_chars
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def extract_text_layer(page) -> str:
|
| 27 |
+
"""Reconstruct page text in reading order from the structured dict.
|
| 28 |
+
|
| 29 |
+
Blocks are sorted by their bounding box ``(y0, x0)``. Within each block,
|
| 30 |
+
line order is preserved and the spans of a line are concatenated. Lines are
|
| 31 |
+
joined by ``"\n"`` and blocks are separated by ``"\n\n"`` to approximate
|
| 32 |
+
paragraph structure.
|
| 33 |
+
|
| 34 |
+
NOTE: the flat ``(y0, x0)`` sort assumes a SINGLE-column layout. On a
|
| 35 |
+
multi-column page, blocks at similar vertical positions in different columns
|
| 36 |
+
interleave, so the reconstructed text zig-zags between columns (every
|
| 37 |
+
character is captured, but the reading order is scrambled). Column-aware
|
| 38 |
+
reordering is not implemented yet.
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
data = page.get_text("dict")
|
| 42 |
+
except Exception:
|
| 43 |
+
return ""
|
| 44 |
+
|
| 45 |
+
blocks = data.get("blocks", []) if isinstance(data, dict) else []
|
| 46 |
+
|
| 47 |
+
text_blocks = []
|
| 48 |
+
for block in blocks:
|
| 49 |
+
# Only text blocks have "lines"; image blocks (type 1) are skipped.
|
| 50 |
+
if block.get("type", 0) != 0:
|
| 51 |
+
continue
|
| 52 |
+
lines = block.get("lines")
|
| 53 |
+
if not lines:
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
bbox = block.get("bbox", [0.0, 0.0, 0.0, 0.0])
|
| 57 |
+
y0 = float(bbox[1]) if len(bbox) > 1 else 0.0
|
| 58 |
+
x0 = float(bbox[0]) if len(bbox) > 0 else 0.0
|
| 59 |
+
text_blocks.append((y0, x0, lines))
|
| 60 |
+
|
| 61 |
+
# Sort blocks top-to-bottom, then left-to-right.
|
| 62 |
+
text_blocks.sort(key=lambda b: (b[0], b[1]))
|
| 63 |
+
|
| 64 |
+
block_texts = []
|
| 65 |
+
for _y0, _x0, lines in text_blocks:
|
| 66 |
+
line_texts = []
|
| 67 |
+
for line in lines:
|
| 68 |
+
spans = line.get("spans", [])
|
| 69 |
+
line_str = "".join(span.get("text", "") for span in spans)
|
| 70 |
+
line_texts.append(line_str)
|
| 71 |
+
block_str = "\n".join(line_texts).strip("\n")
|
| 72 |
+
if block_str.strip():
|
| 73 |
+
block_texts.append(block_str)
|
| 74 |
+
|
| 75 |
+
return "\n\n".join(block_texts)
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paddleocr==2.7.3
|
| 2 |
+
paddlepaddle==2.6.2
|
| 3 |
+
pymupdf==1.24.9
|
| 4 |
+
opencv-python-headless==4.10.0.84
|
| 5 |
+
numpy==1.26.4
|
| 6 |
+
scikit-image==0.25.2
|
| 7 |
+
pillow==10.4.0
|
| 8 |
+
fastapi==0.111.1
|
| 9 |
+
uvicorn==0.30.3
|
| 10 |
+
python-multipart==0.0.9
|
| 11 |
+
python-docx==1.2.0
|
| 12 |
+
pikepdf==10.8.0
|
static/app.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
|
| 3 |
+
/* ============================================================
|
| 4 |
+
Local PDF Extractor — frontend logic (dependency-free)
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
(function () {
|
| 8 |
+
// ---------- State ----------
|
| 9 |
+
const settings = { mode: "max", lang: "en", preprocess: true, binarize: false, handwriting: false, online: false, online_key: "", online_model: "", force_ocr: false, remove_headers: true };
|
| 10 |
+
|
| 11 |
+
// jobId -> { jobId, filename, totalPages, status, pages: Map<pageNo, pageData>,
|
| 12 |
+
// el: {...}, source, es: EventSource }
|
| 13 |
+
const jobs = new Map();
|
| 14 |
+
let jobOrder = []; // preserve display order
|
| 15 |
+
|
| 16 |
+
// OCR lines below this confidence are highlighted as "to check".
|
| 17 |
+
const LOWCONF_THRESH = 0.85;
|
| 18 |
+
|
| 19 |
+
// ---------- Element refs ----------
|
| 20 |
+
const $ = (id) => document.getElementById(id);
|
| 21 |
+
|
| 22 |
+
const modeToggle = $("modeToggle");
|
| 23 |
+
const modeHint = $("modeHint");
|
| 24 |
+
const langSelect = $("langSelect");
|
| 25 |
+
const preprocessToggle = $("preprocessToggle");
|
| 26 |
+
const binarizeToggle = $("binarizeToggle");
|
| 27 |
+
const forceOcrToggle = $("forceOcrToggle");
|
| 28 |
+
const removeHeadersToggle = $("removeHeadersToggle");
|
| 29 |
+
const handwritingToggle = $("handwritingToggle");
|
| 30 |
+
const onlineToggle = $("onlineToggle");
|
| 31 |
+
const onlineRow = $("onlineRow");
|
| 32 |
+
const onlineKey = $("onlineKey");
|
| 33 |
+
const onlineCheck = $("onlineCheck");
|
| 34 |
+
const onlineStatus = $("onlineStatus");
|
| 35 |
+
const onlineModel = $("onlineModel");
|
| 36 |
+
const dropzone = $("dropzone");
|
| 37 |
+
const fileInput = $("fileInput");
|
| 38 |
+
const toolbar = $("toolbar");
|
| 39 |
+
const emptyState = $("emptyState");
|
| 40 |
+
const filesContainer = $("filesContainer");
|
| 41 |
+
const searchInput = $("searchInput");
|
| 42 |
+
const searchCount = $("searchCount");
|
| 43 |
+
const copyAllBtn = $("copyAllBtn");
|
| 44 |
+
const toast = $("toast");
|
| 45 |
+
|
| 46 |
+
const fileCardTpl = $("fileCardTemplate");
|
| 47 |
+
const pageCardTpl = $("pageCardTemplate");
|
| 48 |
+
|
| 49 |
+
// ---------- Settings UI ----------
|
| 50 |
+
modeToggle.addEventListener("click", (e) => {
|
| 51 |
+
const btn = e.target.closest(".seg");
|
| 52 |
+
if (!btn) return;
|
| 53 |
+
settings.mode = btn.dataset.mode;
|
| 54 |
+
modeToggle.querySelectorAll(".seg").forEach((b) => {
|
| 55 |
+
const active = b === btn;
|
| 56 |
+
b.classList.toggle("active", active);
|
| 57 |
+
b.setAttribute("aria-checked", active ? "true" : "false");
|
| 58 |
+
});
|
| 59 |
+
modeHint.textContent =
|
| 60 |
+
settings.mode === "max"
|
| 61 |
+
? "Max accuracy is slower but renders at higher DPI and uses angle correction."
|
| 62 |
+
: "Faster mode renders at lower DPI and skips angle correction — quicker, slightly less accurate.";
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
langSelect.addEventListener("change", () => {
|
| 66 |
+
settings.lang = langSelect.value;
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
preprocessToggle.addEventListener("change", () => {
|
| 70 |
+
settings.preprocess = preprocessToggle.checked;
|
| 71 |
+
// Binarize only makes sense when preprocessing is on.
|
| 72 |
+
binarizeToggle.disabled = !preprocessToggle.checked;
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
binarizeToggle.addEventListener("change", () => {
|
| 76 |
+
settings.binarize = binarizeToggle.checked;
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
if (forceOcrToggle) {
|
| 80 |
+
forceOcrToggle.addEventListener("change", () => {
|
| 81 |
+
settings.force_ocr = forceOcrToggle.checked;
|
| 82 |
+
});
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if (removeHeadersToggle) {
|
| 86 |
+
settings.remove_headers = removeHeadersToggle.checked;
|
| 87 |
+
removeHeadersToggle.addEventListener("change", () => {
|
| 88 |
+
settings.remove_headers = removeHeadersToggle.checked;
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
handwritingToggle.addEventListener("change", () => {
|
| 93 |
+
settings.handwriting = handwritingToggle.checked;
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
// ---------- Online vision OCR (Gemini, opt-in) ----------
|
| 97 |
+
const ONLINE_KEY_LS = "pdfx_gemini_key";
|
| 98 |
+
const ONLINE_MODEL_LS = "pdfx_gemini_model";
|
| 99 |
+
|
| 100 |
+
if (onlineToggle) {
|
| 101 |
+
// Restore a previously saved key/model (stored only in this browser).
|
| 102 |
+
try {
|
| 103 |
+
const savedKey = localStorage.getItem(ONLINE_KEY_LS) || "";
|
| 104 |
+
if (savedKey) {
|
| 105 |
+
onlineKey.value = savedKey;
|
| 106 |
+
settings.online_key = savedKey;
|
| 107 |
+
}
|
| 108 |
+
const savedModel = localStorage.getItem(ONLINE_MODEL_LS) || "";
|
| 109 |
+
if (savedModel) settings.online_model = savedModel;
|
| 110 |
+
} catch (_) {}
|
| 111 |
+
|
| 112 |
+
// Pre-populate the model dropdown with free-tier-friendly defaults so a
|
| 113 |
+
// model is always selectable, even before "Check key" runs (clicking
|
| 114 |
+
// "Check key" then refines the list to exactly what your key can use).
|
| 115 |
+
populateModels(["gemini-2.5-flash", "gemini-2.5-flash-lite"], "gemini-2.5-flash");
|
| 116 |
+
|
| 117 |
+
onlineToggle.addEventListener("change", () => {
|
| 118 |
+
settings.online = onlineToggle.checked;
|
| 119 |
+
onlineRow.hidden = !onlineToggle.checked;
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
onlineKey.addEventListener("input", () => {
|
| 123 |
+
settings.online_key = onlineKey.value.trim();
|
| 124 |
+
try {
|
| 125 |
+
localStorage.setItem(ONLINE_KEY_LS, settings.online_key);
|
| 126 |
+
} catch (_) {}
|
| 127 |
+
setOnlineStatus("", "");
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
onlineModel.addEventListener("change", () => {
|
| 131 |
+
settings.online_model = onlineModel.value;
|
| 132 |
+
try {
|
| 133 |
+
localStorage.setItem(ONLINE_MODEL_LS, settings.online_model);
|
| 134 |
+
} catch (_) {}
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
onlineCheck.addEventListener("click", checkOnlineKey);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function setOnlineStatus(msg, kind) {
|
| 141 |
+
if (!onlineStatus) return;
|
| 142 |
+
onlineStatus.textContent = msg;
|
| 143 |
+
onlineStatus.className = "online-status" + (kind ? " " + kind : "");
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async function checkOnlineKey() {
|
| 147 |
+
const key = (onlineKey.value || "").trim();
|
| 148 |
+
if (!key) {
|
| 149 |
+
setOnlineStatus("Enter a key first.", "bad");
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
onlineCheck.disabled = true;
|
| 153 |
+
setOnlineStatus("Checking…", "");
|
| 154 |
+
const form = new FormData();
|
| 155 |
+
form.append("api_key", key);
|
| 156 |
+
try {
|
| 157 |
+
const res = await fetch("/api/online/validate", { method: "POST", body: form });
|
| 158 |
+
const data = await res.json().catch(() => ({}));
|
| 159 |
+
if (!res.ok) {
|
| 160 |
+
setOnlineStatus(data.detail ? shortErr(data.detail) : "Key check failed.", "bad");
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
const models = data.supported && data.supported.length ? data.supported : [];
|
| 164 |
+
populateModels(models, data.recommended);
|
| 165 |
+
setOnlineStatus("✓ Key works · " + (models.length || "?") + " model(s)", "good");
|
| 166 |
+
} catch (e) {
|
| 167 |
+
setOnlineStatus("Network error checking key.", "bad");
|
| 168 |
+
} finally {
|
| 169 |
+
onlineCheck.disabled = false;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function populateModels(models, recommended) {
|
| 174 |
+
if (!onlineModel) return;
|
| 175 |
+
onlineModel.innerHTML = "";
|
| 176 |
+
const list = models && models.length ? models : recommended ? [recommended] : [];
|
| 177 |
+
list.forEach((m) => {
|
| 178 |
+
const opt = document.createElement("option");
|
| 179 |
+
opt.value = m;
|
| 180 |
+
opt.textContent = m;
|
| 181 |
+
onlineModel.appendChild(opt);
|
| 182 |
+
});
|
| 183 |
+
const saved = settings.online_model;
|
| 184 |
+
const choose =
|
| 185 |
+
saved && list.includes(saved)
|
| 186 |
+
? saved
|
| 187 |
+
: recommended && list.includes(recommended)
|
| 188 |
+
? recommended
|
| 189 |
+
: list[0] || "";
|
| 190 |
+
onlineModel.value = choose;
|
| 191 |
+
settings.online_model = choose;
|
| 192 |
+
try {
|
| 193 |
+
localStorage.setItem(ONLINE_MODEL_LS, choose);
|
| 194 |
+
} catch (_) {}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function shortErr(detail) {
|
| 198 |
+
const s = String(detail);
|
| 199 |
+
return s.length > 90 ? s.slice(0, 90) + "…" : s;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// ---------- Suggest best settings (opt-in) ----------
|
| 203 |
+
const suggestBtn = $("suggestBtn");
|
| 204 |
+
const suggestInput = $("suggestInput");
|
| 205 |
+
const suggestResult = $("suggestResult");
|
| 206 |
+
const suggestApply = $("suggestApply");
|
| 207 |
+
let lastRecommended = null;
|
| 208 |
+
|
| 209 |
+
if (suggestBtn) {
|
| 210 |
+
suggestBtn.addEventListener("click", () => suggestInput.click());
|
| 211 |
+
suggestInput.addEventListener("change", () => {
|
| 212 |
+
const f = suggestInput.files && suggestInput.files[0];
|
| 213 |
+
suggestInput.value = "";
|
| 214 |
+
if (f) analyzeFile(f);
|
| 215 |
+
});
|
| 216 |
+
suggestApply.addEventListener("click", () => {
|
| 217 |
+
if (lastRecommended) applyRecommended(lastRecommended);
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
async function analyzeFile(file) {
|
| 222 |
+
suggestBtn.disabled = true;
|
| 223 |
+
suggestBtn.textContent = "Analyzing 1–2 pages…";
|
| 224 |
+
suggestResult.hidden = true;
|
| 225 |
+
const form = new FormData();
|
| 226 |
+
form.append("file", file, file.name);
|
| 227 |
+
form.append("lang", settings.lang);
|
| 228 |
+
try {
|
| 229 |
+
const res = await fetch("/api/analyze", { method: "POST", body: form });
|
| 230 |
+
if (!res.ok) {
|
| 231 |
+
showToast("Analysis failed.");
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
const data = await res.json();
|
| 235 |
+
renderSuggestion(data);
|
| 236 |
+
} catch (e) {
|
| 237 |
+
showToast("Analysis failed (network).");
|
| 238 |
+
} finally {
|
| 239 |
+
suggestBtn.disabled = false;
|
| 240 |
+
suggestBtn.textContent = "Suggest best settings";
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function renderSuggestion(data) {
|
| 245 |
+
lastRecommended = data.recommended || null;
|
| 246 |
+
const rat = suggestResult.querySelector('[data-role="suggestRationale"]');
|
| 247 |
+
const ev = suggestResult.querySelector('[data-role="suggestEvidence"]');
|
| 248 |
+
if (data.needs_ocr === false) {
|
| 249 |
+
rat.textContent = data.rationale;
|
| 250 |
+
ev.innerHTML = "";
|
| 251 |
+
suggestApply.hidden = true; // nothing to apply; defaults are fine
|
| 252 |
+
} else {
|
| 253 |
+
rat.textContent = data.rationale || "";
|
| 254 |
+
suggestApply.hidden = false;
|
| 255 |
+
const rows = (data.evidence || []).map((r) => {
|
| 256 |
+
const tag = r.recommended ? " ★" : "";
|
| 257 |
+
const conf = r.mean_conf == null ? "–" : Math.round(r.mean_conf * 100) + "%";
|
| 258 |
+
const row = document.createElement("div");
|
| 259 |
+
row.className = "ev-row" + (r.recommended ? " best" : "");
|
| 260 |
+
[r.name + tag, "score " + r.composite, "conf " + conf, "lines " + r.confident_lines]
|
| 261 |
+
.forEach((t) => {
|
| 262 |
+
const s = document.createElement("span");
|
| 263 |
+
s.textContent = t;
|
| 264 |
+
row.appendChild(s);
|
| 265 |
+
});
|
| 266 |
+
return row;
|
| 267 |
+
});
|
| 268 |
+
ev.innerHTML = "";
|
| 269 |
+
rows.forEach((r) => ev.appendChild(r));
|
| 270 |
+
suggestApply.classList.toggle("btn-primary", data.decision === "decisive");
|
| 271 |
+
}
|
| 272 |
+
suggestResult.hidden = false;
|
| 273 |
+
showToast(
|
| 274 |
+
data.decision === "decisive"
|
| 275 |
+
? "Found clearly better settings — review and Apply."
|
| 276 |
+
: "Recommendation ready."
|
| 277 |
+
);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function applyRecommended(rec) {
|
| 281 |
+
if (rec.mode) {
|
| 282 |
+
settings.mode = rec.mode;
|
| 283 |
+
modeToggle.querySelectorAll(".seg").forEach((b) => {
|
| 284 |
+
const active = b.dataset.mode === rec.mode;
|
| 285 |
+
b.classList.toggle("active", active);
|
| 286 |
+
b.setAttribute("aria-checked", active ? "true" : "false");
|
| 287 |
+
});
|
| 288 |
+
}
|
| 289 |
+
if (rec.lang) {
|
| 290 |
+
settings.lang = rec.lang;
|
| 291 |
+
langSelect.value = rec.lang;
|
| 292 |
+
}
|
| 293 |
+
settings.preprocess = !!rec.preprocess;
|
| 294 |
+
preprocessToggle.checked = settings.preprocess;
|
| 295 |
+
settings.binarize = !!rec.binarize;
|
| 296 |
+
binarizeToggle.checked = settings.binarize;
|
| 297 |
+
binarizeToggle.disabled = !settings.preprocess;
|
| 298 |
+
showToast("Applied recommended settings. Now drop your PDF to extract.");
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// ---------- Drag & drop + picker ----------
|
| 302 |
+
dropzone.addEventListener("click", () => fileInput.click());
|
| 303 |
+
dropzone.addEventListener("keydown", (e) => {
|
| 304 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 305 |
+
e.preventDefault();
|
| 306 |
+
fileInput.click();
|
| 307 |
+
}
|
| 308 |
+
});
|
| 309 |
+
fileInput.addEventListener("change", () => {
|
| 310 |
+
handleFiles(fileInput.files);
|
| 311 |
+
fileInput.value = "";
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
+
["dragenter", "dragover"].forEach((ev) =>
|
| 315 |
+
dropzone.addEventListener(ev, (e) => {
|
| 316 |
+
e.preventDefault();
|
| 317 |
+
e.stopPropagation();
|
| 318 |
+
dropzone.classList.add("dragover");
|
| 319 |
+
})
|
| 320 |
+
);
|
| 321 |
+
["dragleave", "dragend"].forEach((ev) =>
|
| 322 |
+
dropzone.addEventListener(ev, (e) => {
|
| 323 |
+
e.preventDefault();
|
| 324 |
+
e.stopPropagation();
|
| 325 |
+
dropzone.classList.remove("dragover");
|
| 326 |
+
})
|
| 327 |
+
);
|
| 328 |
+
dropzone.addEventListener("drop", (e) => {
|
| 329 |
+
e.preventDefault();
|
| 330 |
+
e.stopPropagation();
|
| 331 |
+
dropzone.classList.remove("dragover");
|
| 332 |
+
if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files);
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
// Prevent the browser from navigating when a file is dropped outside the zone.
|
| 336 |
+
["dragover", "drop"].forEach((ev) =>
|
| 337 |
+
window.addEventListener(ev, (e) => {
|
| 338 |
+
if (!dropzone.contains(e.target)) e.preventDefault();
|
| 339 |
+
})
|
| 340 |
+
);
|
| 341 |
+
|
| 342 |
+
function handleFiles(fileList) {
|
| 343 |
+
const files = Array.from(fileList).filter(
|
| 344 |
+
(f) => f.type === "application/pdf" || /\.pdf$/i.test(f.name)
|
| 345 |
+
);
|
| 346 |
+
if (!files.length) {
|
| 347 |
+
showToast("Please choose PDF files only.");
|
| 348 |
+
return;
|
| 349 |
+
}
|
| 350 |
+
files.forEach(uploadFile);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
// ---------- Upload ----------
|
| 354 |
+
async function uploadFile(file) {
|
| 355 |
+
const form = new FormData();
|
| 356 |
+
form.append("file", file, file.name);
|
| 357 |
+
form.append("mode", settings.mode);
|
| 358 |
+
form.append("lang", settings.lang);
|
| 359 |
+
form.append("preprocess", settings.preprocess ? "true" : "false");
|
| 360 |
+
form.append("binarize", settings.binarize ? "true" : "false");
|
| 361 |
+
form.append("handwriting", settings.handwriting ? "true" : "false");
|
| 362 |
+
form.append("online", settings.online ? "true" : "false");
|
| 363 |
+
form.append("online_key", settings.online_key || "");
|
| 364 |
+
form.append("online_model", settings.online_model || "");
|
| 365 |
+
form.append("force_ocr", settings.force_ocr ? "true" : "false");
|
| 366 |
+
form.append("remove_headers", settings.remove_headers ? "true" : "false");
|
| 367 |
+
|
| 368 |
+
// Optimistic card while uploading.
|
| 369 |
+
const placeholderId = "pending-" + Math.random().toString(36).slice(2);
|
| 370 |
+
const job = createFileCard({
|
| 371 |
+
jobId: placeholderId,
|
| 372 |
+
filename: file.name,
|
| 373 |
+
totalPages: 0,
|
| 374 |
+
status: "pending",
|
| 375 |
+
});
|
| 376 |
+
setFileStatus(job, "pending", "Uploading…");
|
| 377 |
+
|
| 378 |
+
let res;
|
| 379 |
+
try {
|
| 380 |
+
res = await fetch("/api/upload", { method: "POST", body: form });
|
| 381 |
+
} catch (err) {
|
| 382 |
+
failJob(job, "Network error during upload: " + (err && err.message ? err.message : err));
|
| 383 |
+
return;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
if (!res.ok) {
|
| 387 |
+
let detail = "Upload failed (" + res.status + ")";
|
| 388 |
+
try {
|
| 389 |
+
const data = await res.json();
|
| 390 |
+
if (data && data.detail) detail = humanizeError(data.detail);
|
| 391 |
+
} catch (_) {}
|
| 392 |
+
failJob(job, detail);
|
| 393 |
+
return;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
let data;
|
| 397 |
+
try {
|
| 398 |
+
data = await res.json();
|
| 399 |
+
} catch (err) {
|
| 400 |
+
failJob(job, "Invalid server response.");
|
| 401 |
+
return;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
// If the user removed this card while it was still uploading, don't
|
| 405 |
+
// resurrect it: free the real server job (now that we know its id) and bail
|
| 406 |
+
// out before re-adding state / opening a stream.
|
| 407 |
+
if (job.removed) {
|
| 408 |
+
const id = data && data.job_id;
|
| 409 |
+
if (id) fetch("/api/jobs/" + encodeURIComponent(id), { method: "DELETE" }).catch(() => {});
|
| 410 |
+
return;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
// Re-key the job from placeholder -> real job_id.
|
| 414 |
+
rekeyJob(job, placeholderId, data.job_id);
|
| 415 |
+
job.jobId = data.job_id;
|
| 416 |
+
job.filename = data.filename || file.name;
|
| 417 |
+
job.totalPages = data.total_pages || 0;
|
| 418 |
+
job.status = data.status || "processing";
|
| 419 |
+
job.el.fileName.textContent = job.filename;
|
| 420 |
+
setFileStatus(job, "processing", "Processing…");
|
| 421 |
+
updateProgress(job, 0, job.totalPages);
|
| 422 |
+
|
| 423 |
+
openStream(job);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function rekeyJob(job, oldId, newId) {
|
| 427 |
+
jobs.delete(oldId);
|
| 428 |
+
jobs.set(newId, job);
|
| 429 |
+
const idx = jobOrder.indexOf(oldId);
|
| 430 |
+
if (idx !== -1) jobOrder[idx] = newId;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// ---------- SSE stream ----------
|
| 434 |
+
function openStream(job) {
|
| 435 |
+
// Never (re)open a stream for a job the user removed or that's gone from
|
| 436 |
+
// state — a pollOnce reopen could otherwise leak an untracked EventSource.
|
| 437 |
+
if (job.removed || !jobs.has(job.jobId)) return;
|
| 438 |
+
if (job.es) {
|
| 439 |
+
job.es.close();
|
| 440 |
+
job.es = null;
|
| 441 |
+
}
|
| 442 |
+
const es = new EventSource("/api/jobs/" + encodeURIComponent(job.jobId) + "/stream");
|
| 443 |
+
job.es = es;
|
| 444 |
+
|
| 445 |
+
es.onmessage = (evt) => {
|
| 446 |
+
let payload;
|
| 447 |
+
try {
|
| 448 |
+
payload = JSON.parse(evt.data);
|
| 449 |
+
} catch (_) {
|
| 450 |
+
return;
|
| 451 |
+
}
|
| 452 |
+
handleEvent(job, payload);
|
| 453 |
+
};
|
| 454 |
+
|
| 455 |
+
es.onerror = () => {
|
| 456 |
+
// Network/stream dropped. If the job isn't terminal, fall back to polling once.
|
| 457 |
+
if (["done", "error", "cancelled"].includes(job.status)) {
|
| 458 |
+
es.close();
|
| 459 |
+
return;
|
| 460 |
+
}
|
| 461 |
+
es.close();
|
| 462 |
+
pollOnce(job);
|
| 463 |
+
};
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
function handleEvent(job, payload) {
|
| 467 |
+
if (payload.type === "progress") {
|
| 468 |
+
if (payload.page) applyPage(job, payload.page);
|
| 469 |
+
updateProgress(
|
| 470 |
+
job,
|
| 471 |
+
typeof payload.processed === "number" ? payload.processed : countDone(job),
|
| 472 |
+
typeof payload.total === "number" ? payload.total : job.totalPages
|
| 473 |
+
);
|
| 474 |
+
} else if (payload.type === "done") {
|
| 475 |
+
setFileStatus(job, "done", "Done");
|
| 476 |
+
closeStream(job);
|
| 477 |
+
refreshExportState();
|
| 478 |
+
} else if (payload.type === "error") {
|
| 479 |
+
failJob(job, payload.error ? humanizeError(payload.error) : "Processing failed.");
|
| 480 |
+
closeStream(job);
|
| 481 |
+
} else if (payload.type === "cancelled") {
|
| 482 |
+
setFileStatus(job, "cancelled", "Cancelled");
|
| 483 |
+
closeStream(job);
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
function closeStream(job) {
|
| 488 |
+
if (job.es) {
|
| 489 |
+
job.es.close();
|
| 490 |
+
job.es = null;
|
| 491 |
+
}
|
| 492 |
+
job.el.cancelBtn.disabled = true;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
// Fallback: fetch the full job state once if the stream errored mid-flight.
|
| 496 |
+
async function pollOnce(job) {
|
| 497 |
+
// Bail if the user removed this card (or it's no longer tracked) so we don't
|
| 498 |
+
// resurrect a removed job's stream or render into a detached DOM subtree.
|
| 499 |
+
if (job.removed || !jobs.has(job.jobId)) return;
|
| 500 |
+
try {
|
| 501 |
+
const res = await fetch("/api/jobs/" + encodeURIComponent(job.jobId));
|
| 502 |
+
if (job.removed || !jobs.has(job.jobId)) return;
|
| 503 |
+
if (!res.ok) {
|
| 504 |
+
if (!["done", "error", "cancelled"].includes(job.status)) {
|
| 505 |
+
failJob(job, "Lost connection to the job stream.");
|
| 506 |
+
}
|
| 507 |
+
return;
|
| 508 |
+
}
|
| 509 |
+
const data = await res.json();
|
| 510 |
+
job.totalPages = data.total_pages || job.totalPages;
|
| 511 |
+
(data.pages || []).forEach((p) => {
|
| 512 |
+
if (p.status === "done" || p.status === "error") applyPage(job, p);
|
| 513 |
+
});
|
| 514 |
+
updateProgress(job, data.processed_pages || 0, job.totalPages);
|
| 515 |
+
if (data.status === "done") setFileStatus(job, "done", "Done");
|
| 516 |
+
else if (data.status === "error") failJob(job, data.error ? humanizeError(data.error) : "Processing failed.");
|
| 517 |
+
else if (data.status === "cancelled") setFileStatus(job, "cancelled", "Cancelled");
|
| 518 |
+
else {
|
| 519 |
+
// still processing — reopen the stream
|
| 520 |
+
openStream(job);
|
| 521 |
+
}
|
| 522 |
+
refreshExportState();
|
| 523 |
+
} catch (err) {
|
| 524 |
+
if (!["done", "error", "cancelled"].includes(job.status)) {
|
| 525 |
+
failJob(job, "Lost connection to the job stream.");
|
| 526 |
+
}
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
// ---------- Page rendering ----------
|
| 531 |
+
function applyPage(job, page) {
|
| 532 |
+
const pageNo = page.page;
|
| 533 |
+
let entry = job.pages.get(pageNo);
|
| 534 |
+
if (!entry) {
|
| 535 |
+
entry = buildPageCard(job, pageNo);
|
| 536 |
+
job.pages.set(pageNo, entry);
|
| 537 |
+
insertPageCardInOrder(job, entry);
|
| 538 |
+
}
|
| 539 |
+
entry.data = page;
|
| 540 |
+
|
| 541 |
+
// badge
|
| 542 |
+
const badge = entry.el.badge;
|
| 543 |
+
badge.className = "badge";
|
| 544 |
+
if (page.status === "error") {
|
| 545 |
+
badge.classList.add("error");
|
| 546 |
+
badge.textContent = "Error";
|
| 547 |
+
} else if (page.source === "text") {
|
| 548 |
+
badge.classList.add("text");
|
| 549 |
+
badge.textContent = "Text layer";
|
| 550 |
+
} else if (page.source === "handwriting") {
|
| 551 |
+
badge.classList.add("handwriting");
|
| 552 |
+
badge.textContent = "Handwriting";
|
| 553 |
+
badge.title = "Recognised with the local handwriting model (TrOCR).";
|
| 554 |
+
} else if (page.source === "online") {
|
| 555 |
+
badge.classList.add("online");
|
| 556 |
+
badge.textContent = "Gemini";
|
| 557 |
+
badge.title = "Transcribed online by Google Gemini.";
|
| 558 |
+
} else if (page.source === "ocr") {
|
| 559 |
+
badge.classList.add("ocr");
|
| 560 |
+
const conf = typeof page.confidence === "number" ? page.confidence : null;
|
| 561 |
+
if (conf !== null) {
|
| 562 |
+
const pct = Math.round(conf * 100);
|
| 563 |
+
badge.textContent = "OCR · " + pct + "%";
|
| 564 |
+
if (conf < LOWCONF_THRESH) {
|
| 565 |
+
badge.classList.add("lowconf");
|
| 566 |
+
badge.title = "Low OCR confidence — verify this page against the original image.";
|
| 567 |
+
} else {
|
| 568 |
+
badge.title = "Average OCR confidence " + pct + "%";
|
| 569 |
+
}
|
| 570 |
+
} else {
|
| 571 |
+
badge.textContent = "OCR";
|
| 572 |
+
}
|
| 573 |
+
} else {
|
| 574 |
+
badge.classList.add("pending");
|
| 575 |
+
badge.textContent = "Pending";
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
// text
|
| 579 |
+
const text = page.status === "error"
|
| 580 |
+
? (page.error ? "Error: " + page.error : "Extraction failed for this page.")
|
| 581 |
+
: (page.text || "");
|
| 582 |
+
// Preserve a user's manual edits even if the server re-broadcasts this page
|
| 583 |
+
// (e.g. the header/footer cleanup pass at the end of a job). Also don't
|
| 584 |
+
// clobber text while the editor is OPEN (unsaved) — the textarea holds the
|
| 585 |
+
// user's in-progress value.
|
| 586 |
+
if (!entry.edited && !entry.editing) entry.text = text;
|
| 587 |
+
|
| 588 |
+
// "N to check": count low-confidence OCR lines (before any manual edit, and
|
| 589 |
+
// not while the editor is open so the chip doesn't flicker back mid-edit).
|
| 590 |
+
let lcCount = 0;
|
| 591 |
+
if (!entry.edited && !entry.editing && page.status === "done" && Array.isArray(page.lines)) {
|
| 592 |
+
lcCount = page.lines.filter(
|
| 593 |
+
(l) => l && typeof l.confidence === "number" && l.confidence < LOWCONF_THRESH
|
| 594 |
+
).length;
|
| 595 |
+
}
|
| 596 |
+
if (entry.el.toCheck) {
|
| 597 |
+
if (lcCount > 0) {
|
| 598 |
+
entry.el.toCheck.hidden = false;
|
| 599 |
+
entry.el.toCheck.textContent = "⚠ " + lcCount + " to check";
|
| 600 |
+
entry.el.toCheck.title =
|
| 601 |
+
lcCount + " line(s) the OCR is unsure about — highlighted below. Click Edit to fix them.";
|
| 602 |
+
} else {
|
| 603 |
+
entry.el.toCheck.hidden = true;
|
| 604 |
+
}
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
const q = searchInput.value.trim();
|
| 608 |
+
if (q) {
|
| 609 |
+
// Re-run the whole search so the match count includes this newly arrived
|
| 610 |
+
// / re-broadcast page (renderPageBody alone wouldn't update searchCount).
|
| 611 |
+
runSearch();
|
| 612 |
+
} else {
|
| 613 |
+
renderPageBody(entry, "");
|
| 614 |
+
}
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
function buildPageCard(job, pageNo) {
|
| 618 |
+
const frag = pageCardTpl.content.cloneNode(true);
|
| 619 |
+
const root = frag.querySelector('[data-role="pageCard"]');
|
| 620 |
+
const el = {
|
| 621 |
+
pageNum: frag.querySelector('[data-role="pageNum"]'),
|
| 622 |
+
badge: frag.querySelector('[data-role="badge"]'),
|
| 623 |
+
toCheck: frag.querySelector('[data-role="toCheck"]'),
|
| 624 |
+
pageText: frag.querySelector('[data-role="pageText"]'),
|
| 625 |
+
pageEdit: frag.querySelector('[data-role="pageEdit"]'),
|
| 626 |
+
editBtn: frag.querySelector('[data-role="editBtn"]'),
|
| 627 |
+
toggleImg: frag.querySelector('[data-role="toggleImg"]'),
|
| 628 |
+
copyPage: frag.querySelector('[data-role="copyPage"]'),
|
| 629 |
+
imageWrap: frag.querySelector('[data-role="imageWrap"]'),
|
| 630 |
+
};
|
| 631 |
+
el.pageNum.textContent = "Page " + pageNo;
|
| 632 |
+
|
| 633 |
+
const entry = { pageNo, root, el, text: "", data: null, imgLoaded: false, edited: false, editing: false };
|
| 634 |
+
|
| 635 |
+
el.copyPage.addEventListener("click", () => {
|
| 636 |
+
copyText(entry.text || "");
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
// Edit: swap the read-only text for a textarea; saving updates the page's
|
| 640 |
+
// text so Copy and all exports use the corrected version.
|
| 641 |
+
el.editBtn.addEventListener("click", () => {
|
| 642 |
+
const editing = !el.pageEdit.hidden;
|
| 643 |
+
if (!editing) {
|
| 644 |
+
el.pageEdit.value = entry.text || "";
|
| 645 |
+
el.pageEdit.hidden = false;
|
| 646 |
+
el.pageText.hidden = true;
|
| 647 |
+
el.editBtn.textContent = "Done";
|
| 648 |
+
if (el.toCheck) el.toCheck.hidden = true;
|
| 649 |
+
entry.editing = true; // a re-broadcast must not overwrite text / reshow the chip mid-edit
|
| 650 |
+
el.pageEdit.focus();
|
| 651 |
+
} else {
|
| 652 |
+
entry.text = el.pageEdit.value;
|
| 653 |
+
entry.edited = true; // stop low-confidence highlighting; keep edits on re-broadcast
|
| 654 |
+
entry.editing = false;
|
| 655 |
+
el.pageEdit.hidden = true;
|
| 656 |
+
el.pageText.hidden = false;
|
| 657 |
+
el.editBtn.textContent = "Edit";
|
| 658 |
+
const q = searchInput.value.trim();
|
| 659 |
+
if (q) runSearch(); else renderPageBody(entry, "");
|
| 660 |
+
refreshExportState();
|
| 661 |
+
showToast("Saved — Copy and exports now use your edited text.");
|
| 662 |
+
}
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
el.toggleImg.addEventListener("click", () => {
|
| 666 |
+
const wrap = el.imageWrap;
|
| 667 |
+
const isHidden = wrap.hasAttribute("hidden");
|
| 668 |
+
if (isHidden) {
|
| 669 |
+
wrap.removeAttribute("hidden");
|
| 670 |
+
el.toggleImg.textContent = "Hide original page";
|
| 671 |
+
if (!entry.imgLoaded) {
|
| 672 |
+
entry.imgLoaded = true;
|
| 673 |
+
wrap.innerHTML = '<div class="img-loading">Rendering original page…</div>';
|
| 674 |
+
const img = new Image();
|
| 675 |
+
img.alt = "Original page " + pageNo;
|
| 676 |
+
img.onload = () => {
|
| 677 |
+
wrap.innerHTML = "";
|
| 678 |
+
wrap.appendChild(img);
|
| 679 |
+
};
|
| 680 |
+
img.onerror = () => {
|
| 681 |
+
wrap.innerHTML = '<div class="img-loading">Could not render this page.</div>';
|
| 682 |
+
entry.imgLoaded = false;
|
| 683 |
+
};
|
| 684 |
+
img.src =
|
| 685 |
+
"/api/jobs/" + encodeURIComponent(job.jobId) + "/pages/" + pageNo + "/image?dpi=150";
|
| 686 |
+
}
|
| 687 |
+
} else {
|
| 688 |
+
wrap.setAttribute("hidden", "");
|
| 689 |
+
el.toggleImg.textContent = "Show original page";
|
| 690 |
+
}
|
| 691 |
+
});
|
| 692 |
+
|
| 693 |
+
return entry;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
function insertPageCardInOrder(job, entry) {
|
| 697 |
+
const container = job.el.pages;
|
| 698 |
+
// Insert keeping ascending page order.
|
| 699 |
+
let inserted = false;
|
| 700 |
+
const children = container.children;
|
| 701 |
+
for (let i = 0; i < children.length; i++) {
|
| 702 |
+
const otherNo = Number(children[i].dataset.pageNo);
|
| 703 |
+
if (otherNo > entry.pageNo) {
|
| 704 |
+
container.insertBefore(entry.root, children[i]);
|
| 705 |
+
inserted = true;
|
| 706 |
+
break;
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
+
entry.root.dataset.pageNo = String(entry.pageNo);
|
| 710 |
+
if (!inserted) container.appendChild(entry.root);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// ---------- File card ----------
|
| 714 |
+
function createFileCard(meta) {
|
| 715 |
+
const frag = fileCardTpl.content.cloneNode(true);
|
| 716 |
+
const el = {
|
| 717 |
+
root: frag.querySelector(".file-card"),
|
| 718 |
+
statusDot: frag.querySelector('[data-role="statusDot"]'),
|
| 719 |
+
fileName: frag.querySelector('[data-role="fileName"]'),
|
| 720 |
+
statusText: frag.querySelector('[data-role="statusText"]'),
|
| 721 |
+
cancelBtn: frag.querySelector('[data-role="cancelBtn"]'),
|
| 722 |
+
removeBtn: frag.querySelector('[data-role="removeBtn"]'),
|
| 723 |
+
progressFill: frag.querySelector('[data-role="progressFill"]'),
|
| 724 |
+
progressLabel: frag.querySelector('[data-role="progressLabel"]'),
|
| 725 |
+
fileError: frag.querySelector('[data-role="fileError"]'),
|
| 726 |
+
pages: frag.querySelector('[data-role="pages"]'),
|
| 727 |
+
};
|
| 728 |
+
el.fileName.textContent = meta.filename;
|
| 729 |
+
|
| 730 |
+
const job = {
|
| 731 |
+
jobId: meta.jobId,
|
| 732 |
+
filename: meta.filename,
|
| 733 |
+
totalPages: meta.totalPages || 0,
|
| 734 |
+
status: meta.status || "pending",
|
| 735 |
+
pages: new Map(),
|
| 736 |
+
es: null,
|
| 737 |
+
el,
|
| 738 |
+
};
|
| 739 |
+
|
| 740 |
+
el.cancelBtn.addEventListener("click", () => cancelJob(job));
|
| 741 |
+
if (el.removeBtn) el.removeBtn.addEventListener("click", () => removeJob(job));
|
| 742 |
+
|
| 743 |
+
jobs.set(job.jobId, job);
|
| 744 |
+
jobOrder.push(job.jobId);
|
| 745 |
+
|
| 746 |
+
emptyState.hidden = true;
|
| 747 |
+
toolbar.hidden = false;
|
| 748 |
+
filesContainer.appendChild(el.root);
|
| 749 |
+
return job;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
// Remove a file card: stop its stream, free its memory on the server, and
|
| 753 |
+
// drop it from the UI/state. Works at any stage (also cancels if in-flight).
|
| 754 |
+
function removeJob(job) {
|
| 755 |
+
job.removed = true; // upload continuation checks this to avoid resurrecting it
|
| 756 |
+
closeStream(job);
|
| 757 |
+
if (job.jobId && String(job.jobId).indexOf("pending-") !== 0) {
|
| 758 |
+
// Tell the server to drop the job (frees its in-memory PDF bytes).
|
| 759 |
+
fetch("/api/jobs/" + encodeURIComponent(job.jobId), { method: "DELETE" }).catch(() => {});
|
| 760 |
+
}
|
| 761 |
+
if (job.el && job.el.root && job.el.root.parentNode) {
|
| 762 |
+
job.el.root.parentNode.removeChild(job.el.root);
|
| 763 |
+
}
|
| 764 |
+
jobs.delete(job.jobId);
|
| 765 |
+
const idx = jobOrder.indexOf(job.jobId);
|
| 766 |
+
if (idx !== -1) jobOrder.splice(idx, 1);
|
| 767 |
+
if (jobs.size === 0) {
|
| 768 |
+
emptyState.hidden = false;
|
| 769 |
+
toolbar.hidden = true;
|
| 770 |
+
}
|
| 771 |
+
refreshExportState();
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
function setFileStatus(job, status, label) {
|
| 775 |
+
job.status = status;
|
| 776 |
+
job.el.statusText.textContent = label;
|
| 777 |
+
job.el.statusDot.className = "status-dot " + status;
|
| 778 |
+
if (status === "done" || status === "error" || status === "cancelled") {
|
| 779 |
+
job.el.cancelBtn.disabled = true;
|
| 780 |
+
}
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
function updateProgress(job, processed, total) {
|
| 784 |
+
total = total || job.totalPages || 0;
|
| 785 |
+
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
| 786 |
+
job.el.progressFill.style.width = pct + "%";
|
| 787 |
+
job.el.progressLabel.textContent = processed + " / " + total;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
function failJob(job, message) {
|
| 791 |
+
setFileStatus(job, "error", "Error");
|
| 792 |
+
job.el.fileError.hidden = false;
|
| 793 |
+
job.el.fileError.textContent = message;
|
| 794 |
+
closeStream(job);
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
function countDone(job) {
|
| 798 |
+
let n = 0;
|
| 799 |
+
job.pages.forEach((p) => {
|
| 800 |
+
if (p.data && (p.data.status === "done" || p.data.status === "error")) n++;
|
| 801 |
+
});
|
| 802 |
+
return n;
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
async function cancelJob(job) {
|
| 806 |
+
if (["done", "error", "cancelled"].includes(job.status)) return;
|
| 807 |
+
job.el.cancelBtn.disabled = true;
|
| 808 |
+
setFileStatus(job, "processing", "Cancelling…");
|
| 809 |
+
try {
|
| 810 |
+
await fetch("/api/jobs/" + encodeURIComponent(job.jobId) + "/cancel", { method: "POST" });
|
| 811 |
+
} catch (_) {
|
| 812 |
+
showToast("Could not reach server to cancel.");
|
| 813 |
+
}
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
// ---------- Search & highlight ----------
|
| 817 |
+
let searchTimer = null;
|
| 818 |
+
searchInput.addEventListener("input", () => {
|
| 819 |
+
clearTimeout(searchTimer);
|
| 820 |
+
searchTimer = setTimeout(runSearch, 120);
|
| 821 |
+
});
|
| 822 |
+
|
| 823 |
+
function runSearch() {
|
| 824 |
+
const q = searchInput.value.trim();
|
| 825 |
+
let total = 0;
|
| 826 |
+
jobs.forEach((job) => {
|
| 827 |
+
job.pages.forEach((entry) => {
|
| 828 |
+
total += applySearchToEntry(entry, q);
|
| 829 |
+
});
|
| 830 |
+
});
|
| 831 |
+
if (q) {
|
| 832 |
+
searchCount.hidden = false;
|
| 833 |
+
searchCount.textContent = total + (total === 1 ? " match" : " matches");
|
| 834 |
+
} else {
|
| 835 |
+
searchCount.hidden = true;
|
| 836 |
+
}
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
// Render a page's text into its <pre>, applying BOTH low-confidence line
|
| 840 |
+
// highlighting (OCR pages, before the user edits) AND search highlighting.
|
| 841 |
+
// Returns the number of search matches.
|
| 842 |
+
function renderPageBody(entry, q) {
|
| 843 |
+
const target = entry.el.pageText;
|
| 844 |
+
const text = entry.text || "";
|
| 845 |
+
// Keep the element truly :empty for blank pages so the CSS
|
| 846 |
+
// "(no text on this page)" placeholder shows (an empty text node would
|
| 847 |
+
// defeat :empty).
|
| 848 |
+
if (!text) {
|
| 849 |
+
target.textContent = "";
|
| 850 |
+
return 0;
|
| 851 |
+
}
|
| 852 |
+
const ql = (q || "").toLowerCase();
|
| 853 |
+
const lines =
|
| 854 |
+
!entry.edited && entry.data && entry.data.status === "done" &&
|
| 855 |
+
Array.isArray(entry.data.lines)
|
| 856 |
+
? entry.data.lines
|
| 857 |
+
: null;
|
| 858 |
+
const textLines = text.split("\n");
|
| 859 |
+
const frag = document.createDocumentFragment();
|
| 860 |
+
let count = 0;
|
| 861 |
+
|
| 862 |
+
textLines.forEach((ln, i) => {
|
| 863 |
+
const lc =
|
| 864 |
+
lines && lines[i] && typeof lines[i].confidence === "number" &&
|
| 865 |
+
lines[i].confidence < LOWCONF_THRESH;
|
| 866 |
+
let wrap = null;
|
| 867 |
+
let sink = frag;
|
| 868 |
+
if (lc) {
|
| 869 |
+
wrap = document.createElement("span");
|
| 870 |
+
wrap.className = "lc-line";
|
| 871 |
+
wrap.title =
|
| 872 |
+
"Low confidence (" + Math.round(lines[i].confidence * 100) +
|
| 873 |
+
"%) — double-check this line";
|
| 874 |
+
sink = wrap;
|
| 875 |
+
}
|
| 876 |
+
const lower = ln.toLowerCase();
|
| 877 |
+
if (ql && lower.length === ln.length && lower.includes(ql)) {
|
| 878 |
+
// Common case: lowercase preserves length, so indices in `lower` align
|
| 879 |
+
// 1:1 with `ln`. Use ql.length consistently for slice + advance.
|
| 880 |
+
let idx = 0;
|
| 881 |
+
while (true) {
|
| 882 |
+
const found = lower.indexOf(ql, idx);
|
| 883 |
+
if (found === -1) {
|
| 884 |
+
sink.appendChild(document.createTextNode(ln.slice(idx)));
|
| 885 |
+
break;
|
| 886 |
+
}
|
| 887 |
+
if (found > idx) sink.appendChild(document.createTextNode(ln.slice(idx, found)));
|
| 888 |
+
const mark = document.createElement("mark");
|
| 889 |
+
mark.textContent = ln.slice(found, found + ql.length);
|
| 890 |
+
sink.appendChild(mark);
|
| 891 |
+
count++;
|
| 892 |
+
idx = found + ql.length;
|
| 893 |
+
}
|
| 894 |
+
} else if (ql && lower.includes(ql)) {
|
| 895 |
+
// Rare: a character whose lowercase changes length (e.g. Turkish 'İ')
|
| 896 |
+
// makes `lower` indices misalign with `ln`. Count the matches but render
|
| 897 |
+
// the line plain rather than wrapping the wrong characters in <mark>.
|
| 898 |
+
let from = 0, f;
|
| 899 |
+
while ((f = lower.indexOf(ql, from)) !== -1) { count++; from = f + ql.length; }
|
| 900 |
+
sink.appendChild(document.createTextNode(ln));
|
| 901 |
+
} else {
|
| 902 |
+
sink.appendChild(document.createTextNode(ln));
|
| 903 |
+
}
|
| 904 |
+
if (wrap) frag.appendChild(wrap);
|
| 905 |
+
if (i < textLines.length - 1) frag.appendChild(document.createTextNode("\n"));
|
| 906 |
+
});
|
| 907 |
+
|
| 908 |
+
target.textContent = "";
|
| 909 |
+
target.appendChild(frag);
|
| 910 |
+
return count;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
function applySearchToEntry(entry, q) {
|
| 914 |
+
return renderPageBody(entry, q);
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
// ---------- Copy ----------
|
| 918 |
+
copyAllBtn.addEventListener("click", () => {
|
| 919 |
+
const parts = [];
|
| 920 |
+
orderedJobs().forEach((job) => {
|
| 921 |
+
parts.push("===== " + job.filename + " =====");
|
| 922 |
+
orderedPages(job).forEach((entry) => {
|
| 923 |
+
parts.push("--- Page " + entry.pageNo + " ---");
|
| 924 |
+
parts.push(entry.text || "");
|
| 925 |
+
});
|
| 926 |
+
parts.push("");
|
| 927 |
+
});
|
| 928 |
+
copyText(parts.join("\n"));
|
| 929 |
+
});
|
| 930 |
+
|
| 931 |
+
function copyText(text) {
|
| 932 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 933 |
+
navigator.clipboard.writeText(text).then(
|
| 934 |
+
() => showToast("Copied to clipboard"),
|
| 935 |
+
() => fallbackCopy(text)
|
| 936 |
+
);
|
| 937 |
+
} else {
|
| 938 |
+
fallbackCopy(text);
|
| 939 |
+
}
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
function fallbackCopy(text) {
|
| 943 |
+
const ta = document.createElement("textarea");
|
| 944 |
+
ta.value = text;
|
| 945 |
+
ta.style.position = "fixed";
|
| 946 |
+
ta.style.opacity = "0";
|
| 947 |
+
document.body.appendChild(ta);
|
| 948 |
+
ta.select();
|
| 949 |
+
try {
|
| 950 |
+
document.execCommand("copy");
|
| 951 |
+
showToast("Copied to clipboard");
|
| 952 |
+
} catch (_) {
|
| 953 |
+
showToast("Copy failed");
|
| 954 |
+
}
|
| 955 |
+
document.body.removeChild(ta);
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
// ---------- Export ----------
|
| 959 |
+
document.querySelectorAll("[data-export]").forEach((btn) => {
|
| 960 |
+
btn.addEventListener("click", () => exportAll(btn.dataset.export));
|
| 961 |
+
});
|
| 962 |
+
|
| 963 |
+
function exportAll(kind) {
|
| 964 |
+
const list = orderedJobs();
|
| 965 |
+
if (!list.length) {
|
| 966 |
+
showToast("Nothing to export yet.");
|
| 967 |
+
return;
|
| 968 |
+
}
|
| 969 |
+
if (kind === "docx") {
|
| 970 |
+
return exportDocx(list);
|
| 971 |
+
}
|
| 972 |
+
if (kind === "json") {
|
| 973 |
+
const data = list.map((job) => ({
|
| 974 |
+
filename: job.filename,
|
| 975 |
+
job_id: String(job.jobId),
|
| 976 |
+
status: job.status,
|
| 977 |
+
total_pages: job.totalPages,
|
| 978 |
+
pages: orderedPages(job).map((entry) => ({
|
| 979 |
+
page: entry.pageNo,
|
| 980 |
+
source: entry.data ? entry.data.source : null,
|
| 981 |
+
status: entry.data ? entry.data.status : "pending",
|
| 982 |
+
confidence: entry.data ? entry.data.confidence : null,
|
| 983 |
+
text: entry.text || "",
|
| 984 |
+
})),
|
| 985 |
+
}));
|
| 986 |
+
download("extraction.json", JSON.stringify(data, null, 2), "application/json");
|
| 987 |
+
return;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
if (kind === "md") {
|
| 991 |
+
const parts = [];
|
| 992 |
+
list.forEach((job) => {
|
| 993 |
+
parts.push("# " + job.filename + "\n");
|
| 994 |
+
orderedPages(job).forEach((entry) => {
|
| 995 |
+
parts.push("## Page " + entry.pageNo + "\n");
|
| 996 |
+
parts.push((entry.text || "_(no text)_") + "\n");
|
| 997 |
+
});
|
| 998 |
+
});
|
| 999 |
+
download(baseName(list) + ".md", parts.join("\n"), "text/markdown");
|
| 1000 |
+
return;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
// txt
|
| 1004 |
+
const parts = [];
|
| 1005 |
+
list.forEach((job) => {
|
| 1006 |
+
parts.push("===== " + job.filename + " =====\n");
|
| 1007 |
+
orderedPages(job).forEach((entry) => {
|
| 1008 |
+
parts.push("--- Page " + entry.pageNo + " ---");
|
| 1009 |
+
parts.push((entry.text || "") + "\n");
|
| 1010 |
+
});
|
| 1011 |
+
});
|
| 1012 |
+
download(baseName(list) + ".txt", parts.join("\n"), "text/plain");
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
async function exportDocx(list) {
|
| 1016 |
+
const documents = list.map((job) => ({
|
| 1017 |
+
filename: job.filename,
|
| 1018 |
+
pages: orderedPages(job).map((entry) => ({
|
| 1019 |
+
page: entry.pageNo,
|
| 1020 |
+
source: entry.data ? entry.data.source : null,
|
| 1021 |
+
confidence: entry.data ? entry.data.confidence : null,
|
| 1022 |
+
text: entry.text || "",
|
| 1023 |
+
})),
|
| 1024 |
+
}));
|
| 1025 |
+
try {
|
| 1026 |
+
const res = await fetch("/api/export/docx", {
|
| 1027 |
+
method: "POST",
|
| 1028 |
+
headers: { "Content-Type": "application/json" },
|
| 1029 |
+
body: JSON.stringify({ documents }),
|
| 1030 |
+
});
|
| 1031 |
+
if (!res.ok) {
|
| 1032 |
+
showToast("Word export failed.");
|
| 1033 |
+
return;
|
| 1034 |
+
}
|
| 1035 |
+
const blob = await res.blob();
|
| 1036 |
+
const url = URL.createObjectURL(blob);
|
| 1037 |
+
const a = document.createElement("a");
|
| 1038 |
+
a.href = url;
|
| 1039 |
+
a.download = (list.length === 1 ? baseName(list) : "extraction") + ".docx";
|
| 1040 |
+
document.body.appendChild(a);
|
| 1041 |
+
a.click();
|
| 1042 |
+
document.body.removeChild(a);
|
| 1043 |
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 1044 |
+
showToast("Word document downloaded");
|
| 1045 |
+
} catch (e) {
|
| 1046 |
+
showToast("Word export failed.");
|
| 1047 |
+
}
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
function baseName(list) {
|
| 1051 |
+
if (list.length === 1) {
|
| 1052 |
+
return list[0].filename.replace(/\.pdf$/i, "") || "extraction";
|
| 1053 |
+
}
|
| 1054 |
+
return "extraction";
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
function download(filename, content, mime) {
|
| 1058 |
+
const blob = new Blob([content], { type: mime + ";charset=utf-8" });
|
| 1059 |
+
const url = URL.createObjectURL(blob);
|
| 1060 |
+
const a = document.createElement("a");
|
| 1061 |
+
a.href = url;
|
| 1062 |
+
a.download = filename;
|
| 1063 |
+
document.body.appendChild(a);
|
| 1064 |
+
a.click();
|
| 1065 |
+
document.body.removeChild(a);
|
| 1066 |
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
// ---------- Ordering helpers ----------
|
| 1070 |
+
function orderedJobs() {
|
| 1071 |
+
const out = [];
|
| 1072 |
+
jobOrder.forEach((id) => {
|
| 1073 |
+
const j = jobs.get(id);
|
| 1074 |
+
if (j) out.push(j);
|
| 1075 |
+
});
|
| 1076 |
+
return out;
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
function orderedPages(job) {
|
| 1080 |
+
return Array.from(job.pages.values()).sort((a, b) => a.pageNo - b.pageNo);
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
function refreshExportState() {
|
| 1084 |
+
// Export/copy always available once there is at least one job; nothing to gate.
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
// ---------- Misc ----------
|
| 1088 |
+
function humanizeError(detail) {
|
| 1089 |
+
const d = String(detail).toLowerCase();
|
| 1090 |
+
if (d.includes("encrypt") || d.includes("password") || d.includes("needs_pass")) {
|
| 1091 |
+
return "This PDF is encrypted / password-protected and cannot be processed.";
|
| 1092 |
+
}
|
| 1093 |
+
if (d.includes("corrupt") || d.includes("cannot open") || d.includes("damaged")) {
|
| 1094 |
+
return "This file appears to be corrupt or is not a valid PDF.";
|
| 1095 |
+
}
|
| 1096 |
+
return String(detail);
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
let toastTimer = null;
|
| 1100 |
+
function showToast(msg) {
|
| 1101 |
+
toast.textContent = msg;
|
| 1102 |
+
toast.hidden = false;
|
| 1103 |
+
// force reflow so the transition runs
|
| 1104 |
+
void toast.offsetWidth;
|
| 1105 |
+
toast.classList.add("show");
|
| 1106 |
+
clearTimeout(toastTimer);
|
| 1107 |
+
toastTimer = setTimeout(() => {
|
| 1108 |
+
toast.classList.remove("show");
|
| 1109 |
+
setTimeout(() => {
|
| 1110 |
+
toast.hidden = true;
|
| 1111 |
+
}, 220);
|
| 1112 |
+
}, 1800);
|
| 1113 |
+
}
|
| 1114 |
+
})();
|
static/index.html
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Local PDF Extractor</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<header class="app-header">
|
| 11 |
+
<div class="brand">
|
| 12 |
+
<div class="brand-mark" aria-hidden="true">
|
| 13 |
+
<svg viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="logo">
|
| 14 |
+
<rect x="5" y="3" width="18" height="26" rx="3" fill="currentColor" opacity="0.15"></rect>
|
| 15 |
+
<rect x="5" y="3" width="18" height="26" rx="3" fill="none" stroke="currentColor" stroke-width="2"></rect>
|
| 16 |
+
<line x1="9" y1="11" x2="19" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
| 17 |
+
<line x1="9" y1="16" x2="19" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
| 18 |
+
<line x1="9" y1="21" x2="15" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
| 19 |
+
</svg>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="brand-text">
|
| 22 |
+
<h1>Local PDF Extractor</h1>
|
| 23 |
+
<p class="tagline">Extract text from typed, scanned & handwritten PDFs</p>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
</header>
|
| 27 |
+
|
| 28 |
+
<main class="layout">
|
| 29 |
+
<!-- Sidebar: settings + drop zone -->
|
| 30 |
+
<aside class="sidebar">
|
| 31 |
+
<section class="panel">
|
| 32 |
+
<h2 class="panel-title">Settings</h2>
|
| 33 |
+
|
| 34 |
+
<div class="setting">
|
| 35 |
+
<label class="setting-label">Accuracy</label>
|
| 36 |
+
<div class="segmented" id="modeToggle" role="radiogroup" aria-label="Accuracy">
|
| 37 |
+
<button type="button" class="seg active" data-mode="max" role="radio" aria-checked="true">Max accuracy</button>
|
| 38 |
+
<button type="button" class="seg" data-mode="fast" role="radio" aria-checked="false">Faster</button>
|
| 39 |
+
</div>
|
| 40 |
+
<p class="hint" id="modeHint">Max accuracy is slower but renders at higher DPI and uses angle correction.</p>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div class="setting">
|
| 44 |
+
<label class="setting-label" for="langSelect">Language</label>
|
| 45 |
+
<select id="langSelect" class="select">
|
| 46 |
+
<option value="en" selected>English</option>
|
| 47 |
+
<option value="hi">Hindi (Devanagari)</option>
|
| 48 |
+
</select>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div class="setting-group-label">Image & pages</div>
|
| 52 |
+
|
| 53 |
+
<div class="setting setting-row">
|
| 54 |
+
<div>
|
| 55 |
+
<label class="setting-label" for="preprocessToggle">Preprocessing</label>
|
| 56 |
+
<p class="hint">Deskew, contrast-boost & denoise before OCR. Best left on.</p>
|
| 57 |
+
</div>
|
| 58 |
+
<label class="switch">
|
| 59 |
+
<input type="checkbox" id="preprocessToggle" checked />
|
| 60 |
+
<span class="slider" aria-hidden="true"></span>
|
| 61 |
+
</label>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="setting setting-row">
|
| 65 |
+
<div>
|
| 66 |
+
<label class="setting-label" for="binarizeToggle">Binarize</label>
|
| 67 |
+
<p class="hint">Force black-and-white. Helps some clean/high-contrast scans; usually leave off (grayscale is more accurate on most documents).</p>
|
| 68 |
+
</div>
|
| 69 |
+
<label class="switch">
|
| 70 |
+
<input type="checkbox" id="binarizeToggle" />
|
| 71 |
+
<span class="slider" aria-hidden="true"></span>
|
| 72 |
+
</label>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="setting setting-row">
|
| 76 |
+
<div>
|
| 77 |
+
<label class="setting-label" for="forceOcrToggle">Force OCR</label>
|
| 78 |
+
<p class="hint">Re-read every page as an image, even pages that already have selectable text. Use only when the embedded text is wrong/garbled, or text is trapped inside images. Slower; off by default.</p>
|
| 79 |
+
</div>
|
| 80 |
+
<label class="switch">
|
| 81 |
+
<input type="checkbox" id="forceOcrToggle" />
|
| 82 |
+
<span class="slider" aria-hidden="true"></span>
|
| 83 |
+
</label>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div class="setting setting-row">
|
| 87 |
+
<div>
|
| 88 |
+
<label class="setting-label" for="removeHeadersToggle">Remove headers & footers</label>
|
| 89 |
+
<p class="hint">Strip repeating page headers, footers & page numbers from multi-page documents (3+ pages). On by default; turn off to keep every line exactly as on the page.</p>
|
| 90 |
+
</div>
|
| 91 |
+
<label class="switch">
|
| 92 |
+
<input type="checkbox" id="removeHeadersToggle" checked />
|
| 93 |
+
<span class="slider" aria-hidden="true"></span>
|
| 94 |
+
</label>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div class="setting-group-label">OCR engine</div>
|
| 98 |
+
|
| 99 |
+
<div class="setting setting-row">
|
| 100 |
+
<div>
|
| 101 |
+
<label class="setting-label" for="handwritingToggle">Handwriting (local) <span class="beta-tag">beta</span></label>
|
| 102 |
+
<p class="hint">Read handwritten pages with a local model (English, ~73% on real cursive). Much slower; first use downloads a model once. For far better handwriting, use Online Gemini below instead — you don't need both.</p>
|
| 103 |
+
</div>
|
| 104 |
+
<label class="switch">
|
| 105 |
+
<input type="checkbox" id="handwritingToggle" />
|
| 106 |
+
<span class="slider" aria-hidden="true"></span>
|
| 107 |
+
</label>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div class="setting setting-row">
|
| 111 |
+
<div>
|
| 112 |
+
<label class="setting-label" for="onlineToggle">Online vision OCR (Gemini)</label>
|
| 113 |
+
<p class="hint">Far higher accuracy on handwriting & hard scans, via Google Gemini.
|
| 114 |
+
<strong>Sends your page images to Google</strong> — off by default. Needs a free API key.</p>
|
| 115 |
+
</div>
|
| 116 |
+
<label class="switch">
|
| 117 |
+
<input type="checkbox" id="onlineToggle" />
|
| 118 |
+
<span class="slider" aria-hidden="true"></span>
|
| 119 |
+
</label>
|
| 120 |
+
</div>
|
| 121 |
+
<div class="setting setting-sub" id="onlineRow" hidden>
|
| 122 |
+
<label class="setting-label" for="onlineKey">Gemini API key</label>
|
| 123 |
+
<input type="password" id="onlineKey" class="select online-input" placeholder="paste key from aistudio.google.com" autocomplete="off" spellcheck="false" />
|
| 124 |
+
<div class="online-key-actions">
|
| 125 |
+
<button type="button" class="btn btn-ghost btn-sm" id="onlineCheck">Check key</button>
|
| 126 |
+
<span class="online-status" id="onlineStatus" data-role="onlineStatus"></span>
|
| 127 |
+
</div>
|
| 128 |
+
<label class="setting-label" for="onlineModel" style="margin-top:8px;">Model</label>
|
| 129 |
+
<select id="onlineModel" class="select"></select>
|
| 130 |
+
<p class="hint">Your key is stored only in this browser and sent with each upload — never saved on the server.
|
| 131 |
+
Pages with a real text layer are still read locally (not sent).</p>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div class="setting-group-label">Tools</div>
|
| 135 |
+
|
| 136 |
+
<div class="setting">
|
| 137 |
+
<button type="button" class="btn btn-ghost" id="suggestBtn">Suggest best settings</button>
|
| 138 |
+
<p class="hint">Samples 1–2 pages of the next file you pick and recommends settings.
|
| 139 |
+
Opt-in · can take up to a minute on the CPU · nothing is applied until you click Apply.</p>
|
| 140 |
+
<input type="file" id="suggestInput" accept="application/pdf,.pdf" hidden />
|
| 141 |
+
</div>
|
| 142 |
+
<div class="setting" id="suggestResult" hidden>
|
| 143 |
+
<div class="suggest-card">
|
| 144 |
+
<p class="suggest-rationale" data-role="suggestRationale"></p>
|
| 145 |
+
<div class="suggest-evidence" data-role="suggestEvidence"></div>
|
| 146 |
+
<button type="button" class="btn btn-primary btn-sm" id="suggestApply">Apply recommended</button>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
</section>
|
| 150 |
+
|
| 151 |
+
<section
|
| 152 |
+
class="dropzone"
|
| 153 |
+
id="dropzone"
|
| 154 |
+
tabindex="0"
|
| 155 |
+
role="button"
|
| 156 |
+
aria-label="Drop PDF files here or click to browse">
|
| 157 |
+
<svg viewBox="0 0 24 24" width="40" height="40" aria-hidden="true" class="dz-icon">
|
| 158 |
+
<path d="M12 16V4m0 0l-4 4m4-4l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
| 159 |
+
<path d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
| 160 |
+
</svg>
|
| 161 |
+
<p class="dz-primary">Drop PDF files here</p>
|
| 162 |
+
<p class="dz-secondary">or <span class="dz-link">browse</span> · multiple files supported</p>
|
| 163 |
+
<input type="file" id="fileInput" accept="application/pdf,.pdf" multiple hidden />
|
| 164 |
+
</section>
|
| 165 |
+
|
| 166 |
+
<section class="notes" aria-label="Honesty notes">
|
| 167 |
+
<h3 class="notes-title">Good to know</h3>
|
| 168 |
+
<ul>
|
| 169 |
+
<li>Typed / born-digital pages are extracted instantly and exactly — no OCR.</li>
|
| 170 |
+
<li>OCR accuracy depends on scan quality; handwriting is the hardest.</li>
|
| 171 |
+
<li>For the best handwriting, use Online Gemini; everything else runs on your machine.</li>
|
| 172 |
+
<li>The first local-OCR run downloads model weights once, then caches them.</li>
|
| 173 |
+
</ul>
|
| 174 |
+
</section>
|
| 175 |
+
</aside>
|
| 176 |
+
|
| 177 |
+
<!-- Main work area -->
|
| 178 |
+
<section class="work">
|
| 179 |
+
<div class="toolbar" id="toolbar" hidden>
|
| 180 |
+
<div class="search-wrap">
|
| 181 |
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" class="search-icon">
|
| 182 |
+
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"></circle>
|
| 183 |
+
<line x1="16.5" y1="16.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round"></line>
|
| 184 |
+
</svg>
|
| 185 |
+
<input type="search" id="searchInput" class="search-input" placeholder="Search across all extracted text…" autocomplete="off" />
|
| 186 |
+
<span class="search-count" id="searchCount" hidden></span>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="toolbar-actions">
|
| 189 |
+
<button type="button" class="btn btn-ghost" id="copyAllBtn">Copy all</button>
|
| 190 |
+
<div class="export-group">
|
| 191 |
+
<button type="button" class="btn btn-ghost" data-export="txt">.txt</button>
|
| 192 |
+
<button type="button" class="btn btn-ghost" data-export="md">.md</button>
|
| 193 |
+
<button type="button" class="btn btn-ghost" data-export="docx">.docx</button>
|
| 194 |
+
<button type="button" class="btn btn-ghost" data-export="json">.json</button>
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
<div class="empty-state" id="emptyState">
|
| 200 |
+
<svg viewBox="0 0 64 64" width="72" height="72" aria-hidden="true">
|
| 201 |
+
<rect x="14" y="8" width="36" height="48" rx="4" fill="none" stroke="currentColor" stroke-width="2.5"></rect>
|
| 202 |
+
<line x1="22" y1="22" x2="42" y2="22" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></line>
|
| 203 |
+
<line x1="22" y1="30" x2="42" y2="30" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></line>
|
| 204 |
+
<line x1="22" y1="38" x2="34" y2="38" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></line>
|
| 205 |
+
</svg>
|
| 206 |
+
<h2>No documents yet</h2>
|
| 207 |
+
<p>Drop one or more PDFs to extract their text.</p>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<div id="filesContainer" class="files-container"></div>
|
| 211 |
+
</section>
|
| 212 |
+
</main>
|
| 213 |
+
|
| 214 |
+
<!-- Templates -->
|
| 215 |
+
<template id="fileCardTemplate">
|
| 216 |
+
<article class="file-card">
|
| 217 |
+
<div class="file-head">
|
| 218 |
+
<div class="file-meta">
|
| 219 |
+
<span class="status-dot" data-role="statusDot"></span>
|
| 220 |
+
<h3 class="file-name" data-role="fileName"></h3>
|
| 221 |
+
</div>
|
| 222 |
+
<div class="file-head-right">
|
| 223 |
+
<span class="status-text" data-role="statusText">Pending</span>
|
| 224 |
+
<button type="button" class="btn btn-danger btn-sm" data-role="cancelBtn">Cancel</button>
|
| 225 |
+
<button type="button" class="btn btn-ghost btn-sm btn-remove" data-role="removeBtn" title="Remove this file" aria-label="Remove this file">×</button>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
<div class="progress-row">
|
| 229 |
+
<div class="progress-track"><div class="progress-fill" data-role="progressFill"></div></div>
|
| 230 |
+
<span class="progress-label" data-role="progressLabel">0 / 0</span>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="file-error" data-role="fileError" hidden></div>
|
| 233 |
+
<div class="pages" data-role="pages"></div>
|
| 234 |
+
</article>
|
| 235 |
+
</template>
|
| 236 |
+
|
| 237 |
+
<template id="pageCardTemplate">
|
| 238 |
+
<div class="page-card" data-role="pageCard">
|
| 239 |
+
<div class="page-head">
|
| 240 |
+
<span class="page-num" data-role="pageNum"></span>
|
| 241 |
+
<span class="badge" data-role="badge"></span>
|
| 242 |
+
<span class="to-check" data-role="toCheck" hidden></span>
|
| 243 |
+
<span class="page-spacer"></span>
|
| 244 |
+
<button type="button" class="btn btn-ghost btn-sm" data-role="toggleImg">Show original page</button>
|
| 245 |
+
<button type="button" class="btn btn-ghost btn-sm" data-role="editBtn">Edit</button>
|
| 246 |
+
<button type="button" class="btn btn-ghost btn-sm" data-role="copyPage">Copy</button>
|
| 247 |
+
</div>
|
| 248 |
+
<pre class="page-text" data-role="pageText"></pre>
|
| 249 |
+
<textarea class="page-edit" data-role="pageEdit" spellcheck="false" hidden></textarea>
|
| 250 |
+
<div class="page-image-wrap" data-role="imageWrap" hidden></div>
|
| 251 |
+
</div>
|
| 252 |
+
</template>
|
| 253 |
+
|
| 254 |
+
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
|
| 255 |
+
|
| 256 |
+
<script src="/static/app.js"></script>
|
| 257 |
+
</body>
|
| 258 |
+
</html>
|
static/styles.css
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #0f1115;
|
| 3 |
+
--bg-soft: #161a22;
|
| 4 |
+
--panel: #1b212c;
|
| 5 |
+
--panel-2: #212835;
|
| 6 |
+
--border: #2a3342;
|
| 7 |
+
--border-soft: #232b38;
|
| 8 |
+
--text: #e7ecf3;
|
| 9 |
+
--text-dim: #9aa6b8;
|
| 10 |
+
--text-faint: #6b7686;
|
| 11 |
+
--accent: #6c8cff;
|
| 12 |
+
--accent-2: #5a78f0;
|
| 13 |
+
--green: #2ecc8f;
|
| 14 |
+
--green-soft: rgba(46, 204, 143, 0.15);
|
| 15 |
+
--amber: #f5a623;
|
| 16 |
+
--amber-soft: rgba(245, 166, 35, 0.15);
|
| 17 |
+
--red: #ff5d6c;
|
| 18 |
+
--red-soft: rgba(255, 93, 108, 0.13);
|
| 19 |
+
--radius: 12px;
|
| 20 |
+
--radius-sm: 8px;
|
| 21 |
+
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
| 22 |
+
--mono: "SFMono-Regular", "Consolas", "Liberation Mono", Menlo, monospace;
|
| 23 |
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
* { box-sizing: border-box; }
|
| 27 |
+
|
| 28 |
+
html, body {
|
| 29 |
+
margin: 0;
|
| 30 |
+
padding: 0;
|
| 31 |
+
background: var(--bg);
|
| 32 |
+
color: var(--text);
|
| 33 |
+
font-family: var(--sans);
|
| 34 |
+
font-size: 15px;
|
| 35 |
+
line-height: 1.5;
|
| 36 |
+
-webkit-font-smoothing: antialiased;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
body {
|
| 40 |
+
min-height: 100vh;
|
| 41 |
+
display: flex;
|
| 42 |
+
flex-direction: column;
|
| 43 |
+
background:
|
| 44 |
+
radial-gradient(1200px 600px at 90% -10%, rgba(108, 140, 255, 0.08), transparent 60%),
|
| 45 |
+
radial-gradient(900px 500px at -10% 110%, rgba(46, 204, 143, 0.06), transparent 55%),
|
| 46 |
+
var(--bg);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
h1, h2, h3 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
| 50 |
+
|
| 51 |
+
/* ---------- Header ---------- */
|
| 52 |
+
.app-header {
|
| 53 |
+
display: flex;
|
| 54 |
+
align-items: center;
|
| 55 |
+
justify-content: space-between;
|
| 56 |
+
padding: 16px 24px;
|
| 57 |
+
border-bottom: 1px solid var(--border-soft);
|
| 58 |
+
background: rgba(15, 17, 21, 0.7);
|
| 59 |
+
backdrop-filter: blur(8px);
|
| 60 |
+
position: sticky;
|
| 61 |
+
top: 0;
|
| 62 |
+
z-index: 20;
|
| 63 |
+
}
|
| 64 |
+
.brand { display: flex; align-items: center; gap: 14px; }
|
| 65 |
+
.brand-mark { color: var(--accent); display: flex; }
|
| 66 |
+
.brand-text h1 { font-size: 19px; }
|
| 67 |
+
.tagline { margin: 2px 0 0; font-size: 12.5px; color: var(--text-dim); }
|
| 68 |
+
.offline-pill {
|
| 69 |
+
font-size: 12px;
|
| 70 |
+
font-weight: 600;
|
| 71 |
+
color: var(--green);
|
| 72 |
+
background: var(--green-soft);
|
| 73 |
+
border: 1px solid rgba(46, 204, 143, 0.3);
|
| 74 |
+
padding: 5px 12px;
|
| 75 |
+
border-radius: 999px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* ---------- Layout ---------- */
|
| 79 |
+
.layout {
|
| 80 |
+
display: grid;
|
| 81 |
+
grid-template-columns: 320px 1fr;
|
| 82 |
+
gap: 24px;
|
| 83 |
+
padding: 24px;
|
| 84 |
+
flex: 1;
|
| 85 |
+
max-width: 1400px;
|
| 86 |
+
width: 100%;
|
| 87 |
+
margin: 0 auto;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* ---------- Sidebar ---------- */
|
| 91 |
+
.sidebar { display: flex; flex-direction: column; gap: 18px; }
|
| 92 |
+
.panel {
|
| 93 |
+
background: var(--panel);
|
| 94 |
+
border: 1px solid var(--border);
|
| 95 |
+
border-radius: var(--radius);
|
| 96 |
+
padding: 18px;
|
| 97 |
+
}
|
| 98 |
+
.panel-title { font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); margin-bottom: 14px; }
|
| 99 |
+
|
| 100 |
+
.setting { margin-bottom: 18px; }
|
| 101 |
+
.setting:last-child { margin-bottom: 0; }
|
| 102 |
+
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
| 103 |
+
.setting-label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--text); }
|
| 104 |
+
.hint { margin: 6px 0 0; font-size: 12px; color: var(--text-faint); line-height: 1.45; }
|
| 105 |
+
|
| 106 |
+
.segmented {
|
| 107 |
+
display: flex;
|
| 108 |
+
background: var(--bg-soft);
|
| 109 |
+
border: 1px solid var(--border);
|
| 110 |
+
border-radius: var(--radius-sm);
|
| 111 |
+
padding: 3px;
|
| 112 |
+
gap: 3px;
|
| 113 |
+
}
|
| 114 |
+
.seg {
|
| 115 |
+
flex: 1;
|
| 116 |
+
border: none;
|
| 117 |
+
background: transparent;
|
| 118 |
+
color: var(--text-dim);
|
| 119 |
+
font: inherit;
|
| 120 |
+
font-size: 13px;
|
| 121 |
+
font-weight: 600;
|
| 122 |
+
padding: 7px 8px;
|
| 123 |
+
border-radius: 6px;
|
| 124 |
+
cursor: pointer;
|
| 125 |
+
transition: background 0.15s, color 0.15s;
|
| 126 |
+
}
|
| 127 |
+
.seg:hover { color: var(--text); }
|
| 128 |
+
.seg.active { background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(108, 140, 255, 0.35); }
|
| 129 |
+
|
| 130 |
+
.select {
|
| 131 |
+
width: 100%;
|
| 132 |
+
background: var(--bg-soft);
|
| 133 |
+
border: 1px solid var(--border);
|
| 134 |
+
color: var(--text);
|
| 135 |
+
font: inherit;
|
| 136 |
+
font-size: 14px;
|
| 137 |
+
padding: 9px 11px;
|
| 138 |
+
border-radius: var(--radius-sm);
|
| 139 |
+
cursor: pointer;
|
| 140 |
+
}
|
| 141 |
+
.select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 142 |
+
|
| 143 |
+
/* switch */
|
| 144 |
+
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex: none; }
|
| 145 |
+
.switch input { opacity: 0; width: 0; height: 0; }
|
| 146 |
+
.slider {
|
| 147 |
+
position: absolute; inset: 0; cursor: pointer;
|
| 148 |
+
background: var(--border); border-radius: 999px; transition: background 0.18s;
|
| 149 |
+
}
|
| 150 |
+
.slider::before {
|
| 151 |
+
content: ""; position: absolute; height: 18px; width: 18px; left: 3px; top: 3px;
|
| 152 |
+
background: #fff; border-radius: 50%; transition: transform 0.18s;
|
| 153 |
+
}
|
| 154 |
+
.switch input:checked + .slider { background: var(--accent); }
|
| 155 |
+
.switch input:checked + .slider::before { transform: translateX(20px); }
|
| 156 |
+
.switch input:focus + .slider { box-shadow: 0 0 0 2px var(--accent); }
|
| 157 |
+
|
| 158 |
+
/* dropzone */
|
| 159 |
+
.dropzone {
|
| 160 |
+
border: 2px dashed var(--border);
|
| 161 |
+
border-radius: var(--radius);
|
| 162 |
+
padding: 28px 18px;
|
| 163 |
+
text-align: center;
|
| 164 |
+
cursor: pointer;
|
| 165 |
+
transition: border-color 0.18s, background 0.18s, transform 0.05s;
|
| 166 |
+
color: var(--text-dim);
|
| 167 |
+
background: var(--panel);
|
| 168 |
+
}
|
| 169 |
+
.dropzone:hover { border-color: var(--accent); background: var(--panel-2); }
|
| 170 |
+
.dropzone:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
| 171 |
+
.dropzone.dragover { border-color: var(--accent); background: rgba(108, 140, 255, 0.08); transform: scale(1.01); }
|
| 172 |
+
.dz-icon { color: var(--accent); margin-bottom: 8px; }
|
| 173 |
+
.dz-primary { margin: 0; font-weight: 600; color: var(--text); font-size: 15px; }
|
| 174 |
+
.dz-secondary { margin: 6px 0 0; font-size: 13px; }
|
| 175 |
+
.dz-link { color: var(--accent); text-decoration: underline; }
|
| 176 |
+
|
| 177 |
+
/* notes */
|
| 178 |
+
.notes {
|
| 179 |
+
background: var(--panel);
|
| 180 |
+
border: 1px solid var(--border);
|
| 181 |
+
border-radius: var(--radius);
|
| 182 |
+
padding: 16px 18px;
|
| 183 |
+
}
|
| 184 |
+
.notes-title { font-size: 13px; color: var(--text-dim); margin-bottom: 10px; }
|
| 185 |
+
.notes ul { margin: 0; padding-left: 18px; }
|
| 186 |
+
.notes li { font-size: 12.5px; color: var(--text-faint); margin-bottom: 6px; line-height: 1.5; }
|
| 187 |
+
|
| 188 |
+
/* ---------- Work area ---------- */
|
| 189 |
+
.work { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
|
| 190 |
+
|
| 191 |
+
.toolbar {
|
| 192 |
+
display: flex;
|
| 193 |
+
align-items: center;
|
| 194 |
+
gap: 14px;
|
| 195 |
+
flex-wrap: wrap;
|
| 196 |
+
background: var(--panel);
|
| 197 |
+
border: 1px solid var(--border);
|
| 198 |
+
border-radius: var(--radius);
|
| 199 |
+
padding: 12px 14px;
|
| 200 |
+
position: sticky;
|
| 201 |
+
top: 78px;
|
| 202 |
+
z-index: 10;
|
| 203 |
+
}
|
| 204 |
+
.search-wrap { position: relative; flex: 1; min-width: 220px; display: flex; align-items: center; }
|
| 205 |
+
.search-icon { position: absolute; left: 11px; color: var(--text-faint); pointer-events: none; }
|
| 206 |
+
.search-input {
|
| 207 |
+
width: 100%;
|
| 208 |
+
background: var(--bg-soft);
|
| 209 |
+
border: 1px solid var(--border);
|
| 210 |
+
color: var(--text);
|
| 211 |
+
font: inherit;
|
| 212 |
+
font-size: 14px;
|
| 213 |
+
padding: 9px 12px 9px 34px;
|
| 214 |
+
border-radius: var(--radius-sm);
|
| 215 |
+
}
|
| 216 |
+
.search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 217 |
+
.search-count { position: absolute; right: 12px; font-size: 12px; color: var(--text-faint); }
|
| 218 |
+
.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
| 219 |
+
.export-group { display: flex; gap: 4px; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px; }
|
| 220 |
+
|
| 221 |
+
/* buttons */
|
| 222 |
+
.btn {
|
| 223 |
+
border: 1px solid var(--border);
|
| 224 |
+
background: var(--panel-2);
|
| 225 |
+
color: var(--text);
|
| 226 |
+
font: inherit;
|
| 227 |
+
font-size: 13px;
|
| 228 |
+
font-weight: 600;
|
| 229 |
+
padding: 8px 13px;
|
| 230 |
+
border-radius: var(--radius-sm);
|
| 231 |
+
cursor: pointer;
|
| 232 |
+
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
| 233 |
+
}
|
| 234 |
+
.btn:hover { background: var(--border); }
|
| 235 |
+
.btn:active { transform: translateY(1px); }
|
| 236 |
+
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
| 237 |
+
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
| 238 |
+
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-dim); }
|
| 239 |
+
.btn-ghost:hover { background: var(--border-soft); color: var(--text); }
|
| 240 |
+
.export-group .btn-ghost { border: none; }
|
| 241 |
+
.btn-danger { color: var(--red); border-color: rgba(255, 93, 108, 0.4); background: transparent; }
|
| 242 |
+
.btn-danger:hover { background: var(--red-soft); }
|
| 243 |
+
|
| 244 |
+
/* empty state */
|
| 245 |
+
/* The HTML `hidden` attribute must always win — otherwise an explicit
|
| 246 |
+
`display:` rule (e.g. .empty-state below) keeps a "hidden" element visible. */
|
| 247 |
+
[hidden] { display: none !important; }
|
| 248 |
+
|
| 249 |
+
.empty-state {
|
| 250 |
+
display: flex;
|
| 251 |
+
flex-direction: column;
|
| 252 |
+
align-items: center;
|
| 253 |
+
text-align: center;
|
| 254 |
+
padding: 80px 24px;
|
| 255 |
+
color: var(--text-faint);
|
| 256 |
+
border: 1px dashed var(--border);
|
| 257 |
+
border-radius: var(--radius);
|
| 258 |
+
}
|
| 259 |
+
.empty-state svg { color: var(--border); margin-bottom: 16px; }
|
| 260 |
+
.empty-state h2 { font-size: 18px; color: var(--text-dim); margin-bottom: 6px; }
|
| 261 |
+
.empty-state p { margin: 0; max-width: 360px; font-size: 14px; }
|
| 262 |
+
|
| 263 |
+
/* files */
|
| 264 |
+
.files-container { display: flex; flex-direction: column; gap: 18px; }
|
| 265 |
+
.file-card {
|
| 266 |
+
background: var(--panel);
|
| 267 |
+
border: 1px solid var(--border);
|
| 268 |
+
border-radius: var(--radius);
|
| 269 |
+
overflow: hidden;
|
| 270 |
+
}
|
| 271 |
+
.file-head {
|
| 272 |
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
| 273 |
+
padding: 14px 16px; border-bottom: 1px solid var(--border-soft);
|
| 274 |
+
}
|
| 275 |
+
.file-meta { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
| 276 |
+
.file-name { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 277 |
+
.file-head-right { display: flex; align-items: center; gap: 12px; flex: none; }
|
| 278 |
+
.status-text { font-size: 12.5px; color: var(--text-dim); }
|
| 279 |
+
.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--text-faint); flex: none; }
|
| 280 |
+
.status-dot.processing { background: var(--accent); animation: pulse 1.2s infinite; }
|
| 281 |
+
.status-dot.done { background: var(--green); }
|
| 282 |
+
.status-dot.error { background: var(--red); }
|
| 283 |
+
.status-dot.cancelled { background: var(--amber); }
|
| 284 |
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
| 285 |
+
|
| 286 |
+
.progress-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; }
|
| 287 |
+
.progress-track { flex: 1; height: 7px; background: var(--bg-soft); border-radius: 999px; overflow: hidden; }
|
| 288 |
+
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); border-radius: 999px; transition: width 0.3s ease; }
|
| 289 |
+
.progress-label { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; flex: none; }
|
| 290 |
+
|
| 291 |
+
.file-error {
|
| 292 |
+
margin: 0 16px 14px;
|
| 293 |
+
padding: 10px 12px;
|
| 294 |
+
background: var(--red-soft);
|
| 295 |
+
border: 1px solid rgba(255, 93, 108, 0.35);
|
| 296 |
+
border-radius: var(--radius-sm);
|
| 297 |
+
color: var(--red);
|
| 298 |
+
font-size: 13px;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
.pages { display: flex; flex-direction: column; gap: 12px; padding: 4px 16px 16px; }
|
| 302 |
+
|
| 303 |
+
/* page card */
|
| 304 |
+
.page-card {
|
| 305 |
+
border: 1px solid var(--border-soft);
|
| 306 |
+
border-radius: var(--radius-sm);
|
| 307 |
+
background: var(--bg-soft);
|
| 308 |
+
overflow: hidden;
|
| 309 |
+
}
|
| 310 |
+
.page-head { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--border-soft); }
|
| 311 |
+
.page-num { font-size: 13px; font-weight: 700; color: var(--text-dim); }
|
| 312 |
+
.page-spacer { flex: 1; }
|
| 313 |
+
.badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.03em; }
|
| 314 |
+
.badge.text { color: var(--green); background: var(--green-soft); border: 1px solid rgba(46, 204, 143, 0.35); }
|
| 315 |
+
.badge.ocr { color: var(--amber); background: var(--amber-soft); border: 1px solid rgba(245, 166, 35, 0.35); }
|
| 316 |
+
.badge.ocr.lowconf { color: var(--red); background: var(--red-soft); border-color: rgba(255, 93, 108, 0.35); }
|
| 317 |
+
.badge.handwriting { color: #b98cff; background: rgba(160, 120, 255, 0.15); border: 1px solid rgba(160, 120, 255, 0.4); }
|
| 318 |
+
.badge.online { color: #4fc3f7; background: rgba(79, 195, 247, 0.14); border: 1px solid rgba(79, 195, 247, 0.4); }
|
| 319 |
+
.beta-tag { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--accent); background: rgba(108,140,255,0.16); padding: 1px 5px; border-radius: 999px; vertical-align: middle; }
|
| 320 |
+
.badge.error { color: var(--red); background: var(--red-soft); border: 1px solid rgba(255, 93, 108, 0.35); }
|
| 321 |
+
.badge.pending { color: var(--text-faint); background: var(--border-soft); border: 1px solid var(--border); }
|
| 322 |
+
|
| 323 |
+
.page-text {
|
| 324 |
+
margin: 0;
|
| 325 |
+
padding: 14px 16px;
|
| 326 |
+
white-space: pre-wrap;
|
| 327 |
+
word-break: break-word;
|
| 328 |
+
font-family: var(--mono);
|
| 329 |
+
font-size: 13px;
|
| 330 |
+
line-height: 1.6;
|
| 331 |
+
color: var(--text);
|
| 332 |
+
max-height: 460px;
|
| 333 |
+
overflow: auto;
|
| 334 |
+
}
|
| 335 |
+
.page-text:empty::before { content: "(no text on this page)"; color: var(--text-faint); font-style: italic; }
|
| 336 |
+
.page-text mark { background: var(--amber); color: #1a1a1a; border-radius: 2px; padding: 0 1px; }
|
| 337 |
+
.page-text mark.active { background: #ffd86b; outline: 1px solid var(--amber); }
|
| 338 |
+
|
| 339 |
+
/* Low-confidence line highlight ("which lines to check") */
|
| 340 |
+
.lc-line { background: rgba(245, 166, 35, 0.16); border-bottom: 1px dashed rgba(245, 166, 35, 0.7); border-radius: 2px; }
|
| 341 |
+
|
| 342 |
+
/* "N to check" chip in the page header */
|
| 343 |
+
.to-check { font-size: 11px; font-weight: 600; color: var(--amber); background: var(--amber-soft); border: 1px solid rgba(245, 166, 35, 0.35); border-radius: 999px; padding: 2px 8px; white-space: nowrap; }
|
| 344 |
+
|
| 345 |
+
/* Inline edit box — matches the read-only text view */
|
| 346 |
+
.page-edit {
|
| 347 |
+
width: 100%; box-sizing: border-box; margin: 0;
|
| 348 |
+
padding: 14px 16px; min-height: 180px; max-height: 460px; resize: vertical;
|
| 349 |
+
white-space: pre-wrap; word-break: break-word;
|
| 350 |
+
font-family: var(--mono); font-size: 13px; line-height: 1.6;
|
| 351 |
+
color: var(--text); background: var(--bg-soft);
|
| 352 |
+
border: 1px solid var(--accent); border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
| 353 |
+
outline: none;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.page-image-wrap { padding: 12px 16px; border-top: 1px solid var(--border-soft); background: var(--panel); }
|
| 357 |
+
.page-image-wrap img { max-width: 100%; border-radius: 6px; border: 1px solid var(--border); display: block; }
|
| 358 |
+
.page-image-wrap .img-loading { color: var(--text-faint); font-size: 13px; }
|
| 359 |
+
|
| 360 |
+
/* toast */
|
| 361 |
+
.toast {
|
| 362 |
+
position: fixed;
|
| 363 |
+
bottom: 24px;
|
| 364 |
+
left: 50%;
|
| 365 |
+
transform: translateX(-50%) translateY(10px);
|
| 366 |
+
background: var(--panel-2);
|
| 367 |
+
border: 1px solid var(--border);
|
| 368 |
+
color: var(--text);
|
| 369 |
+
padding: 11px 18px;
|
| 370 |
+
border-radius: 999px;
|
| 371 |
+
font-size: 13.5px;
|
| 372 |
+
font-weight: 600;
|
| 373 |
+
box-shadow: var(--shadow);
|
| 374 |
+
opacity: 0;
|
| 375 |
+
transition: opacity 0.2s, transform 0.2s;
|
| 376 |
+
z-index: 50;
|
| 377 |
+
pointer-events: none;
|
| 378 |
+
}
|
| 379 |
+
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 380 |
+
|
| 381 |
+
/* responsive */
|
| 382 |
+
@media (max-width: 900px) {
|
| 383 |
+
.layout { grid-template-columns: 1fr; padding: 16px; gap: 18px; }
|
| 384 |
+
.toolbar { position: static; }
|
| 385 |
+
.sidebar { order: 2; }
|
| 386 |
+
.work { order: 1; }
|
| 387 |
+
}
|
| 388 |
+
@media (max-width: 520px) {
|
| 389 |
+
.app-header { padding: 12px 16px; }
|
| 390 |
+
.brand-text h1 { font-size: 17px; }
|
| 391 |
+
.toolbar-actions { width: 100%; justify-content: space-between; }
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* scrollbars */
|
| 395 |
+
.page-text::-webkit-scrollbar { width: 10px; height: 10px; }
|
| 396 |
+
.page-text::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 2px solid var(--bg-soft); }
|
| 397 |
+
.page-text::-webkit-scrollbar-track { background: transparent; }
|
| 398 |
+
|
| 399 |
+
/* Large-handwriting sub-setting: indented under the handwriting toggle */
|
| 400 |
+
.setting-sub { margin-left: 14px; padding-left: 10px; border-left: 2px solid var(--border-soft); }
|
| 401 |
+
|
| 402 |
+
/* "Suggest best settings" recommendation panel */
|
| 403 |
+
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
| 404 |
+
.btn-primary:hover { background: var(--accent-2); }
|
| 405 |
+
.suggest-card { margin-top: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-soft); }
|
| 406 |
+
.suggest-rationale { font-size: 12px; line-height: 1.45; margin: 0 0 8px; color: var(--text-dim); }
|
| 407 |
+
.suggest-evidence { font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-bottom: 8px; }
|
| 408 |
+
.ev-row { display: grid; grid-template-columns: 1.5fr 1fr 0.9fr 0.9fr; gap: 6px; padding: 2px 0; opacity: 0.75; }
|
| 409 |
+
.ev-row.best { opacity: 1; font-weight: 600; color: var(--text); }
|
| 410 |
+
|
| 411 |
+
/* Online (Gemini) settings */
|
| 412 |
+
.online-input { width: 100%; margin-bottom: 6px; font-family: ui-monospace, monospace; font-size: 12px; }
|
| 413 |
+
.online-key-actions { display: flex; align-items: center; gap: 8px; }
|
| 414 |
+
.online-status { font-size: 11px; }
|
| 415 |
+
.online-status.good { color: var(--green); }
|
| 416 |
+
.online-status.bad { color: var(--red); }
|
| 417 |
+
|
| 418 |
+
/* Remove (×) button on a file card */
|
| 419 |
+
.btn-remove { color: var(--text-dim); font-size: 18px; line-height: 1; padding: 3px 9px; font-weight: 700; }
|
| 420 |
+
.btn-remove:hover { color: var(--red); background: var(--red-soft); }
|
| 421 |
+
|
| 422 |
+
/* Settings group sub-headings */
|
| 423 |
+
.setting-group-label {
|
| 424 |
+
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
| 425 |
+
color: var(--text-faint); margin: 18px 0 4px; padding-top: 14px;
|
| 426 |
+
border-top: 1px solid var(--border-soft);
|
| 427 |
+
}
|