DEV Community

Cover image for I added image placement to a browser-based PDF editor. Here's what broke.
vyixor
vyixor

Posted on

I added image placement to a browser-based PDF editor. Here's what broke.

I've been building a browser-based PDF editor on and off for a few months. Everything runs client-side, nothing gets uploaded. The tools include merge, split, rotate, and a reader mode where you can place movable, resizable elements on top of a page.

Text, watermarks, and signatures were already working. This week I added images. It should have been an afternoon. It took two days. Here's what got in the way.

The architecture that made it easy
The key thing is that the tool doesn't bake anything into the PDF until the user clicks "Apply." Elements live in a JavaScript array, get rendered as absolutely positioned DOM nodes over a canvas, and only get embedded into the actual PDF bytes when the user is done arranging.

Each element looks roughly like this:

{
  id: 17,
  type: 'image',
  pageIdx: 2,
  x: 24.5,  // percentage of page width
  y: 61.2,  // percentage of page height
  w: 30,    // percentage of page width
  h: 12.4,  // percentage of page height
  props: { dataURL: 'data:image/png;base64,...' }
}
Enter fullscreen mode Exit fullscreen mode

Percentages instead of pixels. That's the thing that makes everything else work. The same element array renders correctly whether the page is displayed at 50% zoom or 300%, and when it's finally embedded into the PDF, the percentages translate directly to PDF coordinates without any adjustment.

The DOM overlay does the interaction: pointer events for drag, touch events for mobile, handles for resize. It's the same pattern the signature tool already used. All I had to do was teach the array about one more type.

What broke first: pdf-lib only embeds PNG and JPEG
pdf-lib has embedPng() and embedJpg(). That's it. No WebP, no AVIF, no GIF.

Users don't know that. If you give them a file picker that accepts image/*, they'll hand you a WebP every second try. So the picker has to lie less than reality.

The fix is a canvas re-encode step before the image ever reaches pdf-lib:

async function loadImageForPlacement(file) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const url = URL.createObjectURL(file);
    img.onload = () => {
      URL.revokeObjectURL(url);

      // Downscale if the source is huge
      const maxDim = 2000;
      const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
      const w = Math.round(img.naturalWidth * scale);
      const h = Math.round(img.naturalHeight * scale);

      const c = document.createElement('canvas');
      c.width = w;
      c.height = h;
      const cx = c.getContext('2d');
      cx.imageSmoothingEnabled = true;
      cx.imageSmoothingQuality = 'high';
      cx.drawImage(img, 0, 0, w, h);

      resolve({
        dataURL: c.toDataURL('image/png'),
        aspect: img.naturalWidth / img.naturalHeight
      });
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode failed')); };
    img.src = url;
  });
}
Enter fullscreen mode Exit fullscreen mode

Two things are happening here. First, the image is decoded by the browser (which knows every format the user's OS knows) and re-encoded as PNG. Second, anything over 2000 pixels on the longest side gets downscaled before it's stored.

The downscale matters more than I expected. Someone dropping a 12 megapixel phone photo into an invoice should not triple the PDF's file size. Cap it at 2000 and the user cannot tell the difference on a letter page, but the resulting PDF stays small.

What broke second: the coordinate system
PDF coordinates have their origin at the bottom-left. Browser coordinates have their origin at the top-left. Every element position needs to be flipped on the Y axis before it's drawn into the PDF.

My overlay stores Y as a percentage from the top, because that's how the DOM works. When embedding, the Y needs to become a distance from the bottom:

const { width, height } = pg.getSize();

const xPt  = el.x / 100 * width;
const yRaw = height - (el.y / 100 * height); // flip
const wPt  = el.w / 100 * width;
const hPt  = el.h / 100 * height;

pg.drawImage(embeddedPng, {
  x: xPt,
  y: Math.max(0, yRaw - hPt), // drawImage expects the bottom-left of the image
  width: wPt,
  height: hPt
});
Enter fullscreen mode Exit fullscreen mode

The extra yRaw - hPt is the second thing to get wrong. drawImage in pdf-lib takes the bottom-left corner of the image, not the top-left. So the flipped Y needs the element's own height subtracted from it.

I got this wrong the first time and placed every image one image-height too high. It looked correct on small elements and hilariously wrong on big ones.

What broke third: aspect ratio during placement
The scale slider in the sidebar controls the element width. Height needs to derive from the source image's aspect ratio.

But the overlay works in percentages of page width and page height, which are not the same number. A 30% width and a 30% height on an A4 page represent different pixel dimensions.

The math that ends up correct:

const ov = document.querySelector(`.page-overlay[data-pi="${pageIdx}"]`);
const pw = +ov.dataset.pw;
const ph = +ov.dataset.ph;
const pageAspect = pw / ph;

const w = TS.image.scale; // e.g. 30
const h = w * pageAspect / imageAspect;
Enter fullscreen mode Exit fullscreen mode

If the user picks a wide landscape image and the page is portrait, the height comes out small. If they pick a tall portrait image on a landscape page, the height comes out large. That's what you want.

If you forget the pageAspect term and just do h = w / imageAspect, everything looks fine on square pages and progressively wrong as the page gets wider or narrower.

What I'd do differently
Cache the embedded PNG. Right now every Apply cycle re-decodes every dataURL and re-embeds every image. For a single-document session that's fine, but if a user places the same logo on twenty pages, it's embedded twenty times. pdf-lib doesn't deduplicate.

The fix would be a small cache keyed by dataURL, but for the current use case it isn't worth the code yet.

Handle transparency more explicitly. PDF viewers render alpha in PNG correctly. But if the user exports to JPEG later, the transparency turns black. I could warn on JPEG export. I haven't.

Test with the weird stuff. A 4:1 panoramic image, a 1:4 vertical banner, a 1×1 pixel transparent PNG. All of them work but each one was a small surprise while I was building.

The point
The pattern that made this feature possible is separating "the thing the user sees" from "the thing the PDF contains." Once the overlay is a data model, adding a new type of element is additive. Text, watermark, signature, and now image are all the same architecture with different rendering.

That's the part I got right early. It's also the part that made the two days of debugging tolerable.

The editor is part of SORO, a set of browser-based file tools I've been building. It's at SORO File Tools if you want to look.

If you've got a similar overlay system in a browser tool and you're adding a new element type, the two things that will cost you hours are PDF's bottom-left origin and pdf-lib's PNG/JPEG-only embed API. Everything else is repainting what you already built.

Top comments (0)