Guides

PDF layers

Last updated on

Fill existing PDF forms using AcroForm field bindings

In this guide, we'll use Paradoc to fill out an IRS W-9 form. The W-9 is a common tax document that businesses use to collect taxpayer identification information from contractors and vendors. It's a great example because it has text fields, checkboxes, and split fields (like SSN broken into three boxes) — all patterns you'll encounter when working with PDF forms.

About AcroForm Fields

AcroForm is the original interactive PDF forms technology introduced by Adobe. When a PDF lets you type into text fields, check boxes, or select dropdowns, those elements are AcroForm fields. Each field has an internal (often non-semantic, like Page1.f1_01[0].top) name, which Paradoc uses to map your data to the correct field in the PDF.

Set Up Your Project

Create a new project, set up the directory structure, and download the W-9 form:

mkdir my-w9-project && cd my-w9-project
mkdir -p layers src output
npm init -y
curl -o layers/w9.pdf https://www.irs.gov/pub/irs-pdf/fw9.pdf

This downloads the W-9 from the IRS: https://www.irs.gov/pub/irs-pdf/fw9.pdf

Your project structure should now look like this:

my-w9-project/
├── layers/
│   └── w9.pdf          # PDF with AcroForm fields
├── src/                # Your code goes here
├── output/             # Generated filled PDFs
└── package.json

Install

Install the SDK, unified renderer, and filesystem resolver.

npm install @paradoc/sdk @paradoc/render @paradoc/resolvers

Inspect PDF Fields

Before creating bindings, discover the AcroForm field names in your PDF template using inspectAcroFormFields.

import { inspectAcroFormFields } from '@paradoc/render/pdf'
import { readFileSync } from 'node:fs'

const template = readFileSync('layers/w9.pdf')
const fields = await inspectAcroFormFields(new Uint8Array(template))

console.log(JSON.stringify(fields, null, 2))
// [
//   { "name": "topmostSubform[0].Page1[0].f1_01[0]", "type": "text" },
//   { "name": "topmostSubform[0].Page1[0].f1_02[0]", "type": "text" },
//   { "name": "topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]", "type": "checkbox" },
//   ...
// ]

This reveals the internal field names that you'll map to your form fields.

Define the Form

Create a form with a PDF file layer. The bindings object maps PDF field names to your form field expressions.

The pdf layer is file-backed, so bind a resolver when you construct the form — it reads layers/w9.pdf, and every instance you derive from w9Form later carries the same resolver.

import { p } from '@paradoc/sdk'
import { createFsResolver } from '@paradoc/resolvers/fs'

const resolver = createFsResolver({ root: '.' })

const w9Form = p.form({
  name: 'w9-tax-form',
  fields: {
    name: { type: 'text', label: 'Name', required: true },
    businessName: { type: 'text', label: 'Business Name' },
    taxClassification: {
      type: 'enum',
      label: 'Tax Classification',
      enum: [{ value: 'individual' }, { value: 'c_corp' }, { value: 's_corp' }, { value: 'partnership' }, { value: 'llc' }],
      required: true,
    },
    llcTaxCode: { type: 'text', label: 'LLC Tax Code' },
    address: { type: 'address', label: 'Address', required: true },
    ssn: { type: 'text', label: 'SSN' },
    ein: { type: 'text', label: 'EIN' },
  },
  defaultLayer: 'pdf',
  layers: {
    pdf: {
      kind: 'file',
      mimeType: 'application/pdf',
      path: 'layers/w9.pdf',
      bindings: {
        // Simple field binding
        'topmostSubform[0].Page1[0].f1_01[0]': 'name',
        'topmostSubform[0].Page1[0].f1_02[0]': 'businessName',

        // Checkbox bindings for enum values
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]': 'taxClassification:individual',
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[1]': 'taxClassification:c_corp',
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[2]': 'taxClassification:s_corp',
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[3]': 'taxClassification:partnership',
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[5]': 'taxClassification:llc',
        'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].f1_03[0]': 'llcTaxCode',

        // Nested path for complex types
        'topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_07[0]': 'address.line1',

        // Combined fields (joined with comma)
        'topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_08[0]': 'address.locality,address.region,address.postalCode',

        // Split fields (SSN split across 3 inputs)
        'topmostSubform[0].Page1[0].f1_11[0]': 'ssn:1',
        'topmostSubform[0].Page1[0].f1_12[0]': 'ssn:2',
        'topmostSubform[0].Page1[0].f1_13[0]': 'ssn:3',

        // Split fields (EIN split across 2 inputs)
        'topmostSubform[0].Page1[0].f1_14[0]': 'ein:1',
        'topmostSubform[0].Page1[0].f1_15[0]': 'ein:2',
      },
    },
  },
}, { resolver })

A binding writes a choice into a text box as the option's value, not its label. Give an enum the values the paper form asks for, such as C, S, and P for the LLC code box, and keep the labels for people reading them.

Fill the Form

Create a runtime instance by filling the form with data.

const filled = w9Form.fill({
  fields: {
    name: 'John Smith',
    taxClassification: 'individual',
    address: {
      line1: '123 Main Street',
      locality: 'San Francisco',
      region: 'CA',
      postalCode: '94105',
      country: 'US',
    },
    ssn: '123-45-6789',
  },
})

if (!filled.isValid()) {
  console.log('Errors:', filled.validate().errors)
}

Render to PDF

Render the filled form. createLayerRenderer() selects the PDF implementation from the layer's MIME type, and the resolver bound to w9Form loads the template from the filesystem — filled carries it too, so the render call doesn't repeat it.

import { createLayerRenderer } from '@paradoc/render'
import { writeFileSync } from 'node:fs'

const output = await filled.render({
  renderer: createLayerRenderer(),
  layer: 'pdf',
})

writeFileSync('output/w9-filled.pdf', output)

The output is a Uint8Array containing the filled PDF document with all AcroForm fields populated. Each value follows its field's own settings: font size, alignment, comb boxes, and multiline wrapping. The SSN parts land one digit per box. A value too long for its field fails with PdfFieldFillError, naming the field, rather than being clipped. See Field appearance.

paradoc validate finds these failures before any data is filled. It lays out a typical value at each bound field's length limit, so give every bound text field a maxLength (or a pattern) that its box can hold. See Checking bindings before filling.

Latin-1 values fill in Helvetica with no font configuration. To fill names in Latin Extended, Greek, Cyrillic, or CJK scripts, declare a TrueType font on the PDF layer. The bound resolver reads it beside the PDF, and it is embedded in the output:

pdf: {
  kind: 'file',
  mimeType: 'application/pdf',
  path: 'w9.pdf',
  font: { path: 'fonts/NotoSans-Regular.ttf' },
  bindings: { /* ... */ },
}

Scripts that need shaping, such as Arabic or Devanagari, are refused; render those documents from a React composition layer. See Fonts for the full font order.

Read a Filled PDF Back

The same bindings work in reverse. Given a completed W-9, extract returns the form data and a report for each field:

import { readFileSync } from 'node:fs'

const { data, report } = await w9Form.extract(readFileSync('output/w9-filled.pdf'))
const fillResult = w9Form.safeFill(data)
if (!fillResult.success) throw fillResult.error
const draft = fillResult.data

The combined city, state, and ZIP code box cannot be split back into its parts, so the report marks those fields not_recoverable and data leaves them out. See Extracting data from a filled PDF.

You've now created a form that fills an existing PDF template using field bindings. The binding syntax handles common patterns like checkboxes for enum values, split fields for formatted data like SSN/EIN, and combined fields for address components.

On this page