Guide
A fixed-row invoice template, and the line item that doesn't fit
An invoice template with an AcroForm has a fixed number of
line-item rows baked in at authoring time: ten fields named
item_1 through item_10, say, each
paired with a qty_N and price_N. That
works until an order has eleven items, and then the eleventh
one has nowhere to go. The API will fill the ten fields you
send it without complaint; it just has no field named
item_11 to put the eleventh row into.
Why a template can't grow a row on request
The AcroForm spec doesn't have a concept of a repeating row. A
field is a fixed object at a fixed position on a fixed page,
placed there when the PDF was authored in Acrobat or
LibreOffice Draw. Adding a field at request time isn't on the
table either: /api/fill-form
only fills fields that already exist in the uploaded PDF, and
a field name it doesn't recognize comes back as a 400
unknown_field rather than a new row appearing on
the page. So an invoice with a variable number of items needs
a different shape of template, not a bigger one.
Split into pages, not rows
The fix is a one-page continuation template with the same ten row fields as the main page, minus the header and the totals block, and a running count instead of a hard limit. Eleven items becomes two filled pages: the main template holds items one through ten, the continuation template holds item eleven alone, and the two get merged in page order. Thirty items becomes a main page plus two full continuation pages. The row count per page stays fixed; what varies is how many pages get generated and merged.
Totals only go on the last page
If every continuation page repeats a running subtotal, a customer glancing at page two of three sees a number that looks like the invoice total but isn't, and that's a worse failure than a plain unlabeled row. Compute the grand total once, from the full line-item list, and only pass it into the fields payload for whichever page turns out to be last; every other continuation page gets its ten row fields and nothing else.
The script
Chunking the items into pages of ten, building a fields
payload per chunk, and calling fill-form once per chunk is the
whole algorithm. The main template and the continuation
template are two different PDF files on disk, so the loop
picks whichever one matches the current chunk's position,
fills it, and collects the resulting buffer. Once every chunk
has been filled, one call to
/api/merge with the
buffers in order produces the finished invoice, main page
first, continuation pages after it in the same order the items
appeared in.
import { readFile } from "node:fs/promises";
const API_KEY = process.env.PDFOPS_API_KEY;
const ROWS_PER_PAGE = 10;
function chunk(items, size) {
const out = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
function rowFields(items) {
const fields = {};
items.forEach((item, i) => {
fields[`item_${i + 1}`] = item.name;
fields[`qty_${i + 1}`] = String(item.qty);
fields[`price_${i + 1}`] = item.price.toFixed(2);
});
return fields;
}
async function fillPage(templatePath, fields) {
const template = await readFile(templatePath);
const form = new FormData();
form.append("pdf", new Blob([template]), "template.pdf");
form.append("fields", JSON.stringify(fields));
form.append("flatten", "true");
const res = await fetch("https://pdfops.dev/api/fill-form", {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form,
});
if (!res.ok) throw new Error(`fill-form failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
async function buildInvoice(customer, items) {
const pages = chunk(items, ROWS_PER_PAGE);
const total = items.reduce((sum, i) => sum + i.qty * i.price, 0);
const buffers = [];
for (let i = 0; i < pages.length; i++) {
const isFirst = i === 0;
const isLast = i === pages.length - 1;
const fields = {
...(isFirst ? { customer_name: customer } : {}),
...rowFields(pages[i]),
...(isLast ? { grand_total: total.toFixed(2) } : {}),
};
const templatePath = isFirst ? "./invoice-main.pdf" : "./invoice-continuation.pdf";
buffers.push(await fillPage(templatePath, fields));
}
if (buffers.length === 1) return buffers[0];
const form = new FormData();
for (const buf of buffers) form.append("pdf", new Blob([buf]), "page.pdf");
const res = await fetch("https://pdfops.dev/api/merge", {
method: "POST",
headers: { "X-API-Key": API_KEY },
body: form,
});
if (!res.ok) throw new Error(`merge failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
A thirty-item order costs four API calls here: three
fill-form calls, one per page, and one merge call to stitch
the pages together. A ten-item order that fits on the main
page alone costs one fill-form call and no merge call, since
buffers.length === 1 skips straight to returning
the single filled page.
Try it
A free key from /docs/signup covers this comfortably: a ten-row template split across a couple of continuation pages per invoice is two or three calls, well inside the 250 a month a free key includes, enough to test the chunking logic against a real template before wiring it into an order pipeline.