Archie0099 commited on
Commit
b9d34d8
·
verified ·
1 Parent(s): 2e8a821

Add PDF extractor app and Dockerfile

Browse files
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: gray
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &amp; 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 &amp; 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 &amp; 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 &amp; footers</label>
89
+ <p class="hint">Strip repeating page headers, footers &amp; 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 &mdash; 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 &amp; hard scans, via Google Gemini.
114
+ <strong>Sends your page images to Google</strong> &mdash; 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 &mdash; 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&ndash;2 pages of the next file you pick and recommends settings.
139
+ Opt-in &middot; can take up to a minute on the CPU &middot; 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> &middot; 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 &mdash; 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">&times;</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
+ }