# PDF layers

Fill existing PDF forms using AcroForm field bindings

Canonical URL: https://docs.paradoc.dev/guides/pdf-layers/

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:

**npm**

```bash
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
```

**pnpm**

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

**yarn**

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

**bun**

```bash
mkdir my-w9-project && cd my-w9-project
mkdir -p layers src output
bun 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](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**

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

**pnpm**

```bash
pnpm add @paradoc/sdk @paradoc/render @paradoc/resolvers
```

**yarn**

```bash
yarn add @paradoc/sdk @paradoc/render @paradoc/resolvers
```

**bun**

```bash
bun add @paradoc/sdk @paradoc/render @paradoc/resolvers
```

## Inspect PDF Fields

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

```ts
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.

```ts
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.

```ts
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.

```ts
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](/sdk/renderers/pdf#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](/sdk/renderers/pdf#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:

```ts
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](/guides/react-layers). See
[Fonts](/sdk/renderers/pdf#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:

```ts
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](/sdk/artifacts/form#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.
