Skip to content

Commit 28328a5

Browse files
committed
feat(static-pdf): switch to PyMuPDF + reportlab overlay for deterministic field detection
1 parent 8d9c5d0 commit 28328a5

4 files changed

Lines changed: 396 additions & 106 deletions

File tree

api/routes/templates.py

Lines changed: 234 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import re
23
import shutil
34
import uuid
45
from fastapi import APIRouter, Depends, UploadFile, File, Form
@@ -11,7 +12,6 @@
1112

1213
router = APIRouter(prefix="/templates", tags=["templates"])
1314

14-
# Save directly into src/inputs/ — stable location, won't get wiped
1515
TEMPLATES_DIR = os.path.join("src", "inputs")
1616
os.makedirs(TEMPLATES_DIR, exist_ok=True)
1717

@@ -22,24 +22,19 @@ async def create(
2222
file: UploadFile = File(...),
2323
db: Session = Depends(get_db)
2424
):
25-
# Validate PDF
2625
if not file.filename.endswith(".pdf"):
2726
raise AppError("Only PDF files are allowed", status_code=400)
2827

29-
# Save uploaded file with unique name into src/inputs/
3028
unique_name = f"{uuid.uuid4().hex}_{file.filename}"
3129
save_path = os.path.join(TEMPLATES_DIR, unique_name)
3230

3331
with open(save_path, "wb") as f:
3432
shutil.copyfileobj(file.file, f)
3533

36-
# Extract fields using commonforms + pypdf
37-
# Store as simple list of field name strings — what Filler expects
3834
try:
3935
from commonforms import prepare_form
4036
from pypdf import PdfReader
4137

42-
# Read real field names directly from original PDF
4338
reader = PdfReader(save_path)
4439
raw_fields = reader.get_fields() or {}
4540

@@ -49,7 +44,6 @@ async def create(
4944
if isinstance(field_data, dict):
5045
label = field_data.get("/TU")
5146
if not label:
52-
import re
5347
label = re.sub(r'([a-z])([A-Z])', r'\1 \2', internal_name)
5448
label = re.sub(r'_af_.*$', '', label)
5549
label = label.replace('_', ' ').strip().title()
@@ -59,101 +53,285 @@ async def create(
5953
print(f"Field extraction failed: {e}")
6054
fields = []
6155

62-
# Save to DB
6356
tpl = Template(name=name, pdf_path=save_path, fields=fields)
6457
return create_template(db, tpl)
6558

6659

6760
@router.get("", response_model=list[TemplateResponse])
68-
def list_templates(
69-
limit: int = 100,
70-
offset: int = 0,
71-
db: Session = Depends(get_db)
72-
):
61+
def list_templates(limit: int = 100, offset: int = 0, db: Session = Depends(get_db)):
7362
return get_all_templates(db, limit=limit, offset=offset)
7463

7564

7665
@router.get("/{template_id}", response_model=TemplateResponse)
77-
def get_template_by_id(
78-
template_id: int,
79-
db: Session = Depends(get_db)
80-
):
66+
def get_template_by_id(template_id: int, db: Session = Depends(get_db)):
8167
from api.db.repositories import get_template
8268
tpl = get_template(db, template_id)
8369
if not tpl:
8470
raise AppError("Template not found", status_code=404)
8571
return tpl
8672

8773

74+
# ── PyMuPDF-based deterministic field finder ───────────────────────────
75+
#
76+
# Approach (for static non-fillable PDFs with a text layer):
77+
#
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).
91+
92+
def _scan_fields_with_pymupdf(pdf_path: str) -> list[dict]:
93+
"""
94+
Deterministic field detection for static PDFs using PyMuPDF word-level bboxes.
95+
96+
Uses page.get_text("words") which returns tuples:
97+
(x0, y0, x1, y1, text, block_no, line_no, word_no)
98+
where the coordinate origin is the TOP-LEFT of the page (y increases downward).
99+
100+
Returns a list of dicts, each with:
101+
raw_label: cleaned label text (e.g. "Name")
102+
answer_x: absolute x-point where the answer starts (pt, top-left origin)
103+
answer_y: absolute y-point of the line top (pt, top-left origin)
104+
answer_bottom: absolute y-point of the line bottom (pt, top-left origin)
105+
page_num: 0-indexed page number
106+
page_w: page width in points
107+
page_h: page height in points
108+
line_text: full original line text (for debugging)
109+
"""
110+
import fitz # PyMuPDF — already installed
111+
112+
found_fields = []
113+
114+
115+
return found_fields
116+
117+
118+
async def _clean_labels_with_gemma(raw_labels: list[str], image_bytes: bytes) -> list[str]:
119+
"""
120+
Optional: Ask Gemma to convert raw label strings to clean snake_case names.
121+
Falls back to simple regex cleaning if Gemma fails.
122+
"""
123+
from src.llm import LLM
124+
import json
125+
126+
llm = LLM()
127+
128+
# Build a prompt asking Gemma to clean these specific labels
129+
label_list = "\n".join(f" {i}: \"{l}\"" for i, l in enumerate(raw_labels))
130+
prompt = f'''I have these raw field labels from a form. Convert each to a clear, short snake_case identifier.
131+
132+
Raw labels:
133+
{label_list}
134+
135+
Return a JSON array of strings in the SAME ORDER, one snake_case name per label.
136+
Example: ["full_name", "email_address", "phone_number"]
137+
138+
Rules:
139+
- Keep the semantic meaning (e.g. "Name" → "full_name", "Tel" → "phone")
140+
- Use only lowercase letters and underscores
141+
- Output ONLY the JSON array'''
142+
143+
try:
144+
import base64
145+
ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
146+
vision_model = os.getenv("FIREFORM_VISION_MODEL", "gemma3:4b")
147+
b64_image = base64.b64encode(image_bytes).decode("utf-8")
148+
149+
payload = {
150+
"model": vision_model,
151+
"prompt": prompt,
152+
"images": [b64_image],
153+
"stream": False,
154+
}
155+
156+
import requests
157+
response = requests.post(f"{ollama_host}/api/generate", json=payload, timeout=300)
158+
response.raise_for_status()
159+
res_text = response.json().get("response", "").strip()
160+
161+
# Clean markdown fences
162+
for fence in ["```json", "```"]:
163+
if res_text.startswith(fence):
164+
res_text = res_text[len(fence):]
165+
if res_text.endswith("```"):
166+
res_text = res_text[:-3]
167+
res_text = res_text.strip()
168+
169+
data = json.loads(res_text)
170+
if isinstance(data, list) and len(data) == len(raw_labels):
171+
print(f"[GEMMA] Cleaned labels: {data}")
172+
return [str(d) for d in data]
173+
elif isinstance(data, dict):
174+
for v in data.values():
175+
if isinstance(v, list) and len(v) == len(raw_labels):
176+
return [str(d) for d in v]
177+
except Exception as e:
178+
print(f"[GEMMA] Label cleaning failed ({e}), using regex fallback")
179+
180+
# Regex fallback
181+
clean = []
182+
for label in raw_labels:
183+
snake = re.sub(r'[^a-zA-Z0-9]', '_', label.strip().lower())
184+
snake = re.sub(r'_+', '_', snake).strip('_')
185+
clean.append(snake or "unknown_field")
186+
return clean
187+
188+
189+
# ── Scan endpoint ─────────────────────────────────────────────────────────────
190+
88191
@router.post("/{template_id}/scan")
89-
async def scan_static_template(
90-
template_id: int,
91-
db: Session = Depends(get_db)
92-
):
192+
async def scan_static_template(template_id: int, db: Session = Depends(get_db)):
93193
"""
94-
Uses Gemma 3 Vision model to scan a static/non-fillable PDF once,
95-
detect all blank fields by visual analysis, and store their coordinates
96-
in the Data Lake. Scan Once → Fill Forever.
194+
Deterministic scan for static (non-fillable) PDFs.
195+
Scan Once → Fill Forever.
196+
197+
For PDFs WITH a text layer:
198+
1. pdfplumber extracts word-level coordinates (deterministic)
199+
2. We find field lines (colon/underscore patterns)
200+
3. Gemma cleans up the label names (semantic)
201+
4. Absolute point coordinates are saved to FormFieldCoordinates
202+
203+
For PDFs WITHOUT a text layer (flat scans):
204+
1. Gemma Vision scans the image for field bounding boxes
205+
2. Results are saved to FormFieldCoordinates
206+
207+
Either way: the template is scanned ONCE and coordinates are
208+
stored in the Data Lake forever. No re-scanning, no guessing.
97209
"""
98-
print(f"\n\n🚨 [SCAN] Vision scan requested for template {template_id}\n")
210+
print(f"\n🔍 [SCAN] Template {template_id}\n")
99211
from api.db.repositories import get_template, get_template_coordinates, create_field_coordinates
100212
from api.db.models import FormFieldCoordinates
213+
import fitz
214+
from src.llm import LLM
101215

102216
tpl = get_template(db, template_id)
103217
if not tpl:
104218
raise AppError("Template not found", status_code=404)
105-
106219
if not os.path.exists(tpl.pdf_path):
107220
raise AppError("Template PDF not found on disk", status_code=404)
108221

109222
existing_coords = get_template_coordinates(db, template_id)
110223
if existing_coords:
111-
return {
112-
"status": "already_scanned",
113-
"message": "Template already has coordinate data",
114-
"fields_found": len(existing_coords)
115-
}
116-
117-
import fitz
118-
from src.llm import LLM
224+
return {"status": "already_scanned", "message": "Already scanned.", "fields_found": len(existing_coords)}
119225

226+
# Get page dimensions and check for text layer
120227
doc = fitz.open(tpl.pdf_path)
121228
if len(doc) == 0:
122229
raise AppError("PDF has no pages", status_code=400)
123230

124-
# Render page 1 as a high-res image for the vision model
125231
page = doc[0]
232+
page_w, page_h = page.rect.width, page.rect.height
126233
pix = page.get_pixmap(dpi=150)
127234
img_bytes = pix.tobytes("png")
235+
word_count = len(page.get_text("words"))
128236
doc.close()
129237

130-
llm = LLM()
131-
try:
132-
vision_fields = await llm.async_vision_scan_fields(img_bytes)
133-
print(f"[VISION] Found {len(vision_fields)} fields on static template.")
134-
except Exception as e:
135-
raise AppError(f"Vision scan failed: {e}", status_code=500)
238+
has_text_layer = word_count > 5
239+
print(f"[SCAN] {page_w:.0f}×{page_h:.0f}pt | words={word_count} | text_layer={has_text_layer}")
240+
241+
found_fields: list[dict] = []
242+
scan_mode = "unknown"
243+
244+
# ── PRIMARY PATH: PyMuPDF deterministic scan ─────────────────────────
245+
if has_text_layer:
246+
scan_mode = "pymupdf"
247+
print("[SCAN] Using PyMuPDF deterministic word-level scan...")
248+
249+
try:
250+
raw_fields = _scan_fields_with_pymupdf(tpl.pdf_path)
251+
except Exception as e:
252+
print(f"[SCAN] ⚠️ PyMuPDF scan crashed: {e}")
253+
import traceback
254+
traceback.print_exc()
255+
raw_fields = []
136256

137-
if not vision_fields:
138-
return {"status": "no_fields_found", "message": "Vision model found no fields."}
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")
139261

140-
# Save coordinates into DB
141-
stored_coords = []
142-
semantic_fields = {}
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 ──────────────────
285+
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})...")
288+
scan_mode = "vision"
289+
llm = LLM()
290+
try:
291+
found_fields = await llm.async_vision_scan_fields(img_bytes)
292+
# Vision fields come as percentages — mark them accordingly
293+
for vf in found_fields:
294+
vf["coord_unit"] = "pct"
295+
print(f"[SCAN] Gemma vision: {len(found_fields)} fields")
296+
except Exception as e:
297+
raise AppError(f"Vision scan failed: {e}", status_code=500)
298+
299+
if not found_fields:
300+
return {"status": "no_fields_found", "message": "No fields detected."}
301+
302+
# ── Save to Data Lake (FormFieldCoordinates) ─────────────────────────
303+
stored_coords, semantic_fields = [], {}
304+
for vf in found_fields:
305+
coord_unit = vf.get("coord_unit", "pct")
306+
307+
if coord_unit == "pt":
308+
# Absolute points: convert to percentages for DB storage
309+
# (DB schema uses 0-100 percentages)
310+
x_pct = (vf["x"] / vf["page_w"]) * 100
311+
y_pct = (vf["y"] / vf["page_h"]) * 100
312+
w_pct = (vf["w"] / vf["page_w"]) * 100
313+
h_pct = (vf["h"] / vf["page_h"]) * 100
314+
else:
315+
# Already percentages (from vision fallback)
316+
x_pct = float(vf.get("x", 0))
317+
y_pct = float(vf.get("y", 0))
318+
w_pct = float(vf.get("w", vf.get("width", 20)))
319+
h_pct = float(vf.get("h", vf.get("height", 5)))
143320

144-
for vf in vision_fields:
145321
c = FormFieldCoordinates(
146322
template_id=template_id,
147323
field_label=vf.get("label", "unknown_field"),
148-
page_number=0,
149-
x=float(vf.get("x", 0)),
150-
y=float(vf.get("y", 0)),
151-
width=float(vf.get("w", vf.get("width", 20))),
152-
height=float(vf.get("h", vf.get("height", 5))),
324+
page_number=vf.get("page_num", 0),
325+
x=round(x_pct, 3),
326+
y=round(y_pct, 3),
327+
width=round(w_pct, 3),
328+
height=round(h_pct, 3),
153329
field_type=vf.get("type", "text")
154330
)
155331
stored_coords.append(c)
156-
semantic_fields[c.field_label] = c.field_label.replace("_", " ").title()
332+
semantic_fields[c.field_label] = vf.get("display", c.field_label).replace("_", " ").title()
333+
334+
print(f" 💾 {c.field_label:25s} → x={c.x:.1f}% y={c.y:.1f}% w={c.width:.1f}% [{scan_mode}]")
157335

158336
create_field_coordinates(db, stored_coords)
159337

@@ -164,6 +342,7 @@ async def scan_static_template(
164342

165343
return {
166344
"status": "success",
167-
"message": f"Vision scan complete — {len(stored_coords)} fields mapped.",
168-
"fields": semantic_fields
345+
"scan_mode": scan_mode,
346+
"message": f"Scan complete ({scan_mode}) — {len(stored_coords)} fields.",
347+
"fields": semantic_fields,
169348
}

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ ollama
1313
faster-whisper
1414
python-multipart
1515
pymupdf
16+
pdfplumber
17+
reportlab

0 commit comments

Comments
 (0)