You render a PDF with pdf.js, let the user click where the signature goes, and draw it with pdf-lib. It works on every test file you have. Then someone uploads a scan, and the signature shows up sideways, in the wrong corner, or off the page.

The bug is almost never in your click handling. It comes from two coordinate systems that agree on ordinary pages and disagree on the ones that come from scanners.

Two coordinate systems

What pdf-lib draws in. page.drawImage() and friends work in the page's raw user space. The origin is at the bottom-left of the MediaBox, y points up, and the page's /Rotate entry is ignored.

What the user sees. A viewer, pdf.js included, shows the CropBox (the visible part of the page), turned by /Rotate, with the origin at the top-left and y pointing down.

On a plain portrait page with no crop, the difference is a y-flip:

const userY = page.getHeight() - viewY; 

That's the line most tutorials give you. It's correct until one of these shows up:

  • /Rotate 90 (or 180, 270). Scanners often store a landscape page as portrait pixels plus a rotate flag. The viewer turns it. pdf-lib doesn't, so your x and y axes are swapped, and the image is drawn rotated relative to what the user saw.
  • A CropBox smaller than the MediaBox. The viewer shows only the crop. Your click coordinates are relative to the crop's corner, but pdf-lib measures from the MediaBox corner, so everything is shifted.
  • Both at once. That's common in real scanned contracts.

The mapping

For a point in view space (points, top-left origin, y down) on a page whose visible box starts at (bx, by) and is bw × bh points:

/Rotate user-space x user-space y
0 bx + vx by + bh - vy
90 bx + vy by + vx
180 bx + bw - vx by + vy
270 bx + bw - vy by + bh - vx

The displayed width and height also swap for 90 and 270. Drawing an image upright means passing the matching rotation to pdf-lib, and accounting for the fact that pdf-lib rotates around the image's own origin, not its centre. That last part is where most hand-rolled fixes break.

One more edge case: pdf.js treats a /Rotate value that isn't a multiple of 90 as 0. Do the same, or your output won't match what the user saw.

A small library for it

I pulled this logic into pdf-view-coords, a small MIT TypeScript helper for pdf-lib. You stay in view space, and it does the translation:

import { PDFDocument } from "pdf-lib"; import { viewport, drawImageInView, rectFromFractions } from "pdf-view-coords"; const pdf = await PDFDocument.load(bytes); const page = pdf.getPage(0); const png = await pdf.embedPng(signaturePng); // The box the user drew, as fractions of the rendered page size. const vp = viewport(page); const rect = rectFromFractions(vp, { x: 0.1, y: 0.8, width: 0.3, height: 0.08 }); drawImageInView(page, png, rect); // right place, upright, aspect ratio kept 

viewport(page) also gives you toUser(x, y) and toView(x, y) if you'd rather draw yourself. The tests check every rotation, with and without a crop, and confirm that the area pdf-lib actually paints matches the view-space rectangle you asked for.

The live demo runs in the browser. Load your own PDF, click on a rotated page, and compare where plain pdf-lib puts the box with where the library puts it.

Install it from GitHub for now:

npm install github:AlonDrilich/pdf-view-coords pdf-lib 

If you've hit a PDF where it still lands wrong, open an issue with the file. Odd real-world PDFs are exactly what it needs to be tested against.