Back to Blogs

The Tech Stack Behind My Browser-Based PDF Editor

What I Was Trying to Build

A PDF editor that runs in the browser. You drop a PDF in. You can add text anywhere, draw freely, drop shapes, fill in form fields, sign with your mouse or finger. You download the result. The file never leaves your machine.

That's the tool. It exists. It works.

This post is about the parts that were harder than they had any right to be.


The Three Libraries That Made It Possible

For anyone who wants to build something similar, the stack is:

┌─────────────────────────────────────┐
│  Nuxt 4 + Vue 3 + TypeScript        │
├─────────────────────────────────────┤
│  PDF.js          → render pages     │
│  Fabric.js       → annotation layer │
│  PDF-lib         → write the output │
│  Signature Pad   → capture signing  │
└─────────────────────────────────────┘

Each library does one job well.

PDF.js is Mozilla's PDF renderer. It powers the PDF viewer built into Firefox. It takes a PDF and draws pages onto a canvas. That's it. It doesn't edit anything.

Fabric.js is a canvas library for interactive graphics. It handles "user drags a box around, resizes it, types in it, deletes it." It doesn't know what a PDF is.

PDF-lib is a JavaScript library for creating and modifying PDFs. It can add pages, draw shapes, embed images, fill form fields. It doesn't render anything — it just writes bytes.

The editor is the three of them stacked. PDF.js renders the page as a background image. Fabric.js sits on top as a transparent canvas where the user draws. When you click download, PDF-lib reads the original PDF and re-writes it with everything Fabric was holding.


The Coordinate System Problem

This is where I lost two weekends.

PDF coordinates have their origin at the bottom-left of the page. Y increases as you go up. Page coordinates are measured in points (1 point = 1/72 inch).

HTML Canvas coordinates have their origin at the top-left. Y increases as you go down. Coordinates are measured in pixels.

The on-screen display also has its own scale — usually 1.0 to 2.0x depending on what fits the viewport.

So when a user clicks at screen position (200, 400) to place some text, what does that mean to PDF-lib?

screen_x = 200, screen_y = 400
        │
        ▼
display_scale = 1.5
        │
        ▼
canvas_x = 200 / 1.5 = 133
canvas_y = 400 / 1.5 = 267
        │
        ▼
page_height_in_canvas = 1188
        │
        ▼
pdf_x = 133
pdf_y = 1188 - 267 = 921   ← flip Y!

You have to:

  1. Divide by the display scale to get canvas-space coordinates
  2. Flip Y by subtracting from the page height to get PDF-space coordinates
  3. Account for the fact that text in PDFs is positioned by its baseline, not its top-left corner

Get any one of these wrong and the user's text appears in the wrong spot. Get the Y-flip wrong and it appears upside-down relative to where they clicked, which feels like a bug from another dimension.

I now have a pdfToCanvas and canvasToPdf helper that I use everywhere. Anytime I'm tempted to do the math inline I remind myself of the weekends and I call the helper.


Per-Page State

PDFs have multiple pages. The editor renders them stacked vertically. Each page has its own Fabric canvas overlay.

The naive approach is to create one Fabric instance per page. This works for 5-page PDFs and falls over for 500-page ones — you're holding 500 canvas contexts in memory, each running its own event loop.

What I do instead:

  • One Fabric canvas, the active one
  • Each page has a saved snapshot of its annotations (a plain JSON object, not a canvas)
  • When the user scrolls or clicks a different page, save the current page's state to its snapshot, load the new page's snapshot onto the canvas

This means one canvas instance regardless of PDF length. The snapshots are small (a few KB each at most).

const pageStates = new Map<number, FabricState>()

function switchToPage(newIndex: number) {
  pageStates.set(currentPageIndex, canvas.toJSON())
  canvas.clear()
  const newState = pageStates.get(newIndex)
  if (newState) canvas.loadFromJSON(newState)
  currentPageIndex = newIndex
}

This pattern unlocked everything else. Without it, scrolling through a long PDF was unusable.


Writing the Output

When the user clicks download, here's what happens:

  1. Load the original PDF with PDF-lib
  2. For each page that has annotations:
    • Get the saved Fabric state
    • Convert the Fabric canvas to a PNG (with transparent background)
    • Embed that PNG as a full-page overlay on the PDF page using PDF-lib's drawImage
  3. Save the modified PDF
  4. Trigger a download

The overlay approach is the trick. Instead of trying to translate every Fabric object back into PDF-lib calls (which would require handling every shape, font, color, gradient, etc.), I just rasterize the annotation layer to a PNG and slap it on the page.

This has one downside: text in annotations becomes part of an image, so it isn't searchable in the output PDF. For 99% of use cases — signatures, highlights, scribbled notes, form fills — that's fine. For "I'm adding searchable headers to a document" it's the wrong tool.

If you wanted searchable output, you'd need to walk the Fabric object tree and emit PDF-lib drawing calls for each one. That's a much bigger project. I haven't done it. May not.


The Signature Flow

Capturing a signature was its own little adventure.

Signature Pad is a tiny library that does one thing: render a canvas where mouse or touch input gets smoothed into a nice ink stroke. It outputs a base64 PNG when you're done.

The flow:

  1. User clicks "Sign"
  2. A modal opens with a Signature Pad canvas
  3. User draws their signature with mouse or finger
  4. On confirm, the PNG gets added to the Fabric canvas as a draggable image
  5. User positions and resizes it on the page
  6. On save, it gets baked into the PDF along with everything else

The smoothing is what makes it feel professional. Without smoothing, mouse signatures look like a four-year-old's drawing. Signature Pad uses Bezier curves between sample points and the result is honestly pretty good.


Where Performance Got Tight

A few places needed real attention.

Initial PDF render. PDF.js renders one page at a time, asynchronously. For a 100-page document, you don't want to render all of them up-front. I render the visible pages plus a buffer of 2 above and below, then lazy-render the rest as the user scrolls.

Re-rendering on resize. When the browser window resizes, the display scale changes. Every page needs to re-render. This was janky until I debounced the resize handler and only re-rendered visible pages.

Memory. Long PDFs ate memory. Rendered page canvases are big. The fix was the same as the visible-pages trick — keep recently-viewed pages around, garbage-collect the rest by clearing their canvases.

None of this is novel. It's standard "render only what's visible" virtualization. PDFs make it slightly more interesting because the work-per-page is much higher than rendering a list item.


Why Vue/Nuxt for This

I chose Nuxt 4 because the rest of the site is Nuxt and I wanted consistency. But it turned out to be the right call for editor-specific reasons too:

  • Composables map cleanly onto "wrap this library nicely." There's a usePdfRenderer, useFabricEditor, usePdfLib, useSignature. Each one encapsulates a library and exposes a clean API.
  • Reactivity handles state sync — current page, current tool, selection — without me writing a single subscription manually.
  • SSR doesn't help here (the editor is client-only) but doesn't hurt either. The page shell renders server-side, the heavy lifting happens after hydration.

If I were starting over, I'd probably pick the same stack. The pieces fit.


The Whole Thing in One Sentence

The editor is a thin Vue UI over three battle-tested libraries that handle the actual hard parts, with a coordinate-translation layer that took longer to get right than the rest of the app combined.

If you want to see it in action: aayushali.com/tools/pdf-editor.

If you want to build something similar, the libraries are linked above and the patterns are all in this post. The hardest part won't be any single library — it'll be the coordinate math. Build the helpers first.

And if you want to read more about the rest of the toolkit, I wrote about the philosophy behind browser-based PDF tools here.

Enjoyed this?

Share it with your network