@unlev/exeq
v0.6.4
Published
Embeddable PDF form builder and document signing — client-side, no backend required
Maintainers
Readme
Exeq
Embeddable PDF form builder and document signing for the browser. Design form templates on any PDF, then let users fill and sign them — all client-side, no backend required. Your documents never leave the device.
Install
npm install @unlev/exeq
# or
yarn add @unlev/exeqQuick Start
import { DesignerView, SignerView } from '@unlev/exeq';
import '@unlev/exeq/styles';
// Template editor — open a PDF, place fields, export template
function Editor() {
return (
<DesignerView
apiKey="your-api-key"
initialPdfUrl="/contracts/blank.pdf"
onSave={(template) => {
// store the template JSON on your server
}}
/>
);
}
// Signing UI — load a template, pre-fill values, collect signed PDF
function Signer() {
return (
<SignerView
apiKey="your-api-key"
initialPdfUrl="/contracts/template.pdf"
initialTemplate={template}
initialSigner="Signer 1"
initialValues={{
"Full Name": "Jane Smith",
"Email": "[email protected]",
}}
onComplete={(blob) => {
// upload the signed PDF
}}
/>
);
}
// Multi-party signing — signers complete sequentially
function MultiPartySigner() {
return (
<SignerView
apiKey="your-api-key"
initialPdfUrl="/contracts/template.pdf"
initialTemplate={template}
signerOrder={['Signer 1', 'Sender']} // recipient signs first, then sender
onComplete={(blob) => {
// final PDF with all signatures
}}
/>
);
}API
DesignerView Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| apiKey | string | Yes | Your Exeq API key |
| initialPdfUrl | string | No | URL to a PDF to pre-load |
| initialTemplate | Template | No | Existing template to resume editing |
| onSave | (template: Template) => void | No | Called on "Export Template". If omitted, downloads JSON. |
| onChange | (template: Template) => void | No | Fires on every change to fields / signer roles / loaded PDF. Use for draft persistence (e.g. localStorage) so accidental refresh or tab close doesn't lose work. |
SignerView Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| apiKey | string | Yes | Your Exeq API key |
| initialPdfUrl | string | No | URL to the PDF |
| initialTemplate | Template | No | Template with fields and signer roles |
| initialSigner | string | No | Signer role name (default: "Signer 1") |
| initialValues | Record<string, string> | No | Pre-fill fields by label (case-insensitive) or ID |
| callbackUrl | string | No | URL to POST the signed PDF to |
| onComplete | (blob: Blob) => void | No | Callback with the signed PDF Blob |
| submitLabel | string | No | Label for the final submit button (default: "Complete") |
| signerOrder | string[] | No | Signing order for multi-party docs. Signers complete sequentially. Defaults to non-Sender roles first, then Sender. |
| transforms | Record<string, (value: string) => string> | No | Custom transform functions for formula fields. Merges with built-in transforms. |
Formulas
Fields can reference other fields and apply transforms using the syntax {{Source Field Label | transform}}. Formula fields auto-compute their values and are read-only to the signer.
// In the template designer, set a field's formula to:
// {{Contract Date | month2}}/{{Contract Date | day2}}/{{Contract Date | year}}
// {{Full Name | first}}
// {{SSN | last4}}
// Pass custom transforms via the SignerView prop:
<SignerView
initialTemplate={template}
transforms={{
'last4': (value) => value.slice(-4),
'ssn-masked': (value) => `***-**-${value.slice(-4)}`,
'currency': (value) => `$${parseFloat(value).toFixed(2)}`,
'uppercase': (value) => value.toUpperCase(),
}}
/>Built-in transforms:
| Transform | Description | Example |
|-----------|-------------|---------|
| month | Numeric month (1-12) | 3 |
| month2 | Zero-padded month | 03 |
| monthname | Full month name | March |
| monthshort | Short month name | Mar |
| day | Day of month | 5 |
| day2 | Zero-padded day | 05 |
| year | 4-digit year | 2026 |
| year2 | 2-digit year | 26 |
| upper | Uppercase | JANE SMITH |
| lower | Lowercase | jane smith |
| first | First word | Jane |
| last | Last word | Smith |
| initials | First letter of each word | JS |
| last4 | Last 4 characters | 6789 |
| last2 | Last 2 characters | 89 |
| first4 | First 4 characters | 1234 |
| first2 | First 2 characters | 12 |
| digits | Digits only | 1234567890 |
| number | Parse as number | 42 |
| currency | Format as USD | $42.00 |
| trim | Remove whitespace | text |
Custom transforms override built-ins with the same name. Any transform name is valid — use whatever makes sense for your use case.
Utilities
import { renderPdfPages, generateFilledPdf, downloadPdf } from '@unlev/exeq';
// Render PDF pages to images
const pages = await renderPdfPages(pdfUrlOrBytes);
// Generate a filled PDF
const bytes = await generateFilledPdf({ pdfSource, fields });
// Trigger browser download
downloadPdf(bytes, 'signed-document.pdf');generateFilledPdf
generateFilledPdf(opts: FillPdfOptions): Promise<Uint8Array>import { generateFilledPdf, US_LETTER } from '@unlev/exeq';
// Overlay-only: blank Letter pages with no background — for printing
// onto pre-printed physical forms.
const overlay = await generateFilledPdf({
pdfSource: null,
fields,
pageSize: US_LETTER,
});
// Always-Letter output even when the source PDF isn't Letter — useful
// when the source is a scan at slightly different dimensions.
const letter = await generateFilledPdf({
pdfSource: '/forms/template.pdf',
fields,
pageSize: US_LETTER,
});
// Resolve formula fields during render.
const filled = await generateFilledPdf({
pdfSource,
fields,
resolveFormulas: true,
customTransforms: { last4: v => v.slice(-4) },
});| Option | Type | Description |
|--------|------|-------------|
| pdfSource | string \| ArrayBuffer \| null | Background PDF (URL or bytes), or null for overlay-only output. |
| fields | FormField[] | Fields to render on top of the background. |
| pageSize | [number, number] | Override output page size in PDF points (72pt = 1in). With a source, source pages are drawn stretched to fit. Defaults to source dims, or Letter if no source. |
| pageCount | number | When pdfSource is null, how many blank pages to render. Default 1. |
| resolveFormulas | boolean | Run resolveAllFormulas on fields before rendering. Default false. |
| customTransforms | TransformMap | Merged with BUILTIN_TRANSFORMS during formula resolution. |
| calibration | Calibration | Linear offset/scale applied to field positions. |
createPdfBuilder — batch / mail merge
For multi-record output (e.g. one PDF with one page per recipient), createPdfBuilder merges records into a single document and dedupes shared resources — the source PDF, fonts, and signature PNGs are embedded once and referenced from every page.
import { createPdfBuilder, downloadPdf, US_LETTER } from '@unlev/exeq';
const builder = await createPdfBuilder({ pageSize: US_LETTER });
for (const record of records) {
await builder.addRecord({
pdfSource: '/forms/template.pdf', // embedded once, referenced N times
fields: record.fields,
resolveFormulas: true,
});
}
const bytes = await builder.save();
downloadPdf(bytes, 'merged.pdf');A 12-record batch with a 700 KB background goes from ~8 MB (12 separate generateFilledPdf calls, then merged with pdf-lib) to ~705 KB (one embed shared across pages).
PdfBuilder.doc exposes the underlying pdf-lib document if you need to add an audit trail or table-of-contents page before saving.
Calibration
When a printed overlay doesn't quite register with a pre-printed physical form (because the scan was cropped differently than the real paper, or the printer tray has a small offset), apply a linear calibration. Offsets are in PDF points (72pt = 1 inch).
import { applyCalibration, generateFilledPdf, US_LETTER } from '@unlev/exeq';
// Either pass calibration to the renderer:
const bytes = await generateFilledPdf(pdfSource, fields, {
pageSize: US_LETTER,
calibration: { xOffset: -5, yOffset: 3, xScale: 1.005, yScale: 1 },
});
// …or apply it as a standalone transform (useful for previews):
const adjusted = applyCalibration(fields, { xOffset: -5, yOffset: 3, xScale: 1.005, yScale: 1 });| Field | Type | Meaning |
|-------|------|---------|
| xOffset | number (pt) | Horizontal shift; positive = right. |
| yOffset | number (pt) | Vertical shift; positive = down (screen coords). |
| xScale | number | Multiplier on x and width. |
| yScale | number | Multiplier on y and height. |
Page-size constants
import { US_LETTER, US_LEGAL, A4 } from '@unlev/exeq';
// US_LETTER === [612, 792]
// US_LEGAL === [612, 1008]
// A4 === [595.28, 841.89]Types
import type { FormField, Template, FieldType, RenderedPage } from '@unlev/exeq';Field Types
| Type | Description |
|------|-------------|
| text | Text input (subtypes: freeform, number, date, email, phone) |
| signature | Freehand signature drawing (supports ink color) |
| initials | Smaller freehand drawing for initials |
| signed-date | Auto-filled date when signer signs |
| checkbox | Toggle checkbox |
| blackout | Black redaction rectangle (designer only) |
| whiteout | White redaction rectangle (designer only) |
Additional Components
| Component | Description |
|-----------|-------------|
| PdfViewer | Low-level PDF page renderer with draggable field overlays |
| SignatureCanvas | Freehand signature/initials drawing canvas |
| FieldPropertyPanel | Field property editor (type, label, assignee) |
| FieldNavigator | Prev/Next navigation through signer fields |
| SignerRoleSelector | Manage signer roles |
Privacy
All PDF processing happens client-side in the browser. Documents are never uploaded to any server. The npm package is self-hosted — it bundles into your app and serves from your infrastructure.
Workflow
- Design — Use
<DesignerView />to open a PDF and place form fields - Pre-fill — Fill Sender fields (company name, your signature, etc.)
- Export — Capture the template JSON via
onSave - Sign — Render
<SignerView />with the template. UseinitialValuesfor mail-merge. - Collect — Receive the signed PDF via
onComplete
CDN Usage
For non-React apps, use the embed script:
<script src="https://unpkg.com/@unlev/exeq/dist/embed.global.js"></script>
<div id="signer" style="width:100%;height:800px"></div>
<script>
Exeq.sign({
target: '#signer',
apiKey: 'your-api-key',
pdf: '/contract.pdf',
fields: '/template.json',
onComplete: (blob) => { /* signed PDF */ }
});
</script>Documentation
Full docs at exeq.org/docs
License
MIT — Unleavened LLC
