diff --git a/api/routes/templates.py b/api/routes/templates.py index 10e7aea..64db2e0 100644 --- a/api/routes/templates.py +++ b/api/routes/templates.py @@ -1,3 +1,4 @@ +import re from datetime import datetime, timezone from pathlib import Path @@ -9,6 +10,8 @@ TemplateCreate, TemplateResponse, TemplateUploadResponse, + MakeFillableRequest, + MakeFillableResponse, ) from api.db.repositories import create_template, list_templates from api.db.models import Template @@ -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) @@ -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), + ) diff --git a/api/schemas/templates.py b/api/schemas/templates.py index 4997f04..df39832 100644 --- a/api/schemas/templates.py +++ b/api/schemas/templates.py @@ -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] = [] diff --git a/frontend/app.js b/frontend/app.js index 2157b33..2aab25b 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,8 +1,26 @@ const STORAGE_TEMPLATES_KEY = "fireform.templates.v1"; const STORAGE_LAST_OUTPUT_KEY = "fireform.lastOutputPath.v1"; const STORAGE_TEMPLATE_DIR_KEY = "fireform.templateDirectory.v1"; +const STORAGE_FIELD_ROWS_KEY = "fireform.fieldRows.v1"; const API_BASE_URL = "http://127.0.0.1:8000"; +// UI label <-> stored type-string mapping. The stored values stay backward +// compatible with the existing default "string" type. +const FIELD_TYPES = [ + { label: "Text", value: "string" }, + { label: "Long Text", value: "long_text" }, + { label: "Number", value: "number" }, + { label: "Date", value: "date" }, + { label: "Time", value: "time" }, + { label: "Email", value: "email" }, + { label: "Phone", value: "phone" }, + { label: "Signature", value: "signature" }, + { label: "Checkbox", value: "checkbox" }, + { label: "List", value: "list" }, +]; +const TYPE_VALUE_TO_LABEL = Object.fromEntries(FIELD_TYPES.map((t) => [t.value, t.label])); +const DEFAULT_FIELD_ROWS = [{ name: "", type: "string" }]; + const elements = { tabs: Array.from(document.querySelectorAll(".tab")), panels: Array.from(document.querySelectorAll(".panel")), @@ -12,7 +30,12 @@ const elements = { pdfDropZone: document.getElementById("pdfDropZone"), selectedFileMeta: document.getElementById("selectedFileMeta"), templateDirectory: document.getElementById("templateDirectory"), - templateFields: document.getElementById("templateFields"), + makeFillableBtn: document.getElementById("makeFillableBtn"), + makeFillableHelpBtn: document.getElementById("makeFillableHelpBtn"), + makeFillableHelp: document.getElementById("makeFillableHelp"), + fieldsBuilder: document.getElementById("fieldsBuilder"), + fieldCountBadge: document.getElementById("fieldCountBadge"), + addFieldBtn: document.getElementById("addFieldBtn"), templateFormMessage: document.getElementById("templateFormMessage"), templateFormResponse: document.getElementById("templateFormResponse"), fillForm: document.getElementById("fillForm"), @@ -32,6 +55,10 @@ const elements = { let templates = loadTemplates(); let activeObjectUrl = null; let selectedTemplateFile = null; +let fieldRows = loadFieldRows(); +let dragSourceIndex = null; +let uploadedPath = null; +let uploadedFieldCount = null; waitForBackend().then(initialize); @@ -62,6 +89,7 @@ async function waitForBackend() { async function initialize() { bindEvents(); restoreTemplateDirectory(); + renderFieldRows(); renderTemplates(); restorePreviewState(); updateSelectedFileMeta(); @@ -78,6 +106,9 @@ function bindEvents() { elements.pdfDropZone.addEventListener("click", () => elements.templatePdfFile.click()); elements.pdfDropZone.addEventListener("keydown", handleDropZoneKeyDown); elements.templateDirectory.addEventListener("input", handleTemplateDirectoryInput); + elements.addFieldBtn.addEventListener("click", handleAddFieldClick); + elements.makeFillableBtn.addEventListener("click", handleMakeFillableClick); + elements.makeFillableHelpBtn.addEventListener("click", toggleMakeFillableHelp); bindDropZoneDragEvents(); elements.fillForm.addEventListener("submit", handleFillSubmit); elements.templatesList.addEventListener("click", handleTemplateActionClick); @@ -180,15 +211,97 @@ function setSelectedTemplateFile(file) { if (!isPdfFile(file)) { selectedTemplateFile = null; + uploadedPath = null; + uploadedFieldCount = null; + setMakeFillableButtonState(); + renderFieldCountBadge(); setStatus(elements.templateFormMessage, "Please select a PDF file.", "error"); updateSelectedFileMeta(); return; } selectedTemplateFile = file; + uploadedPath = null; + uploadedFieldCount = null; + setMakeFillableButtonState(); + renderFieldCountBadge(); clearJson(elements.templateFormResponse); setStatus(elements.templateFormMessage, ""); updateSelectedFileMeta(); + // Eager upload so the user gets a live field-count comparison while building rows. + uploadSelectedFileSilently(); +} + +async function uploadSelectedFileSilently() { + if (!selectedTemplateFile) return; + const directory = normalizeDirectory(elements.templateDirectory.value); + if (!directory) return; + + const fileAtUploadStart = selectedTemplateFile; + try { + const upload = await uploadTemplatePdf(fileAtUploadStart, directory); + // Guard against the user picking a different file mid-upload. + if (fileAtUploadStart !== selectedTemplateFile) return; + uploadedPath = upload.pdf_path; + uploadedFieldCount = + typeof upload.field_count === "number" ? upload.field_count : null; + maybeSeedFieldRows(upload.fields); + renderFieldCountBadge(); + } catch (_error) { + // Silent failure — the explicit Create / Make Fillable paths surface errors. + } +} + +// Prefill the field rows from the PDF's own form fields, but never overwrite +// rows the user has already started filling in. +function maybeSeedFieldRows(fields) { + if (!Array.isArray(fields) || !fields.length) return; + syncFieldRowsFromDom(); + if (!fieldRows.every((row) => !row.name.trim())) return; + + fieldRows = fields.map((f) => ({ + name: f.description || f.name || "", + type: normalizeFieldType(f.type), + })); + saveFieldRows(); + renderFieldRows(); + setStatus( + elements.templateFormMessage, + `Loaded ${fieldRows.length} field${fieldRows.length === 1 ? "" : "s"} from the PDF — edit the descriptions as needed.`, + "info" + ); +} + +function setMakeFillableButtonState() { + if (!elements.makeFillableBtn) return; + elements.makeFillableBtn.disabled = !selectedTemplateFile; + elements.makeFillableBtn.textContent = "Make this PDF fillable"; +} + +function renderFieldCountBadge() { + const badge = elements.fieldCountBadge; + if (!badge) return; + + if (!selectedTemplateFile || uploadedFieldCount === null) { + badge.classList.add("hidden"); + badge.classList.remove("match", "mismatch"); + badge.textContent = ""; + return; + } + + const expected = uploadedFieldCount; + const actual = fieldRows.length; + const noun = (n) => `${n} fillable field${n === 1 ? "" : "s"}`; + const rowNoun = (n) => `${n} row${n === 1 ? "" : "s"}`; + + badge.classList.remove("hidden", "match", "mismatch"); + if (expected === actual) { + badge.classList.add("match"); + badge.textContent = `PDF has ${noun(expected)} — your ${rowNoun(actual)} match.`; + } else { + badge.classList.add("mismatch"); + badge.textContent = `PDF has ${noun(expected)} — you have ${rowNoun(actual)}.`; + } } function isPdfFile(file) { @@ -256,16 +369,28 @@ function clearJson(preElement) { preElement.classList.add("hidden"); } -function normalizeFields(rawFields) { - try { - const parsed = JSON.parse(rawFields); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { error: "Fields must be a JSON object." }; +function collectFieldRows() { + syncFieldRowsFromDom(); + + if (fieldRows.length === 0) { + return { error: "Add at least one field before creating the template." }; + } + + const dict = {}; + const seen = new Set(); + for (const row of fieldRows) { + const name = row.name.trim(); + if (!name) { + return { error: "Every field needs a name." }; } - return { value: parsed }; - } catch (_error) { - return { error: "Fields JSON is invalid. Fix syntax and try again." }; + const key = name.toLowerCase(); + if (seen.has(key)) { + return { error: `Field names must be unique ("${name}" appears more than once).` }; + } + seen.add(key); + dict[name] = row.type || "string"; } + return { value: dict }; } async function handleTemplateSubmit(event) { @@ -275,7 +400,7 @@ async function handleTemplateSubmit(event) { const name = elements.templateName.value.trim(); const templateDirectory = normalizeDirectory(elements.templateDirectory.value); - const normalized = normalizeFields(elements.templateFields.value.trim()); + const collected = collectFieldRows(); if (!name || !templateDirectory || !selectedTemplateFile) { setStatus( @@ -286,21 +411,26 @@ async function handleTemplateSubmit(event) { return; } - if (normalized.error) { - setStatus(elements.templateFormMessage, normalized.error, "error"); + if (collected.error) { + setStatus(elements.templateFormMessage, collected.error, "error"); return; } try { localStorage.setItem(STORAGE_TEMPLATE_DIR_KEY, templateDirectory); - setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info"); - - const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory); + saveFieldRows(); + let activePdfPath = uploadedPath; + if (!activePdfPath) { + setStatus(elements.templateFormMessage, "Copying PDF into project directory...", "info"); + const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory); + activePdfPath = upload.pdf_path; + uploadedPath = upload.pdf_path; + } const payload = { name, - pdf_path: upload.pdf_path, - fields: normalized.value, + pdf_path: activePdfPath, + fields: collected.value, }; setStatus(elements.templateFormMessage, "Creating template...", "info"); @@ -320,12 +450,25 @@ async function handleTemplateSubmit(event) { elements.fillTemplateId.value = String(body.id || ""); elements.serverPdfPath.value = body.pdf_path || ""; + const expected = body.field_count; + const actual = Object.keys(collected.value).length; + let mismatchNote = ""; + let statusLevel = "success"; + if (typeof expected === "number" && expected !== actual) { + mismatchNote = ` Heads up — the PDF has ${expected} fillable field${expected === 1 ? "" : "s"}, but you added ${actual} row${actual === 1 ? "" : "s"}. Fills may be incomplete or misaligned.`; + statusLevel = "error"; + } + setStatus( elements.templateFormMessage, - `Template created (id: ${body.id}). PDF saved at ${upload.pdf_path}.`, - "success" + `Template created (id: ${body.id}). PDF saved at ${activePdfPath}.${mismatchNote}`, + statusLevel ); showJson(elements.templateFormResponse, body); + uploadedPath = null; + uploadedFieldCount = null; + setMakeFillableButtonState(); + renderFieldCountBadge(); } catch (error) { setStatus(elements.templateFormMessage, error.message, "error"); } @@ -535,9 +678,7 @@ function renderTemplates() { path.className = "template-meta"; path.textContent = `pdf_path: ${template.pdf_path || ""}`; - const fields = document.createElement("pre"); - fields.className = "json-output"; - fields.textContent = JSON.stringify(template.fields || {}, null, 2); + const fields = buildFieldsTable(template.fields || {}); const actions = document.createElement("div"); actions.className = "card-actions"; @@ -560,6 +701,263 @@ function renderTemplates() { }); } +function buildFieldsTable(fieldsDict) { + const table = document.createElement("table"); + table.className = "fields-table"; + + const thead = document.createElement("thead"); + thead.innerHTML = "FieldType"; + table.appendChild(thead); + + const tbody = document.createElement("tbody"); + const entries = Object.entries(fieldsDict || {}); + if (!entries.length) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 2; + cell.textContent = "No fields."; + row.appendChild(cell); + tbody.appendChild(row); + } else { + for (const [name, type] of entries) { + const row = document.createElement("tr"); + const nameCell = document.createElement("td"); + nameCell.textContent = name; + const typeCell = document.createElement("td"); + typeCell.textContent = TYPE_VALUE_TO_LABEL[type] || "Text"; + row.append(nameCell, typeCell); + tbody.appendChild(row); + } + } + table.appendChild(tbody); + return table; +} + +function loadFieldRows() { + try { + const raw = localStorage.getItem(STORAGE_FIELD_ROWS_KEY); + if (!raw) { + return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); + } + return parsed + .filter((item) => item && typeof item === "object") + .map((item) => ({ + name: typeof item.name === "string" ? item.name : "", + type: normalizeFieldType(item.type), + })); + } catch (_error) { + return DEFAULT_FIELD_ROWS.map((row) => ({ ...row })); + } +} + +function normalizeFieldType(value) { + return TYPE_VALUE_TO_LABEL[value] ? value : "string"; +} + +function saveFieldRows() { + localStorage.setItem(STORAGE_FIELD_ROWS_KEY, JSON.stringify(fieldRows)); +} + +function syncFieldRowsFromDom() { + const rowEls = Array.from(elements.fieldsBuilder.querySelectorAll(".field-row")); + fieldRows = rowEls.map((rowEl) => ({ + name: rowEl.querySelector(".field-name").value, + type: rowEl.querySelector(".field-type").value, + })); +} + +function renderFieldRows() { + const fragment = document.createDocumentFragment(); + fieldRows.forEach((row, index) => { + fragment.appendChild(buildFieldRow(row, index)); + }); + elements.fieldsBuilder.innerHTML = ""; + elements.fieldsBuilder.appendChild(fragment); + renderFieldCountBadge(); +} + +function buildFieldRow(row, index) { + const rowEl = document.createElement("div"); + rowEl.className = "field-row"; + rowEl.draggable = true; + rowEl.dataset.index = String(index); + + const handle = document.createElement("span"); + handle.className = "field-drag-handle"; + handle.setAttribute("aria-hidden", "true"); + handle.textContent = "⋮⋮"; // two-column dots — reads as a grip handle + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.className = "field-name"; + nameInput.placeholder = "Give description here"; + nameInput.value = row.name || ""; + nameInput.addEventListener("input", () => { + syncFieldRowsFromDom(); + saveFieldRows(); + }); + + const typeSelect = document.createElement("select"); + typeSelect.className = "field-type"; + FIELD_TYPES.forEach((t) => { + const opt = document.createElement("option"); + opt.value = t.value; + opt.textContent = t.label; + typeSelect.appendChild(opt); + }); + typeSelect.value = normalizeFieldType(row.type); + typeSelect.addEventListener("change", () => { + syncFieldRowsFromDom(); + saveFieldRows(); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.type = "button"; + deleteBtn.className = "field-delete-btn"; + deleteBtn.setAttribute("aria-label", "Remove field"); + deleteBtn.textContent = "✕"; // ✕ + deleteBtn.addEventListener("click", () => { + syncFieldRowsFromDom(); + const rowIndex = Number(rowEl.dataset.index); + fieldRows.splice(rowIndex, 1); + saveFieldRows(); + renderFieldRows(); + }); + + rowEl.addEventListener("dragstart", handleRowDragStart); + rowEl.addEventListener("dragover", handleRowDragOver); + rowEl.addEventListener("dragleave", handleRowDragLeave); + rowEl.addEventListener("drop", handleRowDrop); + rowEl.addEventListener("dragend", handleRowDragEnd); + + rowEl.append(handle, nameInput, typeSelect, deleteBtn); + return rowEl; +} + +function toggleMakeFillableHelp() { + const willShow = elements.makeFillableHelp.classList.contains("hidden"); + elements.makeFillableHelp.classList.toggle("hidden", !willShow); + elements.makeFillableHelpBtn.setAttribute("aria-expanded", String(willShow)); +} + +async function handleMakeFillableClick() { + if (!selectedTemplateFile) { + setStatus(elements.templateFormMessage, "Select a PDF first.", "error"); + return; + } + + const templateDirectory = normalizeDirectory(elements.templateDirectory.value); + if (!templateDirectory) { + setStatus(elements.templateFormMessage, "Template directory is required.", "error"); + return; + } + + elements.makeFillableBtn.disabled = true; + const previousLabel = elements.makeFillableBtn.textContent; + elements.makeFillableBtn.textContent = "Working..."; + setStatus( + elements.templateFormMessage, + "Uploading PDF and running fillable-field detection (this can take a minute)...", + "info" + ); + + try { + if (!uploadedPath) { + const upload = await uploadTemplatePdf(selectedTemplateFile, templateDirectory); + uploadedPath = upload.pdf_path; + } + + const response = await fetch(`${API_BASE_URL}/templates/make-fillable`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pdf_path: uploadedPath }), + }); + const body = await parseJsonResponse(response); + if (!response.ok) { + throw new Error(extractErrorMessage(body, response.status)); + } + + uploadedPath = body.pdf_path; + const count = typeof body.field_count === "number" ? body.field_count : null; + uploadedFieldCount = count; + renderFieldCountBadge(); + setStatus( + elements.templateFormMessage, + count !== null + ? `Fillable PDF created — ${count} field${count === 1 ? "" : "s"} detected.` + : "Fillable PDF created.", + "success" + ); + elements.makeFillableBtn.textContent = "Re-detect fields"; + elements.makeFillableBtn.disabled = false; + } catch (error) { + setStatus(elements.templateFormMessage, error.message, "error"); + elements.makeFillableBtn.textContent = previousLabel; + elements.makeFillableBtn.disabled = false; + } +} + +function handleAddFieldClick() { + syncFieldRowsFromDom(); + fieldRows.push({ name: "", type: "string" }); + saveFieldRows(); + renderFieldRows(); + const rows = elements.fieldsBuilder.querySelectorAll(".field-row .field-name"); + if (rows.length) { + rows[rows.length - 1].focus(); + } +} + +function handleRowDragStart(event) { + const rowEl = event.currentTarget; + dragSourceIndex = Number(rowEl.dataset.index); + rowEl.classList.add("is-dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(dragSourceIndex)); + } +} + +function handleRowDragOver(event) { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + event.currentTarget.classList.add("drag-over"); +} + +function handleRowDragLeave(event) { + event.currentTarget.classList.remove("drag-over"); +} + +function handleRowDrop(event) { + event.preventDefault(); + const rowEl = event.currentTarget; + rowEl.classList.remove("drag-over"); + const targetIndex = Number(rowEl.dataset.index); + if (dragSourceIndex === null || dragSourceIndex === targetIndex) { + return; + } + syncFieldRowsFromDom(); + const [moved] = fieldRows.splice(dragSourceIndex, 1); + fieldRows.splice(targetIndex, 0, moved); + dragSourceIndex = null; + saveFieldRows(); + renderFieldRows(); +} + +function handleRowDragEnd(event) { + event.currentTarget.classList.remove("is-dragging"); + elements.fieldsBuilder + .querySelectorAll(".field-row.drag-over") + .forEach((el) => el.classList.remove("drag-over")); + dragSourceIndex = null; +} + function loadTemplates() { try { const raw = localStorage.getItem(STORAGE_TEMPLATES_KEY); diff --git a/frontend/index.html b/frontend/index.html index 81f5db5..0956384 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,25 @@

Create Template

No PDF selected.

+
+ + +
+ + Create Template The selected PDF is copied into this project directory before template creation.

- - + +

+ What information should be filled in? Add one row per field. Fields are filled + into your PDF in the order shown — drag to reorder. +

+ +
+ @@ -140,6 +157,6 @@

PDF Preview

- + diff --git a/frontend/styles.css b/frontend/styles.css index aef64ed..126c37a 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -142,12 +142,14 @@ label { input, textarea, +select, button { font: inherit; } input, -textarea { +textarea, +select { width: 100%; border: 1px solid #bcc8d6; border-radius: 10px; @@ -156,6 +158,23 @@ textarea { color: var(--text); } +select { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} + +input:focus, +textarea:focus, +select:focus { + outline: 2px solid var(--primary); + outline-offset: 1px; + border-color: var(--primary); +} + textarea { resize: vertical; } @@ -212,6 +231,176 @@ button:hover { display: none; } +.secondary-btn { + background: #f1f5f9; + color: var(--primary-strong); + border: 1px solid var(--panel-border); + font-weight: 600; + padding: 8px 14px; + justify-self: start; +} + +.secondary-btn:hover { + background: #e5ecf3; +} + +.help-trigger { + background: #e5ecf3; + color: var(--primary-strong); + border: 1px solid #c8d2dd; + border-radius: 50%; + width: 24px; + height: 24px; + padding: 0; + font-weight: 700; + font-size: 0.9rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.help-trigger:hover { + background: #d8e2eb; +} + +.help-trigger[aria-expanded="true"] { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.field-count-badge { + margin: 0; + padding: 6px 10px; + border-radius: 8px; + background: #f1f5f9; + border: 1px solid var(--panel-border); + color: var(--muted); + font-size: 0.92rem; + display: inline-block; + justify-self: start; +} + +.field-count-badge.hidden { + display: none; +} + +.field-count-badge.match { + background: #e8f3ec; + border-color: #b9d8c4; + color: var(--success); +} + +.field-count-badge.mismatch { + background: #fbe9e9; + border-color: #f3d3d3; + color: var(--error); +} + +.fields-builder { + display: grid; + gap: 8px; +} + +.field-row { + display: grid; + grid-template-columns: 24px 1fr 170px 36px; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid var(--panel-border); + border-radius: 12px; + background: #fbfcfe; +} + +.field-row.is-dragging { + opacity: 0.4; +} + +.field-row.drag-over { + border-color: var(--primary); + background: #eef7ff; +} + +.field-drag-handle { + cursor: grab; + color: var(--muted); + text-align: center; + user-select: none; + font-size: 1rem; + line-height: 1; +} + +.field-drag-handle:active { + cursor: grabbing; +} + +.field-delete-btn { + background: transparent; + color: var(--muted); + border: 1px solid transparent; + padding: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1; + border-radius: 8px; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.field-delete-btn:hover { + background: #fbe9e9; + color: var(--error); + border-color: #f3d3d3; +} + +.fields-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--panel-border); + border-radius: 10px; + overflow: hidden; + background: #fff; + font-size: 0.92rem; +} + +.fields-table th, +.fields-table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--panel-border); +} + +.fields-table tr:last-child td { + border-bottom: 0; +} + +.fields-table th { + background: #f1f5f9; + font-weight: 600; + color: var(--muted); +} + +@media (max-width: 540px) { + .field-row { + grid-template-columns: 22px 1fr 36px; + grid-template-areas: + "handle name delete" + ". select select"; + } + + .field-drag-handle { grid-area: handle; } + .field-row input[type="text"] { grid-area: name; } + .field-row select { grid-area: select; } + .field-delete-btn { grid-area: delete; } +} + .divider { width: 100%; border: 0; diff --git a/src/controller.py b/src/controller.py index d31ec9c..57348bf 100644 --- a/src/controller.py +++ b/src/controller.py @@ -7,5 +7,5 @@ def __init__(self): def fill_form(self, user_input: str, fields: list, pdf_form_path: str): return self.file_manipulator.fill_form(user_input, fields, pdf_form_path) - def create_template(self, pdf_path: str): - return self.file_manipulator.create_template(pdf_path) \ No newline at end of file + def prepare_fillable(self, pdf_path: str): + return self.file_manipulator.prepare_fillable(pdf_path) \ No newline at end of file diff --git a/src/file_manipulator.py b/src/file_manipulator.py index 91c6afa..1c59180 100644 --- a/src/file_manipulator.py +++ b/src/file_manipulator.py @@ -8,9 +8,10 @@ def __init__(self): self.filler = Filler() self.llm = LLM() - def create_template(self, pdf_path: str): + def prepare_fillable(self, pdf_path: str): """ - By using commonforms, we create an editable .pdf template and we store it. + Run commonforms on a flat PDF to detect form regions and produce a + fillable PDF. Returns the new path (alongside the original). """ # Disable CUDA to force CPU usage, preventing errors on Mac Silicon / Docker import os @@ -27,13 +28,9 @@ def patched_ensure(model_ctx): except ImportError: pass - # Lazy import from commonforms import prepare_form template_path = pdf_path[:-4] + "_template.pdf" - # Ollama lifecycle is managed by Docker / the OS — no need to kill it here. - - prepare_form(pdf_path, template_path) return template_path diff --git a/src/inputs/file.pdf b/src/inputs/file.pdf deleted file mode 100644 index 2ebae13..0000000 Binary files a/src/inputs/file.pdf and /dev/null differ diff --git a/src/inputs/file_filled.pdf b/src/inputs/file_filled.pdf deleted file mode 100644 index fcf2a8d..0000000 Binary files a/src/inputs/file_filled.pdf and /dev/null differ diff --git a/src/inputs/file_template.pdf b/src/inputs/file_template.pdf deleted file mode 100644 index 67af4c9..0000000 Binary files a/src/inputs/file_template.pdf and /dev/null differ diff --git a/src/inputs/file_template_manual.pdf b/src/inputs/file_template_manual.pdf deleted file mode 100644 index 2ebae13..0000000 Binary files a/src/inputs/file_template_manual.pdf and /dev/null differ diff --git a/src/llm.py b/src/llm.py index 1d5985f..6bd791b 100644 --- a/src/llm.py +++ b/src/llm.py @@ -10,24 +10,25 @@ def __init__(self, transcript_text: str=None, target_fields: list=None, json_dic self._target_fields = target_fields self._json = json_dict if json_dict is not None else {} - def build_prompt(self, current_field: str): + def build_prompt(self, current_field: str, current_type: str = "string"): """ This method is in charge of the prompt engineering. It creates a specific prompt for each target field. @params: current_field -> represents the current element of the json that is being prompted. + @params: current_type -> hint to the LLM about the expected value shape (date, number, etc.). """ prompt_path = os.path.join(os.path.dirname(__file__), "prompt.txt") with open(prompt_path, "r") as f: template = f.read() - return template.format(field=current_field, text=self._transcript_text) + return template.format(field=current_field, type=current_type, text=self._transcript_text) def main_loop(self): timeout = 45 max_retries = 3 total_fields = len(self._target_fields) - for i, field in enumerate(self._target_fields.keys(), 1): - prompt = self.build_prompt(field) + for i, (field, field_type) in enumerate(self._target_fields.items(), 1): + prompt = self.build_prompt(field, field_type if isinstance(field_type, str) else "string") ollama_host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/") ollama_url = f"{ollama_host}/api/generate" diff --git a/src/prompt.txt b/src/prompt.txt index 18d55bc..fdf0508 100644 --- a/src/prompt.txt +++ b/src/prompt.txt @@ -7,5 +7,6 @@ If you don't identify the value in the provided text, return "-1". --- DATA: Target JSON field to find in text: {field} +Expected value type: {type} TEXT: {text} diff --git a/tests/test_api.py b/tests/test_api.py index f9e0f3e..8a184cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -105,10 +105,9 @@ def test_create_template(self, client, mock_controller): assert data["id"] is not None assert data["name"] == "Fire Report" assert data["fields"]["Location"] == "string" - # Controller.create_template was called with the pdf_path - mock_controller["template_ctrl"].create_template.assert_called_once_with( - "src/inputs/fire_report.pdf" - ) + # Plain create just persists the row; commonforms only runs via + # the separate /make-fillable endpoint. + mock_controller["template_ctrl"].create_template.assert_not_called() def test_create_then_list(self, client, mock_controller): """Creating a template should make it appear in the list."""