DEV Community

PDFops
PDFops

Posted on Originally published at pdfops.dev

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());
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)