1+ import re
12from datetime import datetime , timezone
23from pathlib import Path
34
910 TemplateCreate ,
1011 TemplateResponse ,
1112 TemplateUploadResponse ,
13+ MakeFillableRequest ,
14+ MakeFillableResponse ,
1215)
1316from api .db .repositories import create_template , list_templates
1417from api .db .models import Template
@@ -77,12 +80,68 @@ async def upload_template_pdf(
7780 with target_path .open ("wb" ) as output_file :
7881 output_file .write (content )
7982
83+ relative_path = target_path .relative_to (PROJECT_ROOT ).as_posix ()
84+ extracted = _extract_pdf_fields (relative_path )
8085 return TemplateUploadResponse (
8186 filename = target_path .name ,
82- pdf_path = target_path .relative_to (PROJECT_ROOT ).as_posix (),
87+ pdf_path = relative_path ,
88+ field_count = None if extracted is None else len (extracted ),
89+ fields = extracted or [],
8390 )
8491
8592
93+ # PDF field-type codes -> the type values the frontend field builder uses.
94+ _FIELD_TYPE_BY_FT = {"/Tx" : "string" , "/Btn" : "checkbox" , "/Ch" : "list" , "/Sig" : "signature" }
95+
96+
97+ def _pdf_text (value ) -> str :
98+ """Decode a pdfrw string (field name / tooltip) to plain text."""
99+ if value is None :
100+ return ""
101+ if hasattr (value , "to_unicode" ):
102+ return value .to_unicode ().strip ()
103+ return str (value ).strip ()
104+
105+
106+ def _humanize (name : str ) -> str :
107+ """Turn a raw field name into a readable description (JobTitle -> Job Title)."""
108+ text = re .sub (r"_+" , " " , name )
109+ text = re .sub (r"(?<=[a-z])(?=[A-Z])" , " " , text )
110+ return re .sub (r"\s+" , " " , text ).strip ()
111+
112+
113+ def _extract_pdf_fields (pdf_path : str ) -> list [dict ] | None :
114+ """Fillable widgets in the same order Filler.fill_form writes them
115+ (top-to-bottom, left-to-right per page), so seeded rows line up with the
116+ fill order. Returns None if the PDF can't be read."""
117+ try :
118+ from pdfrw import PdfReader
119+ candidate = Path (pdf_path )
120+ if not candidate .is_absolute ():
121+ candidate = (PROJECT_ROOT / candidate ).resolve ()
122+ pdf = PdfReader (str (candidate ))
123+ fields : list [dict ] = []
124+ for page in pdf .pages :
125+ widgets = [a for a in (page .Annots or []) if a .Subtype == "/Widget" and a .T ]
126+ widgets .sort (key = lambda a : (- float (a .Rect [1 ]), float (a .Rect [0 ])))
127+ for annot in widgets :
128+ name = _pdf_text (annot .T )
129+ fields .append ({
130+ "name" : name ,
131+ "description" : _pdf_text (annot .TU ) or _humanize (name ),
132+ "type" : _FIELD_TYPE_BY_FT .get (str (annot .FT ), "string" ),
133+ })
134+ return fields
135+ except Exception :
136+ return None
137+
138+
139+ def _count_pdf_widgets (pdf_path : str ) -> int | None :
140+ """Number of fillable widgets in a PDF, or None if unreadable."""
141+ fields = _extract_pdf_fields (pdf_path )
142+ return None if fields is None else len (fields )
143+
144+
86145@router .get ("" , response_model = list [TemplateResponse ])
87146def get_templates (db : Session = Depends (get_db )):
88147 return list_templates (db )
@@ -98,12 +157,42 @@ def preview_template_pdf(path: str = Query(..., description="Project-relative PD
98157 if resolved_path .suffix .lower () != ".pdf" :
99158 raise HTTPException (status_code = 400 , detail = "Only PDF files can be previewed." )
100159
101- return FileResponse (resolved_path , media_type = "application/pdf" , filename = resolved_path .name )
160+ return FileResponse (
161+ resolved_path ,
162+ media_type = "application/pdf" ,
163+ filename = resolved_path .name ,
164+ content_disposition_type = "inline" ,
165+ )
102166
103167
104168@router .post ("/create" , response_model = TemplateResponse )
105169def create (template : TemplateCreate , db : Session = Depends (get_db )):
170+ tpl = Template (** template .model_dump ())
171+ created = create_template (db , tpl )
172+ return TemplateResponse (
173+ id = created .id ,
174+ name = created .name ,
175+ pdf_path = created .pdf_path ,
176+ fields = created .fields ,
177+ field_count = _count_pdf_widgets (created .pdf_path ),
178+ )
179+
180+
181+ @router .post ("/make-fillable" , response_model = MakeFillableResponse )
182+ def make_fillable (req : MakeFillableRequest ):
183+ # Validate the path stays inside the project root.
184+ resolved = _resolve_project_file (req .pdf_path )
185+ if not resolved .exists () or not resolved .is_file ():
186+ raise HTTPException (status_code = 404 , detail = "PDF file not found." )
187+
106188 controller = Controller ()
107- template_path = controller .create_template (template .pdf_path )
108- tpl = Template (** template .model_dump (exclude = {"pdf_path" }), pdf_path = template_path )
109- return create_template (db , tpl )
189+ new_absolute = controller .prepare_fillable (str (resolved ))
190+ new_path = Path (new_absolute )
191+ if not new_path .is_absolute ():
192+ new_path = (PROJECT_ROOT / new_path ).resolve ()
193+ relative_path = new_path .relative_to (PROJECT_ROOT ).as_posix ()
194+
195+ return MakeFillableResponse (
196+ pdf_path = relative_path ,
197+ field_count = _count_pdf_widgets (relative_path ),
198+ )
0 commit comments