I Didn't Know PDFs Have Input Boxes: Filling IRS Tax Forms Programmatically

I Didn't Know PDFs Have Input Boxes: Filling IRS Tax Forms Programmatically

Someone asked me to design a way to print computed values onto U.S. tax forms. The interesting part is the layer in between. They already have the taxpayer’s numbers, and the IRS form is a fixed template that never changes. What’s missing is a portable description that says, for every box on the page, which value goes there, where the box sits, and how the number should look. Someone else’s code reads that description and does the printing.

I picked Form 1040 and Schedule C, downloaded the real PDFs, and immediately started worrying about the boring part. Page one of a 1040 has 128 boxes on it. Every one of them needs an x, a y, a width and a height, accurate to about a point, or the number lands on top of a printed label instead of inside its box. I was mentally budgeting an afternoon of opening the PDF in a viewer, hovering over boxes, and writing down numbers I’d have to double-check anyway.

The coordinates were already in the file

Before starting that, I opened one of the PDFs to see what I was dealing with. The IRS ships these as AcroForm documents: interactive PDFs, the kind you can type into in Preview or Acrobat. Every one of those typeable boxes is a real object inside the file, and every one carries its own rectangle.

I had been opening PDFs for years without ever thinking about this. The coordinates I was about to spend an afternoon measuring by hand were sitting in the file already, exact, put there by whoever built the form at the IRS.

Pulling them out with pdf-lib took about twenty lines. A field can own more than one widget, since the same logical field is sometimes drawn in several places, so you walk widgets rather than fields. Each widget carries its rectangle in /Rect and a reference to its page in /P:

const doc = await PDFDocument.load(pdfBytes);
const pageIndex = new Map(doc.getPages().map((p, i) => [p.ref.toString(), i]));

for (const field of doc.getForm().getFields()) {
  for (const widget of field.acroField.getWidgets()) {
    const { x, y, width, height } = widget.getRectangle();
    const pageRef = widget.dict.get(PDFName.of("P")) as PDFRef | undefined;
    boxes.push({
      id: field.getName(),
      page: pageRef ? pageIndex.get(pageRef.toString()) ?? 0 : 0,
      rect: { x, y, w: width, h: height },
    });
  }
}

One run produced a skeleton with every box on both forms, in PDF points, on the right page. This is the entry for the box the taxpayer’s first name goes into:

{
  "id": "topmostSubform[0].Page1[0].f1_14[0]",
  "page": 0,
  "rect": { "x": 36, "y": 684, "w": 215.25, "h": 14.001 }
}

x: 36 is exactly half an inch in from the left edge, y: 684 puts the box’s bottom edge 684 points up a 792-point page, and the box is 215.25 points wide. Nobody measured any of that. The afternoon I’d budgeted turned into a few minutes of waiting.

There’s a catch, and it’s the interesting half. The names are stable and completely opaque. The file knows precisely where the box is and has no idea it’s where your first name goes.

So I wrote a second small script that stamps the form with its own field ids. It outlines every widget rectangle in red and prints the id inside it in tiny blue type, then saves that as a new PDF.

page.drawRectangle({ x, y, width, height, borderColor: rgb(1, 0, 0), borderWidth: 0.4 });
page.drawText(shortId, { x: x + 0.5, y: y + 1, size: 4.5, font, color: rgb(0, 0, 1) });

A section of Form 1040 with every input box outlined in red and labelled with its internal field id in small blue type: f1_14 in the first name box, f1_15 in the last name box, f1_17 and f1_18 on the spouse row.

Open that next to the skeleton and the mapping is there to read. f1_14 is “Your first name and middle initial”, f1_15 is the last name beside it, f1_47 is line 1a, wages.

The map prints only the last segment of each name, which fits inside a box but isn’t always unique. Two different boxes on page one both end in c1_8[0]: one at x: 97.6, the Single checkbox, and one at x: 349.6, Head of household. The full path separates them, and copying by short name alone silently ticks the wrong box.

So the whole thing turns into a lookup, with one limit. Deciding that f1_14 holds the first name is still a human with a marked-up PDF for twenty minutes. What it buys is that the coordinates are never typed by hand and never wrong.

One deliberate decision on top of that. I copied the rectangles into my own format rather than having the renderer look them up from the PDF at draw time. Reading them live would have been less code, but it would have quietly made the format depend on the source form being interactive, and plenty of real forms are scans or have been flattened along the way. So the AcroForm layer gets used once while authoring, and the format itself never knows it existed.

Then the form starts fighting you

With coordinates solved, I assumed the rest was placing strings at points. The form had opinions.

Your SSN is one value in three boxes. The form prints the social security number in a combed field, split 3-2-4 with dividers between the groups. The AcroForm layer disagrees. It hands you one rectangle for the whole thing:

{ "id": "…Page1[0].f1_16[0]", "rect": { "x": 469, "y": 684, "w": 107, "h": 14.001 } }

The field is flagged as a comb with a MaxLen of 9, so typing 123456789 into it in Acrobat works: the viewer spaces the characters across the dividers itself. My renderer draws a string at a point and knows nothing. So this is one of the few places I authored geometry by hand, carving that 107-point box into the three groups the dividers imply, and a field’s rect became either one rectangle or an array of them:

{
  "id": "taxpayer.ssn",
  "type": "ssn",
  "value": { "$ref": "taxpayer.ssn" },
  "split": "chars",
  "rect": [
    { "x": 472, "y": 684, "w": 28, "h": 14 },
    { "x": 508, "y": 684, "w": 20, "h": 14 },
    { "x": 533, "y": 684, "w": 42, "h": 14 }
  ]
}

The renderer strips the value down to nine digits, groups them 3-2-4, and centers one group in each rectangle.

Filing status is five checkboxes over one value. There’s no radio-group concept on the page, just five separate squares where exactly one gets an X. I was about to add a radio field type before noticing I didn’t need one. A checkbox that draws only when its reference equals a given value covers it: five checkbox fields, all pointed at taxpayer.filingStatus, each with a different when.equals. The grouping falls out of the data instead of the type system, which left one less concept in the spec.

The top of a filled Form 1040: the SSN 123-45-6789 spread across three combed boxes, and an X in the Single checkbox under Filing Status.

One value across three boxes, and a checkbox that only draws because the filing status matched.

Schedule C’s expense list repeats. Schedule C is where a sole proprietor reports business profit or loss, and its Part V is a plain list: write down any expense that didn’t fit the categories above, one per row, amount on the right. Nine blank rows, which means eighteen boxes to place.

Part V of a blank Schedule C, Other Expenses, with all eighteen boxes outlined in red and labelled with their field ids: f2_15 through f2_32, description boxes down the left and amount boxes down the right.

The same field map trick, run on Schedule C page two.

Worth looking at what the extraction returns for those eighteen boxes, because only one of the four numbers is doing any work:

description                      amount                       step
f2_15  x 36  y 239.998  w 424.8  f2_16  x 468  y 239.998  w 108
f2_17  x 36  y 215.997  w 424.8  f2_18  x 468  y 215.997  w 108  -24.001
f2_19  x 36  y 191.996  w 424.8  f2_20  x 468  y 191.996  w 108  -24.001
f2_21  x 36  y 167.995  w 424.8  f2_22  x 468  y 167.995  w 108  -24.001
f2_23  x 36  y 143.994  w 424.8  f2_24  x 468  y 143.994  w 108  -24.001
f2_25  x 36  y 119.993  w 424.8  f2_26  x 468  y 119.993  w 108  -24.001
f2_27  x 36  y  95.992  w 424.8  f2_28  x 468  y  95.992  w 108  -24.001
f2_29  x 36  y  71.991  w 424.8  f2_30  x 468  y  71.991  w 108  -24.001
f2_31  x 36  y  47.990  w 424.8  f2_32  x 468  y  47.990  w 108  -24.001

Both x values repeat unchanged down the whole block, both widths repeat, and every row sits exactly 24.001 points below the one above it. Part V was laid out on a 24-point grid, and the extracted coordinates say so plainly enough that you don’t have to measure anything to find out.

Which means pasting all eighteen rectangles into the annotation would be recording the same two rectangles nine times. The block becomes one field instead: declare the two columns once at the first row’s position and give it a stride.

{
  "id": "partV.otherExpenses",
  "type": "repeat",
  "page": 1,
  "from": { "$ref": "otherExpenses" },
  "maxRows": 9,
  "rowHeight": 24,
  "columns": [
    { "id": "description", "type": "text",     "rect": { "x": 36,  "y": 240, "w": 424.8, "h": 12 },
      "value": { "$ref": "$row.description" } },
    { "id": "amount",      "type": "currency", "rect": { "x": 468, "y": 240, "w": 108,   "h": 12 },
      "layout": { "align": "right" }, "value": { "$ref": "$row.amount" } }
  ]
}

$row points at whichever array element the renderer is on. I rounded the stride to a flat 24, which drifts 0.008pt by the ninth row and is invisible on paper.

Stepping down means subtracting, because PDF coordinates put the origin at the bottom-left and count y upward. I got that backwards on the first run and printed a very tidy expense list off the top of the page.

Part V of a filled Schedule C: four expense rows printed from an array, descriptions on the left and right-aligned amounts on the right, totalling 9,350.

Four array elements, one annotation, nine available rows.

Names don’t respect box widths. Boxes are sized for whatever the IRS considered a normal name. Rather than clipping the text or letting it run over the next field, it shrinks to fit: step the font size down half a point at a time until it fits, with a floor of 4pt so an overlong value degrades into something small you can still squint at.

None of these are hard problems on their own. What they did was decide the shape of the format. A field’s rectangle had to become a list of rectangles. A field had to be allowed to draw conditionally, or to act as a template for rows whose count it doesn’t know in advance. Hitting those four cases early meant the format ended up describing real forms rather than the clean one-rectangle-per-value forms I had in my head on day one.

I wrote a reference renderer to prove it could actually drive output. It refuses to draw if the annotation fails schema validation, or if the loaded PDF’s page sizes don’t match the ones the coordinates were authored against. A tax form filled slightly wrong is worse than a tax form that didn’t get filled, so a mismatch is a hard stop rather than a best effort.

What you get at the end of it

Annotating a form now takes a run of the bootstrap, a look at the field map, and twenty minutes of deciding which boxes matter and what they mean. Labelling is the only part a person does.

What that leaves behind is worth more than the filled PDF. The annotation is plain JSON with a schema, so my renderer, under 500 lines of TypeScript, is a consumer rather than the product. Because the coordinates are copied in rather than looked up, the format survives its source: a scanned form with no field layer annotates the same way, and when the IRS ships next year’s 1040 the hash in form.source stops matching, the renderer refuses to draw, and you re-bootstrap the boxes that moved. The alternative is finding out from a taxpayer that their wages printed on top of a printed label.

It computes nothing, on purpose. No expressions, no conditionals past an equality check. Values arrive already computed and the annotation only decides where they land, which keeps a layout format from quietly becoming a small language.

The one piece worth adding, for anyone folding this into a real workflow, is a visual editor: drag boxes on the rendered PDF instead of hand-labelling a skeleton. The bootstrap already knows where every box sits, so an editor only has to handle the naming, and those twenty minutes stop being something you spend again on every new form.

Full size image