DEV Community

Usman Ali
Usman Ali

Posted on AI-assisted

Merge, Interleave and Print PDFs as Booklets in the Browser with pdf-lib

You can merge PDFs, fix the page order of double-sided scans and even lay out a printable booklet without sending a single file to a server. Everything below runs in the browser with pdf-lib, a small library that reads and writes PDFs in plain JavaScript.

I use this approach in the browser-based tools on WoowPDF, and these are the patterns that held up with real user files. The PDF functions here were tested against pdf-lib 1.17.

Why process PDFs in the browser?

Client-side processing has three practical benefits:

  • Privacy: contracts, bank statements and IDs never leave the user's device.
  • Cost: no upload bandwidth, no queue, no storage to clean up.
  • Speed: for page-level jobs (merge, split, reorder) the bottleneck is usually the upload, not the work.

The limit is memory. A 300 MB scanned PDF on a phone will struggle, so keep heavy jobs (OCR, Office conversion) on a server and do page operations locally.

Setup

npm install pdf-lib
Enter fullscreen mode Exit fullscreen mode

Getting the bytes from a file input is one line:

const buffers = await Promise.all([...input.files].map((f) => f.arrayBuffer()));
Enter fullscreen mode Exit fullscreen mode

And getting the result back to the user:

function download(bytes, name) {
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
  const a = Object.assign(document.createElement("a"), { href: url, download: name });
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Enter fullscreen mode Exit fullscreen mode

1. Merge several PDFs

The key method is copyPages. A page belongs to one document, so you copy it into the output document before adding it:

import { PDFDocument } from "pdf-lib";

async function mergePdfs(buffers) {
  const out = await PDFDocument.create();
  for (const buf of buffers) {
    const src = await PDFDocument.load(buf);
    const pages = await out.copyPages(src, src.getPageIndices());
    pages.forEach((p) => out.addPage(p));
  }
  return out.save(); // Uint8Array
}
Enter fullscreen mode Exit fullscreen mode

Copying keeps text selectable and file size stays close to the sum of the inputs, because the page content streams are copied as they are, not re-rendered.

2. Interleave front and back scans

A common office problem: the scanner only does one side, so you scan all the fronts, flip the stack, and scan all the backs. You end up with two files, and the backs are in reverse order.

async function interleave(frontBuf, backBuf) {
  const out = await PDFDocument.create();
  const front = await PDFDocument.load(frontBuf);
  const back = await PDFDocument.load(backBuf);

  const fp = await out.copyPages(front, front.getPageIndices());
  // The back stack was flipped, so its last page belongs after page 1.
  const bp = await out.copyPages(back, back.getPageIndices().reverse());

  for (let i = 0; i < Math.max(fp.length, bp.length); i++) {
    if (fp[i]) out.addPage(fp[i]);
    if (bp[i]) out.addPage(bp[i]);
  }
  return out.save();
}
Enter fullscreen mode Exit fullscreen mode

Make the .reverse() an option in your UI. Some scanners with a document feeder already output the backs in the right order.

3. Print a PDF as a folded booklet

This is the fun one. To print a booklet you fold in half and staple in the middle (saddle stitch), each sheet holds four pages, two on the front and two on the back, and they are not in reading order.

For an 8 page document the sheet sides are:

Sheet side Left slot Right slot
Sheet 1 front page 8 page 1
Sheet 1 back page 2 page 7
Sheet 2 front page 6 page 3
Sheet 2 back page 4 page 5

The pattern is easy to generate. Pad the page count up to a multiple of 4, then walk inwards from both ends:

function bookletOrder(n) {
  // n is a multiple of 4; returns [left, right] page indices per sheet side
  const sides = [];
  for (let i = 0; i < n / 4; i++) {
    sides.push([n - 1 - 2 * i, 2 * i]);     // front of sheet i
    sides.push([2 * i + 1, n - 2 - 2 * i]); // back of sheet i
  }
  return sides;
}

bookletOrder(8); // [[7,0],[1,6],[5,2],[3,4]]
Enter fullscreen mode Exit fullscreen mode

To place two pages side by side on one landscape sheet, use embedPdf and drawPage instead of copyPages. Embedding turns each page into a reusable form object you can draw at any position and scale:

async function makeBooklet(buf) {
  const src = await PDFDocument.load(buf);
  const count = src.getPageCount();
  const n = Math.ceil(count / 4) * 4; // blank pages fill the gap

  const out = await PDFDocument.create();
  const embedded = await out.embedPdf(src, src.getPageIndices());
  const { width, height } = src.getPage(0).getSize();

  for (const side of bookletOrder(n)) {
    const sheet = out.addPage([width * 2, height]);
    side.forEach((idx, slot) => {
      if (idx >= count) return; // leave filler slots blank
      sheet.drawPage(embedded[idx], { x: slot * width, y: 0, width, height });
    });
  }
  return out.save();
}
Enter fullscreen mode Exit fullscreen mode

Print the result double-sided, flipping on the short edge, then fold the stack in half. A 6 page PDF becomes 4 sheet sides (2 sheets of paper) with 2 blank pages at the end.

If your pages have mixed sizes, scale each one to fit its slot: const s = Math.min(slotW / e.width, slotH / e.height) and centre it. For right-to-left books (Arabic, Hebrew, manga), swap the two slots on every side.

Pitfalls I hit with real files

  1. Encrypted PDFs. PDFDocument.load throws on encrypted files. Catch the error and tell the user to remove the password first. { ignoreEncryption: true } lets the load succeed, but the output is often unreadable, so only use it when you know the file has an owner password and no user password.
  2. Rotated pages. Scanned pages often carry a /Rotate value. copyPages keeps it, but drawPage ignores it, so read page.getRotation().angle and rotate the drawing when you embed.
  3. Huge files on mobile. Load one file at a time, release references as soon as pages are copied, and show progress. Yielding to the browser between pages (await new Promise(r => setTimeout(r))) keeps the UI responsive.
  4. Previews need a renderer. pdf-lib writes PDFs but does not draw them. Use pdf.js (pdfjs-dist) to render thumbnails so users can check the order before downloading.

Wrapping up

With copyPages for page-level edits and embedPdf plus drawPage for layout, you can cover most everyday PDF chores client-side, with no backend and no files leaving the user's device.

If you want to see these running with a drag-and-drop UI, try the PDF booklet maker and the merge PDF tool on WoowPDF. I'm happy to answer questions about edge cases in the comments.

Top comments (0)