Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 94 additions & 5 deletions api/routes/templates.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from datetime import datetime, timezone
from pathlib import Path

Expand All @@ -9,6 +10,8 @@
TemplateCreate,
TemplateResponse,
TemplateUploadResponse,
MakeFillableRequest,
MakeFillableResponse,
)
from api.db.repositories import create_template, list_templates
from api.db.models import Template
Expand Down Expand Up @@ -77,12 +80,68 @@ async def upload_template_pdf(
with target_path.open("wb") as output_file:
output_file.write(content)

relative_path = target_path.relative_to(PROJECT_ROOT).as_posix()
extracted = _extract_pdf_fields(relative_path)
return TemplateUploadResponse(
filename=target_path.name,
pdf_path=target_path.relative_to(PROJECT_ROOT).as_posix(),
pdf_path=relative_path,
field_count=None if extracted is None else len(extracted),
fields=extracted or [],
)


# PDF field-type codes -> the type values the frontend field builder uses.
_FIELD_TYPE_BY_FT = {"/Tx": "string", "/Btn": "checkbox", "/Ch": "list", "/Sig": "signature"}


def _pdf_text(value) -> str:
"""Decode a pdfrw string (field name / tooltip) to plain text."""
if value is None:
return ""
if hasattr(value, "to_unicode"):
return value.to_unicode().strip()
return str(value).strip()


def _humanize(name: str) -> str:
"""Turn a raw field name into a readable description (JobTitle -> Job Title)."""
text = re.sub(r"_+", " ", name)
text = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", text)
return re.sub(r"\s+", " ", text).strip()


def _extract_pdf_fields(pdf_path: str) -> list[dict] | None:
"""Fillable widgets in the same order Filler.fill_form writes them
(top-to-bottom, left-to-right per page), so seeded rows line up with the
fill order. Returns None if the PDF can't be read."""
try:
from pdfrw import PdfReader
candidate = Path(pdf_path)
if not candidate.is_absolute():
candidate = (PROJECT_ROOT / candidate).resolve()
pdf = PdfReader(str(candidate))
fields: list[dict] = []
for page in pdf.pages:
widgets = [a for a in (page.Annots or []) if a.Subtype == "/Widget" and a.T]
widgets.sort(key=lambda a: (-float(a.Rect[1]), float(a.Rect[0])))
for annot in widgets:
name = _pdf_text(annot.T)
fields.append({
"name": name,
"description": _pdf_text(annot.TU) or _humanize(name),
"type": _FIELD_TYPE_BY_FT.get(str(annot.FT), "string"),
})
return fields
except Exception:
return None


def _count_pdf_widgets(pdf_path: str) -> int | None:
"""Number of fillable widgets in a PDF, or None if unreadable."""
fields = _extract_pdf_fields(pdf_path)
return None if fields is None else len(fields)


@router.get("", response_model=list[TemplateResponse])
def get_templates(db: Session = Depends(get_db)):
return list_templates(db)
Expand All @@ -98,12 +157,42 @@ def preview_template_pdf(path: str = Query(..., description="Project-relative PD
if resolved_path.suffix.lower() != ".pdf":
raise HTTPException(status_code=400, detail="Only PDF files can be previewed.")

return FileResponse(resolved_path, media_type="application/pdf", filename=resolved_path.name)
return FileResponse(
resolved_path,
media_type="application/pdf",
filename=resolved_path.name,
content_disposition_type="inline",
)


@router.post("/create", response_model=TemplateResponse)
def create(template: TemplateCreate, db: Session = Depends(get_db)):
tpl = Template(**template.model_dump())
created = create_template(db, tpl)
return TemplateResponse(
id=created.id,
name=created.name,
pdf_path=created.pdf_path,
fields=created.fields,
field_count=_count_pdf_widgets(created.pdf_path),
)


@router.post("/make-fillable", response_model=MakeFillableResponse)
def make_fillable(req: MakeFillableRequest):
# Validate the path stays inside the project root.
resolved = _resolve_project_file(req.pdf_path)
if not resolved.exists() or not resolved.is_file():
raise HTTPException(status_code=404, detail="PDF file not found.")

controller = Controller()
template_path = controller.create_template(template.pdf_path)
tpl = Template(**template.model_dump(exclude={"pdf_path"}), pdf_path=template_path)
return create_template(db, tpl)
new_absolute = controller.prepare_fillable(str(resolved))
new_path = Path(new_absolute)
if not new_path.is_absolute():
new_path = (PROJECT_ROOT / new_path).resolve()
relative_path = new_path.relative_to(PROJECT_ROOT).as_posix()

return MakeFillableResponse(
pdf_path=relative_path,
field_count=_count_pdf_widgets(relative_path),
)
18 changes: 18 additions & 0 deletions api/schemas/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,34 @@ class TemplateCreate(BaseModel):
pdf_path: str
fields: dict


class MakeFillableRequest(BaseModel):
pdf_path: str


class MakeFillableResponse(BaseModel):
pdf_path: str
field_count: int | None = None

class TemplateResponse(BaseModel):
id: int
name: str
pdf_path: str
fields: dict
field_count: int | None = None

class Config:
from_attributes = True


class ExtractedField(BaseModel):
name: str
description: str
type: str


class TemplateUploadResponse(BaseModel):
filename: str
pdf_path: str
field_count: int | None = None
fields: list[ExtractedField] = []
Loading
Loading