DEV Community

Silkline
Silkline

Posted on

Bates numbering PDFs in the browser with pdf-lib: shrink the page, then read the numbers back

A Bates number is a label printed on every page of a set of documents: usually a short prefix and a running number, like ABC-000251, ABC-000252, and so on. No two pages in the set share one, so anyone can point at a single page and find it again.

I added Bates numbering to a browser tool that turns .eml and .msg emails into PDFs. Stamping a string at the bottom of a page sounds like ten lines of pdf-lib. It wasn't. Here is what the code does and why.

The format: prefix, padding, start number

The prefix is optional and limited to A–Z, a–z, 0–9, space and - _ . /, up to 20 characters, because the number is drawn in Helvetica, a standard PDF font. A character Helvetica can't draw is refused up front, with its code point in the message.

The number gets at least six digits. If the last number in the batch needs more, every page in that batch gets the wider width, so the numbers still sort as text:

export function batesStamp(options: BatesOptions, totalPages: number): BatesStamp {
  const digits = Math.max(BATES_DIGITS, String(options.start + totalPages - 1).length);
  return { ...options, digits };
}

export function batesNumber(stamp: BatesStamp, pageIndex: number): string {
  return `${stamp.prefix}${String(stamp.start + pageIndex).padStart(stamp.digits, '0')}`;
}
Enter fullscreen mode Exit fullscreen mode

A batch starting at 999990 with 20 pages runs from ABC-0999990 to ABC-1000009: seven digits on all 20 pages.

The start number (1 to 9999999) accepts 1,353, full-width digits and leading zeros. Paste SMITH-001353 while the prefix is SMITH- and the prefix is taken off, but only after the input fails as a plain number, so a digit prefix can't eat part of the number.

Where the number goes on someone else's page

The number sits bottom right, 22 points up, black, 8pt, shrinking to as little as 4pt on receipts and labels. An optional grey "Page x of y" goes in the middle and moves up a line if the two would come within 6 points.

"Bottom right" means as displayed. Other people's PDFs carry /Rotate and CropBoxes that differ from the MediaBox, so the visible area is the CropBox clipped to the MediaBox, and every point is mapped from displayed coordinates into unrotated page space, with the angle the text needs:

export function displayedPoint(width: number, height: number, rotation: number, across: number, up: number): FooterPlacement {
  const turn = ((rotation % 360) + 360) % 360;
  if (turn === 90) return { x: width - up, y: across, rotate: 90 };
  if (turn === 180) return { x: width - across, y: height - up, rotate: 180 };
  if (turn === 270) return { x: up, y: height - across, rotate: 270 };
  return { x: across, y: up, rotate: 0 };
}
Enter fullscreen mode Exit fullscreen mode

The tests run this over portrait, landscape, all three rotations, crop boxes inside and sticking out of the sheet, MediaBoxes off the origin, scans and a 226×152 label, checking that the number reads upright at the bottom right.

Don't cover anything: shrink the content

The obvious approach is a white box with the number on top. But an attached PDF is someone's evidence, and a box can hide a line of it. So the tool shrinks the page content evenly, just enough to leave an empty strip at the bottom, keeping its top edge and horizontal centre:

export function makeRoomAtBottom(page: PDFPage, band: number): void {
  const box = visibleBox(page);
  const turn = page.getRotation().angle;
  const [across, down] = sideways(turn) ? [box.height, box.width] : [box.width, box.height];
  if (band <= 0 || band >= down) return;
  const factor = (down - band) / down;
  const fixed = displayedPoint(box.width, box.height, turn, across / 2, down);
  const move: Move = { factor, shift: { x: (1 - factor) * (box.x + fixed.x), y: (1 - factor) * (box.y + fixed.y) } };
  moveContent(page, move, box);
  moveAnnotations(page, move);
}
Enter fullscreen mode Exit fullscreen mode

moveContent wraps the existing streams in q … cm … Q and clips to the visible box, so what the CropBox hid (the black edge of a scan) stays hidden. moveAnnotations applies the same transform to link Rect and QuadPoints.

The nasty detail: real content streams don't always balance their graphics state. An extra q leaves the wrapper open, so the number lands inside the shrink; an extra Q closes it early. So the content is counted first, extra qs are opened for stray Qs to close, and stray qs are closed before the wrapper's own Q.

Pages the tool rendered itself already have a wide enough bottom margin, so only copied PDF pages are shrunk.

Read the numbers back before the download

Computing the right numbers isn't the same as writing them. After stamping, the tool loads the bytes it's about to hand you and reads every page's number. The stamp is drawn last, so the check takes the last <hex> Tj in the last content stream, trusted only if the streams balance their q/Q:

function lastShownText(page: PDFPage): string | null {
  const streams = contentStreamBytes(page);
  const last = streams?.at(-1);
  if (!streams || !last || !isBalanced(streams.slice(0, -1)) || !isBalanced([last])) return null;
  const shown = [...new TextDecoder('latin1').decode(last).matchAll(SHOWN_TEXT)].at(-1);
  return shown ? fromHex(shown[1]) : null;
}
Enter fullscreen mode Exit fullscreen mode

Each page is compared with its expected number, counting across all output files. One wrong page fails the export with the page and its source file named, and nothing is downloaded. Tests cover a repeat, a gap and missing pages.

A file that can't be numbered (encrypted, unreadable, empty, an undecodable page) is listed with the reason and skipped. Files are also rechecked just before numbering, so one that changed after you added it can't shift everyone else's numbers.

Emails and their attachments in one sequence

The merged email export sorts oldest first; undated emails go last, in the order they were opened:

export function sortByDate(emails: readonly ParsedEmail[]): ParsedEmail[] {
  const time = (email: ParsedEmail) => email.date?.getTime() ?? Number.POSITIVE_INFINITY;
  return emails
    .map((email, index) => ({ email, index }))
    .sort((a, b) => time(a.email) - time(b.email) || a.index - b.index)
    .map(({ email }) => email);
}
Enter fullscreen mode Exit fullscreen mode

Each email's PDF and image attachments become pages right after it (GIF, WebP and BMP via a canvas); other types are only named. A contents page goes in front, and one stamp numbers the whole document, so an email and its attachment run on without a break. A CSV lists begin and end numbers per email and attachment, with formula-looking cells escaped.

Remembering where you stopped

Productions come in batches. After a numbered export, nextStart = start + pages is saved to localStorage and offered as a "Start the next batch at N" button:

private offerNextBatch(range: BatesRange | null): void {
  if (!range || range.nextStart > BATES_START_MAX) return;
  this.nextStart = range.nextStart;
  this.startBoxAtExport = this.ui.batesStart.value;
  saveNumbering({ start: String(range.nextStart) });
  this.syncNextBatch();
}
Enter fullscreen mode Exit fullscreen mode

Only the prefix, start number and a few checkbox choices are saved, never file content, and each is type-checked on read. Blocked or broken storage just means the page forgets on reload. The start box only changes if you haven't edited it since the export.

It all stays in the tab

Files are read with File.arrayBuffer(), pdf-lib loads when the first PDF is dropped, and the result leaves through a Blob URL. No server step.

Stamping your own PDFs is free for everyone. Numbering emails together with their attachments in one step, sorted by date, is part of the paid Case Pass and Legal plans.

Summary

  • Fix the digit width once per batch, so numbers sort as text.
  • Place text in displayed coordinates. Rotation and CropBox are normal in other people's PDFs.
  • Shrink the content to make room instead of covering it, and move the link annotations with it.
  • Count q/Q before you wrap someone else's content stream.
  • Read the numbers back from the final bytes before you let anyone download them.

If you need to number a set of PDFs without installing anything, the tool is at emltopdf.net/bates-numbering.

This post was written with AI assistance.

Top comments (0)