Skip to content

Commit 343dbe4

Browse files
committed
feat: implement static PDF cascade with Tesseract OCR, PyMuPDF, and LLM context capping
1 parent 28328a5 commit 343dbe4

4 files changed

Lines changed: 260 additions & 60 deletions

File tree

api/routes/templates.py

Lines changed: 240 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -71,23 +71,17 @@ def get_template_by_id(template_id: int, db: Session = Depends(get_db)):
7171
return tpl
7272

7373

74-
# ── PyMuPDF-based deterministic field finder ───────────────────────────
74+
# ── PyMuPDF-based deterministic field finder ───────────────────────
7575
#
76-
# Approach (for static non-fillable PDFs with a text layer):
76+
# PATH 1 — Text-layer PDFs (typed Word docs, digital PDFs)
77+
# PATH 2 — Image-based PDFs (screenshots pasted into Word, scanned docs)
78+
# → Tesseract OCR on the rendered page image
79+
# PATH 3 — Final fallback (Gemma Vision, for exotic/complex cases)
7780
#
78-
# 1. PyMuPDF page.get_text("words") → word-level bboxes (x0,y0,x1,y1,text)
79-
# 2. Group words into visual lines by Y coordinate (3pt tolerance)
80-
# 3. Lines containing ":" or "____" are field lines
81-
# 4. Blank zone = underscore position OR right-edge of colon word + small gap
82-
# 5. Gemma (text-only) cleans raw label into snake_case — no vision needed
83-
# 6. Coordinates stored as percentages of page size in the DB
84-
# → filler converts back to absolute pts at render time
85-
#
86-
# PyMuPDF is used instead of pdfplumber because it is already installed
87-
# as part of the core stack (fitz) and avoids an optional dependency.
88-
#
89-
# Gemma Vision is ONLY used as a fallback when the PDF has NO text layer
90-
# (i.e. a flat image scan with no searchable text).
81+
# Gemma is used ONLY for:
82+
# a) Cleaning raw label text to snake_case (both paths)
83+
# b) Final fallback vision scan (path 3 only)
84+
# Gemma NEVER estimates coordinates.
9185

9286
def _scan_fields_with_pymupdf(pdf_path: str) -> list[dict]:
9387
"""
@@ -115,6 +109,191 @@ def _scan_fields_with_pymupdf(pdf_path: str) -> list[dict]:
115109
return found_fields
116110

117111

112+
def _scan_fields_with_tesseract(pdf_path: str, dpi: int = 200) -> list[dict]:
113+
"""
114+
PATH 2: Deterministic field detection for image-based static PDFs.
115+
116+
Renders each page to a raster image (via PyMuPDF) then runs Tesseract OCR
117+
with pytesseract.image_to_data() to get word-level bounding boxes.
118+
119+
Coordinate system:
120+
Tesseract returns pixel coordinates relative to the rendered image.
121+
We convert: px → PDF points via pt = px * (72 / dpi)
122+
Then store as percentages of page size (same as PyMuPDF path).
123+
124+
Uses the same colon / underscore field detection logic as the text-layer path,
125+
so a single downstream pipeline handles both PDF types identically.
126+
127+
Requires: pytesseract and Tesseract binary (v5+ recommended).
128+
"""
129+
import fitz
130+
import pytesseract
131+
from PIL import Image
132+
import io
133+
134+
# Point pytesseract at the Tesseract binary (Windows path)
135+
pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
136+
137+
found_fields = []
138+
doc = fitz.open(pdf_path)
139+
print(f"[TESSERACT] Opened '{pdf_path}' — {len(doc)} page(s) at {dpi} DPI")
140+
141+
for page_num in range(len(doc)):
142+
page = doc[page_num]
143+
pw_pt = page.rect.width # page width in PDF points
144+
ph_pt = page.rect.height # page height in PDF points
145+
146+
# Render page to image
147+
mat = fitz.Matrix(dpi / 72, dpi / 72)
148+
pix = page.get_pixmap(matrix=mat, alpha=False)
149+
img = Image.open(io.BytesIO(pix.tobytes("png")))
150+
img_w, img_h = img.size
151+
print(f"[TESSERACT] Page {page_num}: rendered {img_w}x{img_h}px (PDF: {pw_pt:.0f}x{ph_pt:.0f}pt)")
152+
153+
# Preprocess image for better OCR on low-quality/scanned PDFs
154+
# Grayscale + contrast enhancement helps Tesseract significantly
155+
from PIL import ImageOps, ImageFilter
156+
img_gray = img.convert("L") # grayscale
157+
img_enhanced = ImageOps.autocontrast(img_gray, cutoff=2) # boost contrast
158+
159+
# Run Tesseract OCR — returns word-level data
160+
ocr_data = pytesseract.image_to_data(
161+
img_enhanced,
162+
output_type=pytesseract.Output.DICT,
163+
config="--psm 6" # assume uniform block of text
164+
)
165+
166+
# Build word dicts, filtering out empty/low-confidence tokens
167+
# Tesseract bbox: left, top, width, height (all in pixels)
168+
words = []
169+
n = len(ocr_data["text"])
170+
for i in range(n):
171+
txt = ocr_data["text"][i].strip()
172+
conf = int(ocr_data["conf"][i])
173+
# conf == -1 → non-word token (block/para/line level), skip
174+
# conf >= 0 → actual OCR word (0=very uncertain, 100=certain)
175+
if not txt or conf < 0:
176+
continue
177+
px_left = ocr_data["left"][i]
178+
px_top = ocr_data["top"][i]
179+
px_w = ocr_data["width"][i]
180+
px_h = ocr_data["height"][i]
181+
182+
# Convert pixel coords → PDF points
183+
scale = 72.0 / dpi
184+
x0 = px_left * scale
185+
y0 = px_top * scale
186+
x1 = (px_left + px_w) * scale
187+
y1 = (px_top + px_h) * scale
188+
189+
words.append({"x0": x0, "y0": y0, "x1": x1, "y1": y1, "text": txt})
190+
191+
print(f"[TESSERACT] Page {page_num}: {len(words)} confident words")
192+
for w in words[:12]:
193+
print(f" word '{w['text']:20s}' x0={w['x0']:6.1f} y0={w['y0']:6.1f}")
194+
if len(words) > 12:
195+
print(f" ... and {len(words) - 12} more")
196+
197+
# ── Group words into visual lines (same logic as PyMuPDF path) ────
198+
Y_TOL = 4 # slightly larger tolerance for OCR jitter
199+
sorted_words = sorted(words, key=lambda w: (w["y0"], w["x0"]))
200+
201+
lines: list[list[dict]] = []
202+
current_line: list[dict] = []
203+
current_y: float | None = None
204+
205+
for w in sorted_words:
206+
if current_y is None or abs(w["y0"] - current_y) <= Y_TOL:
207+
current_line.append(w)
208+
if current_y is None:
209+
current_y = w["y0"]
210+
else:
211+
if current_line:
212+
lines.append(current_line)
213+
current_line = [w]
214+
current_y = w["y0"]
215+
if current_line:
216+
lines.append(current_line)
217+
218+
print(f"[TESSERACT] Grouped into {len(lines)} visual lines")
219+
for idx, lw in enumerate(lines):
220+
lt = " ".join(w["text"] for w in lw)
221+
print(f" line {idx:02d}: '{lt[:90]}'")
222+
223+
# ── Identical field detection logic as PyMuPDF path ─────────────
224+
for line_words in lines:
225+
line_words = sorted(line_words, key=lambda w: w["x0"])
226+
full_text = " ".join(w["text"] for w in line_words)
227+
228+
has_colon = ":" in full_text
229+
has_underscores = "_" * 4 in full_text or full_text.count("_") >= 4
230+
231+
if not (has_colon or has_underscores):
232+
continue
233+
234+
# Skip header lines with content after colon
235+
if has_colon and not has_underscores:
236+
colon_pos = full_text.find(":")
237+
after_colon = full_text[colon_pos + 1:].strip()
238+
if after_colon and not after_colon.startswith("_"):
239+
label_part = full_text[:colon_pos].strip().lower()
240+
HEADER_WORDS = {"title", "form", "section", "page", "date", "version", "revision"}
241+
if any(hw in label_part for hw in HEADER_WORDS):
242+
continue
243+
244+
label_text = full_text.strip()
245+
answer_x: float | None = None
246+
answer_y = min(w["y0"] for w in line_words)
247+
answer_bottom = max(w["y1"] for w in line_words)
248+
249+
if has_underscores:
250+
for w in line_words:
251+
if "_" * 4 in w["text"]:
252+
answer_x = w["x0"]
253+
answer_bottom = w["y1"]
254+
label_parts = [lw["text"] for lw in line_words if lw["x0"] < w["x0"]]
255+
if label_parts:
256+
label_text = " ".join(label_parts)
257+
break
258+
259+
if answer_x is None and has_colon:
260+
for w in line_words:
261+
if ":" in w["text"]:
262+
answer_x = w["x1"] + 2
263+
label_parts = []
264+
for lw in line_words:
265+
label_parts.append(lw["text"])
266+
if ":" in lw["text"]:
267+
break
268+
label_text = " ".join(label_parts)
269+
break
270+
271+
if answer_x is None:
272+
answer_x = line_words[-1]["x1"] + 2
273+
274+
clean_label = re.sub(r'^[\d\.\)\s]+', '', label_text)
275+
clean_label = clean_label.rstrip(': _\n').strip()
276+
277+
if not clean_label:
278+
continue
279+
280+
found_fields.append({
281+
"raw_label": clean_label,
282+
"answer_x": round(answer_x, 2),
283+
"answer_y": round(answer_y, 2),
284+
"answer_bottom": round(answer_bottom, 2),
285+
"page_num": page_num,
286+
"page_w": round(pw_pt, 2),
287+
"page_h": round(ph_pt, 2),
288+
"line_text": full_text.strip(),
289+
})
290+
print(f" [FIELD] '{clean_label:25s}' answer_x={answer_x:.1f}pt y0={answer_y:.1f}pt")
291+
292+
doc.close()
293+
print(f"[TESSERACT] Done — {len(found_fields)} field(s) found")
294+
return found_fields
295+
296+
118297
async def _clean_labels_with_gemma(raw_labels: list[str], image_bytes: bytes) -> list[str]:
119298
"""
120299
Optional: Ask Gemma to convert raw label strings to clean snake_case names.
@@ -195,7 +374,7 @@ async def scan_static_template(template_id: int, db: Session = Depends(get_db)):
195374
Scan Once → Fill Forever.
196375
197376
For PDFs WITH a text layer:
198-
1. pdfplumber extracts word-level coordinates (deterministic)
377+
1. PyMuPDF extracts word-level coordinates (deterministic)
199378
2. We find field lines (colon/underscore patterns)
200379
3. Gemma cleans up the label names (semantic)
201380
4. Absolute point coordinates are saved to FormFieldCoordinates
@@ -238,67 +417,73 @@ async def scan_static_template(template_id: int, db: Session = Depends(get_db)):
238417
has_text_layer = word_count > 5
239418
print(f"[SCAN] {page_w:.0f}×{page_h:.0f}pt | words={word_count} | text_layer={has_text_layer}")
240419

420+
raw_fields: list[dict] = []
241421
found_fields: list[dict] = []
242422
scan_mode = "unknown"
243423

244-
# ── PRIMARY PATH: PyMuPDF deterministic scan ─────────────────────────
424+
# ── PATH 1: PyMuPDF — text-layer PDFs ────────────────────────────────
245425
if has_text_layer:
246426
scan_mode = "pymupdf"
247-
print("[SCAN] Using PyMuPDF deterministic word-level scan...")
248-
427+
print("[SCAN] PATH 1: PyMuPDF deterministic word-level scan...")
249428
try:
250429
raw_fields = _scan_fields_with_pymupdf(tpl.pdf_path)
251430
except Exception as e:
252-
print(f"[SCAN] ⚠️ PyMuPDF scan crashed: {e}")
253-
import traceback
254-
traceback.print_exc()
431+
print(f"[SCAN] ⚠️ PyMuPDF crashed: {e}")
432+
import traceback; traceback.print_exc()
433+
raw_fields = []
434+
435+
# ── PATH 2: Tesseract OCR — image-based PDFs ─────────────────────────
436+
if not raw_fields:
437+
reason = "no text layer" if not has_text_layer else "PyMuPDF found 0 fields"
438+
print(f"[SCAN] PATH 2: Tesseract OCR ({reason})...")
439+
scan_mode = "tesseract"
440+
try:
441+
raw_fields = _scan_fields_with_tesseract(tpl.pdf_path)
442+
except Exception as e:
443+
print(f"[SCAN] ⚠️ Tesseract crashed: {e}")
444+
import traceback; traceback.print_exc()
255445
raw_fields = []
256446

257-
if raw_fields:
258-
print(f"[SCAN] Found {len(raw_fields)} field lines:")
259-
for f in raw_fields:
260-
print(f" '{f['raw_label']:30s}' → answer_x={f['answer_x']:.1f}pt y={f['answer_y']:.1f}pt")
261-
262-
# Ask Gemma to clean up label names (semantic intelligence)
263-
raw_labels = [f["raw_label"] for f in raw_fields]
264-
clean_labels = await _clean_labels_with_gemma(raw_labels, img_bytes)
265-
266-
for i, rf in enumerate(raw_fields):
267-
snake_label = clean_labels[i] if i < len(clean_labels) else "unknown_field"
268-
found_fields.append({
269-
"label": snake_label,
270-
"display": rf["raw_label"],
271-
"x": rf["answer_x"], # absolute PDF points
272-
"y": rf["answer_y"], # absolute PDF points
273-
"w": rf["page_w"] * 0.9 - rf["answer_x"], # remaining width
274-
"h": rf["answer_bottom"] - rf["answer_y"], # line height
275-
"page_num": rf["page_num"],
276-
"page_w": rf["page_w"],
277-
"page_h": rf["page_h"],
278-
"type": "text",
279-
"coord_unit": "pt", # MARKER: absolute points, not %
280-
})
281-
282-
print(f"\n[SCAN] ✅ pdfplumber matched {len(found_fields)} fields")
283-
284-
# ── FALLBACK PATH: Gemma Vision for image-only PDFs ──────────────────
447+
# ── Shared: clean labels with Gemma (text→text only, no vision) ───────
448+
if raw_fields:
449+
print(f"[SCAN] {len(raw_fields)} raw field(s) — asking Gemma to clean labels...")
450+
raw_labels = [f["raw_label"] for f in raw_fields]
451+
clean_labels = await _clean_labels_with_gemma(raw_labels, img_bytes)
452+
453+
for i, rf in enumerate(raw_fields):
454+
snake_label = clean_labels[i] if i < len(clean_labels) else "unknown_field"
455+
found_fields.append({
456+
"label": snake_label,
457+
"display": rf["raw_label"],
458+
"x": rf["answer_x"],
459+
"y": rf["answer_y"],
460+
"w": rf["page_w"] * 0.9 - rf["answer_x"],
461+
"h": rf["answer_bottom"] - rf["answer_y"],
462+
"page_num": rf["page_num"],
463+
"page_w": rf["page_w"],
464+
"page_h": rf["page_h"],
465+
"type": "text",
466+
"coord_unit": "pt",
467+
})
468+
print(f"[SCAN] ✅ {scan_mode}{len(found_fields)} fields with exact coords")
469+
470+
# ── PATH 3: Gemma Vision — last resort only ───────────────────────────
285471
if not found_fields:
286-
reason = "no text layer" if not has_text_layer else "pdfplumber found 0 fields"
287-
print(f"[SCAN] Fallback → Gemma Vision ({reason})...")
472+
print("[SCAN] PATH 3: Gemma Vision (last resort — both deterministic paths failed)...")
288473
scan_mode = "vision"
289474
llm = LLM()
290475
try:
291476
found_fields = await llm.async_vision_scan_fields(img_bytes)
292-
# Vision fields come as percentages — mark them accordingly
293477
for vf in found_fields:
294-
vf["coord_unit"] = "pct"
478+
vf["coord_unit"] = "pct" # Gemma returns guessed percentages
295479
print(f"[SCAN] Gemma vision: {len(found_fields)} fields")
296480
except Exception as e:
297-
raise AppError(f"Vision scan failed: {e}", status_code=500)
481+
raise AppError(f"All scan paths failed: {e}", status_code=500)
298482

299483
if not found_fields:
300484
return {"status": "no_fields_found", "message": "No fields detected."}
301485

486+
302487
# ── Save to Data Lake (FormFieldCoordinates) ─────────────────────────
303488
stored_coords, semantic_fields = [], {}
304489
for vf in found_fields:

frontend/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,7 +1245,7 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
12451245
<div class="result">
12461246
<div class="result-loading" id="resLoading">
12471247
<div class="spinner"></div>
1248-
<div class="loading-msg"><b>Mistral</b> is extracting data and filling your form...</div>
1248+
<div class="loading-msg"><b>FireForm AI</b> is extracting data and filling your form...</div>
12491249
</div>
12501250
<div class="result-success" id="resSuccess">
12511251
<div class="success-header">
@@ -1589,8 +1589,8 @@ <h1 class="hero-h1">REPORT<br /><span class="outline">ONCE.</span></h1>
15891589
document.getElementById('btnFill').disabled = true;
15901590
const lm = document.querySelector('.loading-msg');
15911591
lm.innerHTML = (mode === 'batch' && selectedIds.size > 1)
1592-
? `<b>Mistral</b> is filling ${selectedIds.size} forms in one pass...`
1593-
: `<b>Mistral</b> is extracting data and filling your form...`;
1592+
? `<b>FireForm AI</b> is filling ${selectedIds.size} forms in one pass...`
1593+
: `<b>FireForm AI</b> is extracting data and filling your form...`;
15941594
document.getElementById('resLoading').classList.add('show');
15951595
try {
15961596
if (mode === 'batch' && selectedIds.size > 1) await fillBatch(text);

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ python-multipart
1515
pymupdf
1616
pdfplumber
1717
reportlab
18+
pytesseract
19+
Pillow

0 commit comments

Comments
 (0)