Skip to content

Commit 100b62f

Browse files
authored
Merge pull request #500 from fireform-core/feature/template-ui-friendly
feat: friendlier template-creation UI
2 parents 19c5f3d + 12721d1 commit 100b62f

14 files changed

Lines changed: 764 additions & 55 deletions

File tree

api/routes/templates.py

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from datetime import datetime, timezone
23
from pathlib import Path
34

@@ -9,6 +10,8 @@
910
TemplateCreate,
1011
TemplateResponse,
1112
TemplateUploadResponse,
13+
MakeFillableRequest,
14+
MakeFillableResponse,
1215
)
1316
from api.db.repositories import create_template, list_templates
1417
from 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])
87146
def 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)
105169
def 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+
)

api/schemas/templates.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,34 @@ class TemplateCreate(BaseModel):
55
pdf_path: str
66
fields: dict
77

8+
9+
class MakeFillableRequest(BaseModel):
10+
pdf_path: str
11+
12+
13+
class MakeFillableResponse(BaseModel):
14+
pdf_path: str
15+
field_count: int | None = None
16+
817
class TemplateResponse(BaseModel):
918
id: int
1019
name: str
1120
pdf_path: str
1221
fields: dict
22+
field_count: int | None = None
1323

1424
class Config:
1525
from_attributes = True
1626

1727

28+
class ExtractedField(BaseModel):
29+
name: str
30+
description: str
31+
type: str
32+
33+
1834
class TemplateUploadResponse(BaseModel):
1935
filename: str
2036
pdf_path: str
37+
field_count: int | None = None
38+
fields: list[ExtractedField] = []

0 commit comments

Comments
 (0)