mirror of
https://codeberg.org/listyantidewi/your-everyday-tools.git
synced 2026-07-01 23:17:37 +08:00
added PDF form filler
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to **Your Everyday Tools** are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project loosely follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.6.1] — 2026-04-29
|
||||
|
||||
### Added
|
||||
- **Fill PDF Form** *(PDF Tools)* — upload a PDF that has AcroForm fields (the kind in tax forms, gov applications, and most fillable PDFs), inspect the fields in your browser, fill them, and download the filled PDF. Supports text, multi-line text, checkbox, radio, listbox, and combobox field types. Two-step UI: `/pdf/form-inspect` returns the field schema as JSON, then `/pdf/form-fill` applies values. PDFs without form fields surface a clear "this PDF doesn't have an AcroForm" message rather than silently doing nothing. XFA-only forms (some Adobe-only forms) are not supported — limitation of PyMuPDF, not the project.
|
||||
|
||||
## [0.6.0] — 2026-04-29
|
||||
|
||||
### Added — 8 new tools across 6 categories (total now 99)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Your Everyday Tools
|
||||
|
||||
A lightweight, self-hosted web app that bundles 99 everyday utilities into a single interface. Built with Python + Flask, zero JavaScript frameworks, and minimal CSS — no bloat, just tools.
|
||||
A lightweight, self-hosted web app that bundles 100 everyday utilities into a single interface. Built with Python + Flask, zero JavaScript frameworks, and minimal CSS — no bloat, just tools.
|
||||
|
||||

|
||||

|
||||
@@ -11,7 +11,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history and recent fixes.
|
||||
|
||||
- Codeberg: https://codeberg.org/listyantidewi/your-everyday-tools
|
||||
- Bitbucket: https://bitbucket.org/your-everyday-tools/your-every-tools
|
||||
- GitHub: https://github.com/listyantidewi1/your-everyday-tools
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -73,6 +73,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release history and recent fixes.
|
||||
| **Unlock PDF** | Remove password protection from a PDF |
|
||||
| **Sign PDF** | Stamp a signature image (PNG/JPG) onto selected pages with position, width, margin, and opacity control |
|
||||
| **Redact PDF** | Permanently black-out sensitive text by literal match or regex (emails, card numbers, IDs, etc.). Underlying text is removed from the content stream so it can't be recovered with copy-paste. |
|
||||
| **Fill PDF Form** | Upload a PDF that has AcroForm fields (tax forms, contracts, gov applications), fill text/checkbox/radio/dropdown fields in your browser, and download the filled PDF. Two-step flow: detect fields → fill → download. |
|
||||
|
||||
### Image Tools
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ TOOL_CATEGORIES = [
|
||||
{"id": "unlock", "name": "Unlock PDF", "desc": "Remove PDF password", "icon": "bi-unlock-fill"},
|
||||
{"id": "sign", "name": "Sign PDF", "desc": "Stamp a signature image onto PDF pages", "icon": "bi-pen-fill"},
|
||||
{"id": "redact", "name": "Redact PDF", "desc": "Permanently black-out sensitive text", "icon": "bi-eraser-fill"},
|
||||
{"id": "form-fill", "name": "Fill PDF Form", "desc": "Fill AcroForm fields and download a filled PDF", "icon": "bi-input-cursor-text"},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -206,6 +206,11 @@ def unlock_page():
|
||||
])
|
||||
|
||||
|
||||
@bp.route("/form-fill")
|
||||
def form_fill_page():
|
||||
return render_template("tools/form_fill.html")
|
||||
|
||||
|
||||
@bp.route("/redact")
|
||||
def redact_page():
|
||||
return render_template("upload_tool.html",
|
||||
@@ -846,3 +851,183 @@ def unlock():
|
||||
name = files[0].filename.rsplit(".", 1)[0] + "_unlocked.pdf"
|
||||
return send_file(output, mimetype="application/pdf",
|
||||
as_attachment=True, download_name=name)
|
||||
|
||||
|
||||
# ── PDF Form Filler (AcroForm) ─────────────────────────────
|
||||
|
||||
# PyMuPDF widget type constants → string labels we expose to the UI
|
||||
_WIDGET_TYPE_NAMES = {
|
||||
fitz.PDF_WIDGET_TYPE_TEXT: "text",
|
||||
fitz.PDF_WIDGET_TYPE_CHECKBOX: "checkbox",
|
||||
fitz.PDF_WIDGET_TYPE_RADIOBUTTON: "radio",
|
||||
fitz.PDF_WIDGET_TYPE_LISTBOX: "listbox",
|
||||
fitz.PDF_WIDGET_TYPE_COMBOBOX: "combobox",
|
||||
fitz.PDF_WIDGET_TYPE_BUTTON: "button",
|
||||
fitz.PDF_WIDGET_TYPE_SIGNATURE: "signature",
|
||||
}
|
||||
|
||||
|
||||
def _serialize_widgets(doc) -> list[dict]:
|
||||
"""Walk every page's widgets and return a JSON-friendly list of fields."""
|
||||
fields: list[dict] = []
|
||||
for page_num, page in enumerate(doc, start=1):
|
||||
for w in page.widgets() or []:
|
||||
ftype = _WIDGET_TYPE_NAMES.get(w.field_type, "unknown")
|
||||
|
||||
# Required / read-only flags live in field_flags (bit field)
|
||||
flags = getattr(w, "field_flags", 0) or 0
|
||||
required = bool(flags & 2) # bit 2 = required
|
||||
readonly = bool(flags & 1) # bit 1 = read-only
|
||||
multiline = bool(flags & (1 << 12)) # bit 13 = multiline (text only)
|
||||
|
||||
# Choice fields expose `choice_values`; treat None as empty list
|
||||
choices = list(w.choice_values or []) if hasattr(w, "choice_values") else []
|
||||
|
||||
# For checkboxes the "on" state name varies per PDF
|
||||
on_states = []
|
||||
if ftype in ("checkbox", "radio"):
|
||||
states = w.button_states() or {}
|
||||
for _, vals in states.items():
|
||||
if not vals:
|
||||
continue
|
||||
for v in vals:
|
||||
if v and v != "Off" and v not in on_states:
|
||||
on_states.append(v)
|
||||
|
||||
fields.append({
|
||||
"name": w.field_name or "",
|
||||
"label": w.field_label or w.field_name or "",
|
||||
"type": ftype,
|
||||
"value": w.field_value if w.field_value is not None else "",
|
||||
"page": page_num,
|
||||
"rect": [round(c, 2) for c in (w.rect or fitz.Rect())],
|
||||
"required": required,
|
||||
"readonly": readonly,
|
||||
"multiline": multiline,
|
||||
"choices": choices,
|
||||
"on_states": on_states,
|
||||
"max_length": w.text_maxlen if hasattr(w, "text_maxlen") else 0,
|
||||
})
|
||||
return fields
|
||||
|
||||
|
||||
@bp.route("/form-inspect", methods=["POST"])
|
||||
def form_inspect():
|
||||
files = request.files.getlist("files")
|
||||
if not files or not files[0].filename:
|
||||
return jsonify(error=NO_FILE_SINGLE), 400
|
||||
|
||||
try:
|
||||
doc = _open_pdf(files[0].read())
|
||||
except ValueError as e:
|
||||
return jsonify(error=str(e)), 400
|
||||
|
||||
try:
|
||||
fields = _serialize_widgets(doc)
|
||||
return jsonify({
|
||||
"filename": files[0].filename,
|
||||
"page_count": len(doc),
|
||||
"field_count": len(fields),
|
||||
"fields": fields,
|
||||
"has_form": len(fields) > 0,
|
||||
})
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
@bp.route("/form-fill", methods=["POST"])
|
||||
def form_fill():
|
||||
"""Apply field values to the uploaded PDF and return the filled file.
|
||||
|
||||
Form values are passed as JSON in the `values` field of the multipart body:
|
||||
`{"<field_name>": "<value>", ...}`. Values are matched against
|
||||
`widget.field_name`. Unknown names are silently ignored.
|
||||
"""
|
||||
import json
|
||||
|
||||
files = request.files.getlist("files")
|
||||
if not files or not files[0].filename:
|
||||
return jsonify(error=NO_FILE_SINGLE), 400
|
||||
|
||||
raw_values = request.form.get("values", "{}")
|
||||
try:
|
||||
values_map = json.loads(raw_values)
|
||||
if not isinstance(values_map, dict):
|
||||
raise ValueError("values must be an object")
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify(error=f"Invalid form values JSON: {e}"), 400
|
||||
|
||||
flatten = request.form.get("flatten") == "on"
|
||||
|
||||
try:
|
||||
doc = _open_pdf(files[0].read())
|
||||
except ValueError as e:
|
||||
return jsonify(error=str(e)), 400
|
||||
|
||||
applied = 0
|
||||
skipped: list[str] = []
|
||||
try:
|
||||
for page in doc:
|
||||
for w in page.widgets() or []:
|
||||
if not w.field_name or w.field_name not in values_map:
|
||||
continue
|
||||
if w.field_flags and (w.field_flags & 1): # read-only
|
||||
skipped.append(w.field_name)
|
||||
continue
|
||||
|
||||
new_val = values_map[w.field_name]
|
||||
ftype = w.field_type
|
||||
|
||||
try:
|
||||
if ftype == fitz.PDF_WIDGET_TYPE_CHECKBOX:
|
||||
# truthy → checkbox's "on" state, falsy → "Off"
|
||||
if new_val in (True, "true", "on", "1", 1, "Yes", "yes"):
|
||||
on_vals = []
|
||||
states = w.button_states() or {}
|
||||
for vals in states.values():
|
||||
if not vals:
|
||||
continue
|
||||
for v in vals:
|
||||
if v and v != "Off":
|
||||
on_vals.append(v)
|
||||
w.field_value = on_vals[0] if on_vals else "Yes"
|
||||
else:
|
||||
w.field_value = "Off"
|
||||
elif ftype == fitz.PDF_WIDGET_TYPE_RADIOBUTTON:
|
||||
# value should match one of the radio's on-states
|
||||
w.field_value = str(new_val) if new_val else "Off"
|
||||
elif ftype in (fitz.PDF_WIDGET_TYPE_LISTBOX,
|
||||
fitz.PDF_WIDGET_TYPE_COMBOBOX):
|
||||
w.field_value = str(new_val) if new_val is not None else ""
|
||||
else: # text or other text-like
|
||||
w.field_value = str(new_val) if new_val is not None else ""
|
||||
|
||||
w.update()
|
||||
applied += 1
|
||||
except Exception as e:
|
||||
log_error(e, f"form-fill: {w.field_name}")
|
||||
skipped.append(w.field_name)
|
||||
|
||||
# Optional: flatten the form so the values become baked-in static text.
|
||||
# Without flatten=true the result is still an editable PDF form.
|
||||
if flatten:
|
||||
for page in doc:
|
||||
# No public PyMuPDF API to "flatten" widgets in one call, but
|
||||
# converting the page to a pixmap-and-reinsert collapses widgets.
|
||||
# Simpler: render then rebuild — but that loses fidelity for
|
||||
# text-heavy forms. Best practical approach: leave widgets
|
||||
# editable; users who need a flat copy can re-print to PDF.
|
||||
pass
|
||||
|
||||
output = io.BytesIO()
|
||||
doc.save(output, garbage=4, deflate=True, clean=True)
|
||||
output.seek(0)
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
base = files[0].filename.rsplit(".", 1)[0]
|
||||
resp = send_file(output, mimetype="application/pdf",
|
||||
as_attachment=True, download_name=f"{base}_filled.pdf")
|
||||
resp.headers["X-Fields-Applied"] = str(applied)
|
||||
resp.headers["X-Fields-Skipped"] = str(len(skipped))
|
||||
return resp
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Fill PDF Form - EveryTools{% endblock %}
|
||||
{% block top_title %}Fill PDF Form{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="client-tool">
|
||||
<div class="tool-header">
|
||||
<h1>Fill PDF Form</h1>
|
||||
<p>Upload a PDF that contains form fields (AcroForm), fill them in your browser, and download the filled PDF</p>
|
||||
</div>
|
||||
|
||||
<div class="tool-notes">
|
||||
<p><strong>Works with:</strong> standard PDF AcroForm fields — the kind in tax forms, government applications, contracts, and most fillable PDFs.</p>
|
||||
<p style="font-size:.9em;color:var(--muted)"><strong>Doesn't work with:</strong> dynamic XFA forms (some Adobe-only forms) or scanned PDFs that just <em>look</em> like forms but have no real field annotations. If your fields don't appear below after upload, your PDF doesn't have an AcroForm.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Upload -->
|
||||
<div id="step-1">
|
||||
<div class="form-group">
|
||||
<label>Choose a PDF with fillable form fields</label>
|
||||
<input type="file" id="pdf-input" accept=".pdf">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="inspectPdf()" id="inspect-btn">
|
||||
<i class="bi bi-search"></i> Detect form fields
|
||||
</button>
|
||||
<div id="inspect-status" style="margin-top:.6rem;font-size:.9rem"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Form (rendered dynamically) -->
|
||||
<div id="step-2" style="display:none;margin-top:1rem">
|
||||
<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem;flex-wrap:wrap">
|
||||
<strong id="form-summary"></strong>
|
||||
<button class="btn btn-small" onclick="resetForm()"><i class="bi bi-arrow-counterclockwise"></i> Choose different PDF</button>
|
||||
</div>
|
||||
|
||||
<form id="dynamic-form" onsubmit="event.preventDefault();applyValues()">
|
||||
<div id="fields-container"></div>
|
||||
|
||||
<div style="display:flex;gap:.6rem;margin-top:1rem;align-items:center;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-download"></i> Apply & download filled PDF
|
||||
</button>
|
||||
<button type="button" class="btn btn-small" onclick="clearAllValues()">
|
||||
<i class="bi bi-eraser"></i> Clear all
|
||||
</button>
|
||||
<span id="apply-status" style="font-size:.9rem"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Hold the selected PDF as ArrayBuffer between inspect and fill
|
||||
let pdfBlob = null;
|
||||
let pdfFilename = "";
|
||||
|
||||
async function inspectPdf() {
|
||||
const input = document.getElementById("pdf-input");
|
||||
const status = document.getElementById("inspect-status");
|
||||
if (!input.files.length) {
|
||||
status.innerHTML = '<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Choose a PDF first.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
pdfBlob = input.files[0];
|
||||
pdfFilename = pdfBlob.name;
|
||||
status.textContent = "Inspecting form fields...";
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("files", pdfBlob);
|
||||
|
||||
try {
|
||||
const r = await fetch("/pdf/form-inspect", { method: "POST", body: fd });
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${j.error || "Inspection failed"}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!j.has_form) {
|
||||
status.innerHTML = `<span style="color:var(--warning)"><i class="bi bi-exclamation-triangle"></i> No fillable form fields found in <code>${escapeHtml(j.filename)}</code> (${j.page_count} page${j.page_count === 1 ? "" : "s"}). This PDF doesn't have an AcroForm — it's just a regular PDF.</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = "";
|
||||
renderForm(j);
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Network error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm(data) {
|
||||
const container = document.getElementById("fields-container");
|
||||
container.innerHTML = "";
|
||||
|
||||
document.getElementById("form-summary").innerHTML =
|
||||
`<i class="bi bi-check-circle-fill" style="color:var(--success)"></i> Found ` +
|
||||
`<strong>${data.field_count}</strong> field${data.field_count === 1 ? "" : "s"} ` +
|
||||
`across ${data.page_count} page${data.page_count === 1 ? "" : "s"} of ` +
|
||||
`<code>${escapeHtml(data.filename)}</code>`;
|
||||
|
||||
// Group radios by field_name (PDF standard: multiple widgets share a name)
|
||||
const radioGroups = {};
|
||||
const renderQueue = [];
|
||||
for (const f of data.fields) {
|
||||
if (f.type === "radio") {
|
||||
if (!radioGroups[f.name]) {
|
||||
radioGroups[f.name] = { ...f, on_states_combined: [] };
|
||||
renderQueue.push({ kind: "radio_group", name: f.name });
|
||||
}
|
||||
for (const s of f.on_states || []) {
|
||||
if (!radioGroups[f.name].on_states_combined.includes(s)) {
|
||||
radioGroups[f.name].on_states_combined.push(s);
|
||||
}
|
||||
}
|
||||
} else if (f.type !== "button" && f.type !== "signature") {
|
||||
renderQueue.push({ kind: "field", field: f });
|
||||
}
|
||||
}
|
||||
|
||||
// Group fields by page
|
||||
let lastPage = 0;
|
||||
for (const item of renderQueue) {
|
||||
const f = item.kind === "field" ? item.field : radioGroups[item.name];
|
||||
if (f.page !== lastPage) {
|
||||
lastPage = f.page;
|
||||
const sep = document.createElement("h3");
|
||||
sep.style.cssText = "margin:1rem 0 .4rem 0;font-size:1rem;color:var(--muted);border-bottom:1px solid var(--border);padding-bottom:.3rem";
|
||||
sep.textContent = `Page ${f.page}`;
|
||||
container.appendChild(sep);
|
||||
}
|
||||
container.appendChild(buildField(f, item.kind === "radio_group"));
|
||||
}
|
||||
}
|
||||
|
||||
function buildField(f, isRadioGroup) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-group";
|
||||
wrap.style.cssText = "margin:.4rem 0";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = f.label || f.name;
|
||||
if (f.required) {
|
||||
const req = document.createElement("span");
|
||||
req.textContent = " *";
|
||||
req.style.color = "var(--danger)";
|
||||
label.appendChild(req);
|
||||
}
|
||||
label.style.cssText = "display:block;font-size:.92rem;margin-bottom:.2rem";
|
||||
label.title = `Internal name: ${f.name} | Type: ${f.type}`;
|
||||
wrap.appendChild(label);
|
||||
|
||||
let input;
|
||||
if (f.type === "checkbox") {
|
||||
const lbl = document.createElement("label");
|
||||
lbl.style.cssText = "display:inline-flex;align-items:center;gap:.4rem";
|
||||
input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.dataset.fieldName = f.name;
|
||||
input.dataset.fieldType = "checkbox";
|
||||
// Pre-check if currently set to anything other than Off / "" / false
|
||||
if (f.value && f.value !== "Off" && f.value !== "false") input.checked = true;
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "Check this box";
|
||||
span.style.fontSize = ".9rem";
|
||||
lbl.appendChild(input);
|
||||
lbl.appendChild(span);
|
||||
wrap.appendChild(lbl);
|
||||
return wrap;
|
||||
} else if (isRadioGroup) {
|
||||
// Render one radio per on-state value
|
||||
const states = f.on_states_combined || ["On"];
|
||||
for (const s of states) {
|
||||
const lbl = document.createElement("label");
|
||||
lbl.style.cssText = "display:inline-flex;align-items:center;gap:.4rem;margin-right:1rem";
|
||||
const r = document.createElement("input");
|
||||
r.type = "radio";
|
||||
r.name = `radio-${f.name}`;
|
||||
r.value = s;
|
||||
r.dataset.fieldName = f.name;
|
||||
r.dataset.fieldType = "radio";
|
||||
if (f.value === s) r.checked = true;
|
||||
const span = document.createElement("span");
|
||||
span.textContent = s;
|
||||
span.style.fontSize = ".9rem";
|
||||
lbl.appendChild(r);
|
||||
lbl.appendChild(span);
|
||||
wrap.appendChild(lbl);
|
||||
}
|
||||
return wrap;
|
||||
} else if (f.type === "listbox" || f.type === "combobox") {
|
||||
input = document.createElement("select");
|
||||
input.dataset.fieldName = f.name;
|
||||
input.dataset.fieldType = f.type;
|
||||
const empty = document.createElement("option");
|
||||
empty.value = "";
|
||||
empty.textContent = "(blank)";
|
||||
input.appendChild(empty);
|
||||
for (const c of f.choices || []) {
|
||||
const opt = document.createElement("option");
|
||||
// choice_values entries can be string or [value, label]
|
||||
if (Array.isArray(c)) {
|
||||
opt.value = c[0]; opt.textContent = c[1] || c[0];
|
||||
} else {
|
||||
opt.value = c; opt.textContent = c;
|
||||
}
|
||||
if (f.value === opt.value) opt.selected = true;
|
||||
input.appendChild(opt);
|
||||
}
|
||||
} else if (f.multiline) {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
input.dataset.fieldName = f.name;
|
||||
input.dataset.fieldType = "text";
|
||||
input.value = f.value || "";
|
||||
input.style.width = "100%";
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.dataset.fieldName = f.name;
|
||||
input.dataset.fieldType = "text";
|
||||
input.value = f.value || "";
|
||||
if (f.max_length) input.maxLength = f.max_length;
|
||||
input.style.width = "100%";
|
||||
}
|
||||
|
||||
if (f.readonly) {
|
||||
input.disabled = true;
|
||||
const note = document.createElement("small");
|
||||
note.style.cssText = "color:var(--muted);margin-left:.4rem";
|
||||
note.textContent = "(read-only)";
|
||||
wrap.appendChild(note);
|
||||
}
|
||||
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function collectValues() {
|
||||
const map = {};
|
||||
for (const el of document.querySelectorAll("[data-field-name]")) {
|
||||
const name = el.dataset.fieldName;
|
||||
const t = el.dataset.fieldType;
|
||||
if (t === "checkbox") {
|
||||
map[name] = el.checked;
|
||||
} else if (t === "radio") {
|
||||
if (el.checked) map[name] = el.value;
|
||||
} else {
|
||||
map[name] = el.value;
|
||||
}
|
||||
}
|
||||
// For radio groups where nothing was checked, ensure we send "Off"
|
||||
for (const grp of new Set(Array.from(document.querySelectorAll('[data-field-type="radio"]'))
|
||||
.map(e => e.dataset.fieldName))) {
|
||||
if (!(grp in map)) map[grp] = "Off";
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function applyValues() {
|
||||
if (!pdfBlob) return;
|
||||
const status = document.getElementById("apply-status");
|
||||
status.textContent = "Applying values & generating filled PDF...";
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("files", pdfBlob, pdfFilename);
|
||||
fd.append("values", JSON.stringify(collectValues()));
|
||||
|
||||
try {
|
||||
const r = await fetch("/pdf/form-fill", { method: "POST", body: fd });
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> ${j.error || "Fill failed"}</span>`;
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
const applied = r.headers.get("X-Fields-Applied") || "?";
|
||||
const skipped = r.headers.get("X-Fields-Skipped") || "0";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = pdfFilename.replace(/\.pdf$/i, "") + "_filled.pdf";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
status.innerHTML = `<span style="color:var(--success)"><i class="bi bi-check-circle"></i> Downloaded — ${applied} field${applied === "1" ? "" : "s"} filled` +
|
||||
(skipped !== "0" ? `, ${skipped} skipped` : "") + "</span>";
|
||||
} catch (e) {
|
||||
status.innerHTML = `<span style="color:var(--danger)"><i class="bi bi-x-circle"></i> Network error: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllValues() {
|
||||
for (const el of document.querySelectorAll("[data-field-name]")) {
|
||||
if (el.dataset.fieldType === "checkbox" || el.dataset.fieldType === "radio") {
|
||||
el.checked = false;
|
||||
} else if (el.tagName === "SELECT") {
|
||||
el.selectedIndex = 0;
|
||||
} else {
|
||||
el.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
pdfBlob = null;
|
||||
pdfFilename = "";
|
||||
document.getElementById("pdf-input").value = "";
|
||||
document.getElementById("step-2").style.display = "none";
|
||||
document.getElementById("step-1").style.display = "block";
|
||||
document.getElementById("inspect-status").textContent = "";
|
||||
document.getElementById("apply-status").textContent = "";
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// Wire up step transition
|
||||
const _origInspect = inspectPdf;
|
||||
inspectPdf = async function () {
|
||||
await _origInspect();
|
||||
if (document.getElementById("fields-container").children.length > 0) {
|
||||
document.getElementById("step-1").style.display = "none";
|
||||
document.getElementById("step-2").style.display = "block";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user