PDFPipe

Migrating from PDFKit / An imperative drawing API

Migrating off PDFKit for Node, from a stream of calls to markup

A Node drawing library with a chainable API and a streaming output model, where the document is a sequence of calls and the stream is the part that changes shape.

What ports and what does not

The data layer. The drawing calls do not. PDFKit's chained text, moveDown, rect and image calls express the layout as a running cursor, which is the same model as every other drawing API and equally unportable. The distinctive thing here is the output: PDFKit pipes to a stream, so call sites are written around a stream rather than a buffer, and moving to a request that returns bytes changes the shape of the surrounding code as much as the document code.

What the call becomes

new PDFDocument, the call sequence and the pipe become a POST to /v1/pdf with html. The constructor's size becomes options.format, layout becomes options.landscape, and the margins option becomes options.margin. addPage becomes break-before: page. The pipe to a response stream becomes sending the bytes you got back, which for a web handler is usually simpler and for a large batch is worth thinking about, because you now hold the whole document in memory rather than streaming it.

Side by side

The old shape and the new one, with the parts that have no counterpart called out in comments rather than quietly omitted.

js
// Before
const doc = new PDFDocument({ size: "A4", margins: { top: 50, bottom: 50, left: 40, right: 40 } });
doc.pipe(res);
doc.fontSize(18).text("Invoice", { align: "left" });
doc.moveDown();
doc.fontSize(10);
rows.forEach((r) => {
  doc.text(r.desc, { continued: true }).text(r.amt, { align: "right" });
});
doc.end();

// After
const html = renderInvoice({ rows, total });

const r = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
             "Content-Type": "application/json" },
  body: JSON.stringify({
    html,
    options: {
      format: "A4",
      margin: { top: "50pt", bottom: "50pt", left: "40pt", right: "40pt" },
    },
  }),
});
res.type("application/pdf").send(Buffer.from(await r.arrayBuffer()));

/* The margins were in points and points are valid CSS, so they
   transcribe directly. The continued flag, which kept text on the same
   line, becomes ordinary inline layout. moveDown becomes margin. */

The CSS delta, in both directions

What the old engine accepted that a current one does not, and what it refused that a current one wants. The second direction is the one people forget, and it is where the value of the migration is.

  • Nothing to migrate. The constructor's margins are in points, which is a CSS length, so they transcribe without conversion.
  • The continued option, which PDFKit used to keep two text calls on one line, becomes inline elements and disappears as a concept.
  • moveDown becomes margin-bottom, and the running cursor disappears with it.
  • Real tables become available. PDFKit has no table primitive, so every tabular layout in the old code is hand-positioned columns, and all of that collapses into a table element.

What changes without anything erroring

The dangerous list. Each of these produces a different document and no diagnostic, so none of them are caught by a test that only checks the render succeeded.

  • Everything visual, because it is a rewrite.
  • Memory behaviour, from streaming a document as it is produced to holding a complete one. That matters for large batches and not at all for a single invoice.
  • Page count.
  • Fonts, from registerFont to @font-face.

What to diff before cutting over

Diff extracted text. Then, if you generate large documents or many at once, measure memory before and after, because the streaming model was doing something for you that the request model does not.

The one thing that always breaks

The streaming call sites. PDFKit's pipe made the handler's response the document's destination, so error handling, content-length and the point at which headers are sent are all built around a stream that produces bytes over time. Replacing it with a buffer changes all three, and a handler that set headers before the document existed will now set them at the wrong moment.

Frequently asked

Do I have to rewrite my templates to leave PDFKit?

Effectively yes, and it is better to plan for that than to discover it. The old tool did not have HTML templates to port, so the document has to be expressed as markup for the first time. The value in the old code is the data layer underneath the drawing, and that part survives untouched.

Can I run both for a while?

Yes, and it is the safest way to do it. Put both behind one internal function that takes your data and returns bytes, switch on an environment variable, and run the new path on real traffic while the old one still serves. You get a diff on real documents rather than on fixtures, and you keep a way back that does not involve a deploy.

What about the documents already generated?

Nothing here changes them. They are files that already exist. What is worth deciding before the cutover is whether a regenerated document has to match the original byte for byte or merely say the same thing, because for anything with a legal or audit character the answer is usually to keep the original file rather than to be able to reproduce it.

Other migrations

The nearest neighbours first, then others that started from the same kind of tool, because the model matters more than the language.

Render one of your existing documents through the playground before changing any code. That comparison is the whole of the risk assessment for this migration.