Forms that fill in the browser, save as JSON.
Embed a .jdf form on any web page. The user types, ticks, picks, signs. Click Save — they download the same .jdf with their answers baked in. No backend, no PDF tooling, no server-side AcroForm pipeline. Just one tag and one file.
What's wrong with PDF forms
PDF AcroForm
- Browsers' built-in PDF viewers handle widgets inconsistently — fill fields, hit refresh, lose everything.
- "Save filled PDF" usually requires Acrobat Reader or a server with iText / pdftk / qpdf.
- The filled PDF is still a binary blob — your RAG re-parses every form to extract one new value.
- Generating the form is a binary-format job. Most stacks reach for a service.
- Validating user input is on you — the format has no schema for it.
JDF Forms
- Renders identically everywhere jdf.js loads — Chrome, Safari, Firefox, mobile, embedded webviews.
- Save is a one-click browser download. No backend, no Acrobat, no plugin.
- Filled form is plain JSON. Your RAG indexes
name → valuedirectly.jqreads it.git diffshows what changed. - Generating the form is
JSON.stringify(doc). LLMs and code-gen pipelines emit forms natively. - JSON Schema validates structure.
required,pattern,readonlyare first-class.
How it works in three pieces
Author the form
One JSON document. Five element types: input, textarea, checkbox, select, signature. Each has a stable name — that's the value lookup key.
Embed on your page
One <jdf> tag. Add save-button to drop a button in the corner. jdf.js renders real <input> / <textarea> / <canvas> elements and tracks the user's input live.
User saves the filled JDF
Browser downloads customer-form.jdf. Open in a text editor — values are right there. Re-open in jdf.js and the form resumes pre-filled. Export to PDF with jdf export; pipe to RAG with jdf validate.
Try it — fill the form, click Save
Open the downloaded file in any text editor. Every value you typed is right there next to its field declaration — no binary parsing required.
Element types
| Type | Renders as | Value field | Use for |
|---|---|---|---|
input |
<input> |
value: string |
Single-line text. inputType picks the variant — text / number / email / url / tel / date / time / datetime-local / password / color. |
textarea |
<textarea> |
value: string |
Multi-line text. rows sets initial height; users can drag-resize. |
checkbox |
<input type="checkbox"> |
checked: boolean |
Boolean toggles, agreement boxes, "subscribe to newsletter". |
select |
<select> |
value: string or values: string[] when multiple: true |
Dropdowns, country pickers, multi-tag selectors. |
signature |
<canvas> with pointer drawing |
value: string (base64 PNG) |
Drawn signatures. The PNG bytes live inline in the JDF — no external upload, no third-party signature service. |
Common field properties
Every form element shares these attributes — encoded the same way as the underlying HTML so authors don't have to learn a new shape:
name(required) — stable key for value lookup. Required by the schema.label— text rendered above / next to the field.required— boolean validation hint.readonly— disable editing while still showing the current value.position/width/height— same layout fields as every other element type.style— inline style or named style ref.
Hello, JDF Form
A complete fillable form, copy-pasteable:
{
"$jdf": "1.0.0",
"meta": { "title": "Customer onboarding", "pageSize": "A4", "unit": "mm" },
"pages": [{
"id": "page-1",
"elements": [
{ "type": "text", "content": "Onboarding", "heading": 1,
"position": { "x": 20, "y": 15 }, "width": 170 },
{ "type": "input", "name": "fullName", "label": "Full name",
"inputType": "text", "required": true,
"position": { "x": 20, "y": 40 }, "width": 170, "height": 12 },
{ "type": "input", "name": "email", "label": "Email",
"inputType": "email", "required": true,
"position": { "x": 20, "y": 60 }, "width": 170, "height": 12 },
{ "type": "select", "name": "country", "label": "Country",
"options": [{"value":"tr", "label":"Turkey"}, {"value":"us", "label":"USA"}],
"position": { "x": 20, "y": 80 }, "width": 80 },
{ "type": "checkbox", "name": "newsletter",
"label": "Subscribe to the newsletter",
"position": { "x": 20, "y": 100 }, "width": 170 },
{ "type": "signature", "name": "signature", "label": "Signature",
"position": { "x": 20, "y": 120 }, "width": 100, "height": 30 }
]
}]
}
Embed it
<link rel="stylesheet" href="https://unpkg.com/@uurtech/jdf@latest/dist/jdfjs.css">
<script type="module" src="https://unpkg.com/@uurtech/jdf@latest"></script>
<jdf src="customer-form.jdf"
save-button="Save form"
save-filename="filled-form.jdf"
height="640"></jdf>
Embed attributes
save-button— adds a Save button in the embed's corner. Set to a string to override the label (save-button="Download").save-filename— explicit download filename. Defaults to<meta.title>.jdf.- Standard
<jdf>attributes still apply:height,width,zoom,fit,dark-mode.
Programmatic API
The class form gives you direct access to form values without involving the Save button:
// import the embed factory
import { embed } from "@uurtech/jdf";
const viewer = await embed("#form", "/customer-form.jdf");
// One-shot read
viewer.getFormValues();
// → { fullName: "Jane Doe", email: "jane@x.com", country: "tr",
// newsletter: true, signature: "data:image/png;base64,iVBOR..." }
viewer.getFormValue("fullName"); // "Jane Doe"
// Live subscription — auto-save, dirty tracking, validation hooks
const v2 = await embed("#form", "/customer-form.jdf", {
onFormChange: (doc, change) => {
fetch("/api/draft", { method: "POST", body: JSON.stringify(doc) });
},
});
// Manual download — wire it to your own button
viewer.downloadJdf("my-form.jdf");
// Or grab the blob — upload it, store it in IndexedDB, do whatever
const blob = viewer.exportJdf();
await fetch("/api/submit", {
method: "POST",
body: blob,
headers: { "Content-Type": "application/jdf+json" },
});
Method reference
| Method | Returns | What it does |
|---|---|---|
getFormValue(name) | string | boolean | string[] | Reads one field by its name. Checkboxes return boolean, multi-selects return string[], everything else returns string. |
getFormValues() | { [name]: value } | Flat map of every form field's current value. |
exportJdf({ pretty? }) | Blob | Current document as a Blob with mime application/jdf+json. Default is pretty-printed. |
toJSON({ pretty? }) | string | Same content as exportJdf() but as a string — for direct fetch bodies, console logs, etc. |
downloadJdf(filename?) | void | Trigger a browser download. Without a name, uses <meta.title>.jdf. |
document (getter) | JdfDocument | Live reference to the current document. Reading it after the user typed reflects the latest values. |
PDF AcroForm import
Existing PDFs with form fields convert one-to-one. The CLI walks the PDF's widget annotations and emits matching JDF form elements with their current values intact:
# PDF AcroForm → JDF with form elements + filled values
$ jdf convert w9-form.pdf -o w9-form.jdf
$ jdf validate w9-form.jdf
✓ Valid: w9-form.jdf
Format: 1.0.0
Pages: 1
Elements: 18
# Open in jdf.js — fields are pre-filled with whatever the PDF had
<jdf src="w9-form.jdf" save-button></jdf>
| PDF widget type | JDF element | Notes |
|---|---|---|
Tx (text), single-line | input | inputType: "text"; fieldValue → value. |
Tx with multi-line flag | textarea | Detected via field flags bit 13. |
Btn (checkbox) | checkbox | checked = true when fieldValue ≠ "Off". |
Btn (radio) | (currently dropped) | JDF 1.0 doesn't have a native radio element type yet — radio groups are flattened away on import. |
Btn (pushbutton) | (skipped) | No form-value semantics, ignored. |
Ch (combo / list) | select | combo flag → dropdown; multiSelect flag → multiple: true with values[]. |
Sig (signature) | signature | Existing signature appearance is dropped; the user re-signs in the embed. |
RAG / pipeline ingestion
A filled JDF form is structured data your retriever can chunk per field. No more parsing PDF widget streams or running OCR on filled pages:
# CI gate: every submitted form must pass schema validation
$ jdf validate inbox/*.jdf || exit 1
# Then jq the responses straight into your retriever
$ jq -s 'map(.. | select(.type=="input" or .type=="select") | {name, value})' \
inbox/*.jdf
[
{ "name": "fullName", "value": "Jane Doe" },
{ "name": "email", "value": "jane@example.com" },
{ "name": "country", "value": "tr" }
]
The same query works on every form in your corpus, regardless of who authored it. There is no per-document parser to write, no font-extraction step, no OCR fallback.
value fields, write the JSON back to disk), and the same file flows through a viewer for human review or final signature. PDF requires a different tool at every stage.
Reader / desktop parity
The same five element types render in the desktop reader. A form filled in jdf.js opens in the desktop reader with the same values; a form filled in the desktop reader exports to PDF (via Cmd+Shift+E) with the user's values flattened to printable text and an underline for blank fields. Round-tripping is intentional — the document is the source of truth, the runtime is interchangeable.