44from huggingface_hub import hf_hub_download
55from rfdetr import RFDETRNano , RFDETRBase , RFDETRMedium , RFDETRLarge
66
7- from commonforms .utils import BoundingBox , Page , Widget
7+ from commonforms .utils import BoundingBox , Page , TextFragment , Widget
88from commonforms .form_creator import PyPdfFormCreator
99from commonforms .exceptions import EncryptedPdfError
1010
11- import formalpdf
1211import pypdfium2
1312import logging
1413import PIL
@@ -38,7 +37,9 @@ def batch(lst: list, n: int = 8):
3837class FFDetrDetector :
3938 def __init__ (self , model_or_path : str , device : int | str = "cpu" ) -> None :
4039 self .device = device
41- self .model = RFDETRMedium (pretrain_weights = self .get_model_path (model_or_path ))
40+ self .model = RFDETRMedium (
41+ pretrain_weights = self .get_model_path (model_or_path ), device = device
42+ )
4243
4344 self .id_to_cls = {0 : "TextBox" , 1 : "ChoiceButton" , 2 : "Signature" }
4445
@@ -73,7 +74,9 @@ def extract_widgets(
7374 image_size = 1024
7475 results = []
7576 for b in batch ([p .image for p in pages ], n = batch_size ):
76- predictions = self .model .predict (b , threshold = confidence )
77+ predictions = self .model .predict (
78+ b , threshold = confidence , device = self .device
79+ )
7780 if isinstance (predictions , list ):
7881 results .extend (predictions )
7982 else :
@@ -229,16 +232,137 @@ def sort_widgets(widgets: list[Widget]) -> list[Widget]:
229232 return [widget for line in lines for widget in line ]
230233
231234
235+ def extract_text_fragments (page : pypdfium2 .PdfPage ) -> list [TextFragment ]:
236+ textpage = page .get_textpage ()
237+ try :
238+ fragments = []
239+ for term in textpage .get_text_range ().splitlines ():
240+ text = term .strip ()
241+ if not text :
242+ continue
243+
244+ searcher = textpage .search (term , match_case = False , consecutive = True )
245+ try :
246+ match = searcher .get_next ()
247+ finally :
248+ searcher .close ()
249+
250+ if match is None :
251+ continue
252+
253+ index , count = match
254+ rect_count = textpage .count_rects (index , count )
255+ rects = [textpage .get_rect (i ) for i in range (rect_count )]
256+ if not rects :
257+ continue
258+
259+ left = min (rect [0 ] for rect in rects )
260+ top = max (rect [3 ] for rect in rects )
261+ fragments .append (
262+ TextFragment (
263+ text = text ,
264+ x0 = left / page .get_width (),
265+ y0 = 1 - (top / page .get_height ()),
266+ )
267+ )
268+
269+ return fragments
270+ finally :
271+ textpage .close ()
272+
273+
232274def render_pdf (pdf_path : str ) -> list [Page ]:
233275 pages = []
234- doc = formalpdf . open (pdf_path )
276+ doc = pypdfium2 . PdfDocument (pdf_path )
235277 try :
236278 for page in doc :
237- image = page .render (dpi = 144 )
238- pages .append (Page (image = image , width = image .width , height = image .height ))
279+ image = page .render (scale = 2 ).to_pil ()
280+ pages .append (
281+ Page (
282+ image = image ,
283+ width = image .width ,
284+ height = image .height ,
285+ text_fragments = extract_text_fragments (page ),
286+ )
287+ )
239288 return pages
240289 finally :
241- doc .document .close ()
290+ doc .close ()
291+
292+
293+ def group_widget_rows (
294+ widgets : list [Widget ], y_threshold : float = 0.015
295+ ) -> list [list [Widget ]]:
296+ rows : list [list [Widget ]] = []
297+ for widget in sorted (widgets , key = lambda item : item .bounding_box .y0 ):
298+ if (
299+ rows
300+ and abs (widget .bounding_box .y0 - rows [- 1 ][0 ].bounding_box .y0 ) <= y_threshold
301+ ):
302+ rows [- 1 ].append (widget )
303+ else :
304+ rows .append ([widget ])
305+ return rows
306+
307+
308+ def promote_signature_widgets (
309+ pages : list [Page ],
310+ results : dict [int , list [Widget ]],
311+ signature_label_terms : tuple [str , ...] = ("signature" ,),
312+ ) -> dict [int , list [Widget ]]:
313+ """Promote likely signature fields by matching signature labels to nearby rows."""
314+ normalized_terms = tuple (term .lower () for term in signature_label_terms )
315+
316+ for page_ix , widgets in results .items ():
317+ if any (widget .widget_type == "Signature" for widget in widgets ):
318+ continue
319+
320+ signature_labels = [
321+ fragment
322+ for fragment in pages [page_ix ].text_fragments
323+ if any (term in fragment .text .lower () for term in normalized_terms )
324+ ]
325+ if not signature_labels :
326+ continue
327+
328+ textbox_rows = group_widget_rows (
329+ [widget for widget in widgets if widget .widget_type == "TextBox" ]
330+ )
331+ if not textbox_rows :
332+ continue
333+
334+ scored_rows = []
335+ for row in textbox_rows :
336+ row_left = min (widget .bounding_box .x0 for widget in row )
337+ row_right = max (widget .bounding_box .x1 for widget in row )
338+ row_y = sum (widget .bounding_box .y0 for widget in row ) / len (row )
339+ row_width = row_right - row_left
340+
341+ for label in signature_labels :
342+ horizontal_penalty = 0.0
343+ if label .x0 < row_left :
344+ horizontal_penalty = row_left - label .x0
345+ elif label .x0 > row_right :
346+ horizontal_penalty = label .x0 - row_right
347+
348+ score = (
349+ horizontal_penalty ,
350+ abs (row_y - label .y0 ),
351+ abs (row_left - label .x0 ),
352+ - row_width ,
353+ - row_y ,
354+ )
355+ scored_rows .append ((score , row ))
356+
357+ if not scored_rows :
358+ continue
359+
360+ best_row = min (scored_rows , key = lambda item : item [0 ])[1 ]
361+ candidate = min (best_row , key = lambda widget : widget .bounding_box .x0 )
362+ widget_ix = widgets .index (candidate )
363+ widgets [widget_ix ] = candidate .model_copy (update = {"widget_type" : "Signature" })
364+
365+ return results
242366
243367
244368def prepare_form (
@@ -254,11 +378,12 @@ def prepare_form(
254378 fast : bool = False ,
255379 multiline : bool = False ,
256380 batch_size : int = 4 ,
381+ signature_label_terms : tuple [str , ...] = ("signature" ,),
257382):
258383 if "FFDNET" in model_or_path .upper ():
259384 detector = FFDNetDetector (model_or_path , device = device , fast = fast )
260385 else :
261- detector = FFDetrDetector (model_or_path )
386+ detector = FFDetrDetector (model_or_path , device = device )
262387
263388 try :
264389 pages = render_pdf (input_path )
@@ -274,6 +399,11 @@ def prepare_form(
274399 pages , confidence = confidence , image_size = image_size
275400 )
276401
402+ if use_signature_fields :
403+ results = promote_signature_widgets (
404+ pages , results , signature_label_terms = signature_label_terms
405+ )
406+
277407 writer = PyPdfFormCreator (input_path )
278408 if not keep_existing_fields :
279409 writer .clear_existing_fields ()
0 commit comments