@@ -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
9286def _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+
118297async 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 :
0 commit comments