Extension

Puff PDF

The browser will not let you draw on its PDF viewer, so this replaces the viewer. What you draw exports back into a real PDF that opens anywhere.

By Mihir Jataniya2026Live
Puff PDF landing page
The landing page, which is itself a sheet of paper you can scribble on.

What it is

Puff PDF is a browser extension that turns any PDF into something you can write on. Open a PDF link, or drag a file in, and it loads into a viewer with a pen, a brush, shapes and arrows, two kinds of highlighter, text boxes, images, sticky notes and an eraser. Markup is saved as you go and comes back the next time the same file is opened.

Export writes a genuine PDF with the annotations baked into the page content. It opens correctly in Acrobat, in Preview, and anywhere else, including after the extension has been uninstalled. Nothing is a proprietary sidecar file and nothing depends on the extension still being installed to be readable.

It runs from one codebase on Chromium browsers and on Firefox, and there is no build step at all. Every first-party file is plain, unminified JavaScript, and everything it does happens on the device: no servers, no accounts, no uploads.

How it is built

01

Replacing a viewer you are not allowed to draw on

Every browser ships a PDF viewer, and every one of them is a closed plugin with no surface to draw on. The only way in is to make sure it never opens. A background service worker watches navigations and sends anything that is a PDF to the extension's own viewer page instead.

There are two triggers, because there are two ways to find a PDF. The first fires before the request goes out, on URLs whose path or query ends in the right extension, which means the browser never downloads the file twice. The second reads the response headers for a PDF content type, catching files served without a telling extension, and deliberately steps aside when the response asks to be downloaded rather than displayed.

Both can fire for the same navigation, so redirects are recorded per tab with a timestamp and a second attempt within a few seconds is dropped. Without that the page bounces.

02

One coordinate space, chosen for the exit

Every annotation stores its geometry in the page at scale one, origin top left, measured downwards. That is not an arbitrary choice: at scale one a PDF.js unit is exactly a PDF point, so the coordinate the pen wrote is already the coordinate the exporter needs.

Drawing on screen multiplies by the current zoom, and export flips the axis, because PDF user space measures upwards from the bottom left. Those are the only two conversions in the project. Storing screen pixels instead would have meant every saved file was tied to the zoom level and the display density it was drawn at.

03

A page is three stacked layers

Each page is a PDF.js canvas with a transparent drawing canvas over it and a selectable text layer over that. The rendered page never changes, the drawing layer is repainted from the annotation list whenever something moves, and the text layer exists so the browser's own text selection can be borrowed.

A long document cannot paint every page. An intersection observer with a generous margin around the viewport decides what is live: all pages are sized immediately, which is cheap and keeps the scrollbar honest, but only the ones near the viewport hold rendered bitmaps. Scrolling away releases them.

04

Two kinds of highlight

Highlighting words uses the real text layer and the browser's native selection, so a drag snaps to words and sentences. The rectangles a selection range hands back are line boxes, which stretch to the container edge and bleed across the gaps between paragraphs, so the selection is instead clipped to each individual text span and measured there. The result hugs the glyphs actually selected.

The freehand highlighter works on anything, including a scan with no text in it at all.

Both are composited the same careful way. Translucent strokes drawn one after another stack their alpha, so a crossing looks darker than the two strokes that made it. Instead, every highlight of a given colour and opacity is drawn fully opaque into an offscreen canvas, and that whole layer is blitted once at the group's opacity. Overlaps come out as one flat shade, the way a real marker behaves. Rectangles that share a line are merged into single bars first.

05

Undo that survives the page order changing

History entries record what was added, what was removed and what was moved, so one step can cover a multi-page operation and undo knows exactly which pages need repainting. The stack is capped, and anything that mutates the document schedules a debounced save rather than writing on every stroke.

After an undo the current selection may point at an annotation that no longer exists, so it is dropped rather than left dangling with resize handles floating over nothing.

06

Saving to the file, not to the address

Markup is keyed to a hash of the PDF's bytes. The same document reached through a different link, or downloaded and opened locally, restores the same annotations, and two different files that happen to share a URL never collide.

The saved format stores the full page order alongside the annotations, with each page carrying a stable identifier rather than a position. Blank pages can be inserted mid-document, so a position-keyed save would silently shift everyone's markup down by one the next time the file opened. Saves written by the older, position-based format are still read, and are mapped by position, because that is what they meant when they were written.

07

Making a scan selectable

A scanned PDF is a picture of a document with no text in it. Detecting one by checking for a text layer is wrong in both directions, so the detection reads the page's operator list and looks for a page that is essentially one image with almost no drawing operations. A vector page has hundreds of path operations even when its text is outlined, and a scanned book that carries a hidden text layer still reads as a scan. The same pass records the intrinsic width of the largest image, which gives the scale at which the scan renders one to one and stops it being upscaled into mush.

Recognition runs on demand, one page at a time, through a locally bundled OCR engine. Bundling is not a preference: the extension's content security policy blocks loading code from a CDN and blocks workers created from blobs, so the engine, its WebAssembly core and its language data all ship inside the extension and the worker is pointed at those paths explicitly.

What comes back is a list of word boxes in image pixels. Those are mapped into the page's coordinate space and used to build a synthetic text layer positioned over the scan, at which point native selection, word-snapped highlighting and read-aloud all start working on that page. Results are cached per page against the same stable identifier, so reopening the file restores them.

08

Reading it out loud

Read-aloud does not speak the raw text content. It walks the rendered text spans, builds one continuous string while recording where each span landed on the page, then splits that string into sentences and collects the rectangles overlapping each one.

That indirection is what lets the sentence being spoken be highlighted in place while the browser's speech synthesis reads it, and it moves on to the next page by itself when a page runs out.

09

Two documents side by side

Split view is a shell page that hosts the ordinary viewer twice, in iframes, each told to hide its own toolbar. One shared toolbar at the top drives them through messages.

The routing is the interesting part. Style, meaning the tool, colour, width, opacity and font, is broadcast to every pane, so switching to the highlighter switches it everywhere and the toolbar never lies about one pane while you are working in another. Actions like undo, save, export, OCR and zoom go only to whichever pane has focus. Each pane reports its own document name, page readout and zoom back, and the toolbar mirrors whichever one is active. Commands sent before a pane has finished loading are queued rather than dropped.

10

Burning it into a real PDF

Export walks the final page order rather than the original one. Inserted blank pages are created first, at the indices they occupy on screen, so the original pages stay where they were and everything lands on the right sheet.

Each annotation type is redrawn using the PDF's own drawing operators: strokes as line segments, the brush varying thickness per point from the pressure recorded while drawing, arrowheads computed from the segment angle, shapes as polygons from a unit-square definition, sticky notes as a filled rectangle with a darker folded corner and wrapped text measured against the embedded font. Images are embedded once each no matter how many times they appear.

Rotation needed care. The library rotates an image about its placement anchor rather than its centre, and screen rotation runs the opposite way to PDF rotation, so the anchor is solved backwards from where the centre has to end up and the angle is negated. Pages with a non-zero rotation are still an honest known limitation, and the export says so in a toast rather than quietly producing a file with the markup in the wrong place.

11

Nothing to build, nothing to send

There is no bundler, no transpiler and no package manifest. The extension is loaded and run exactly as it is written, and the third-party libraries are unmodified upstream builds sitting in their own directory, which is also what makes it reviewable by a store without the reviewer having to trust a build pipeline.

Packaging is a script that zips only the files the extension needs, writing archive entries with forward slashes so the result unpacks correctly regardless of the operating system it was built on. Rendering, drawing, recognition and export all happen on the device, and the permissions the extension asks for exist to spot a PDF navigation and redirect it, not to watch browsing.

What it does

  • Opens PDFs automatically: Navigate to any PDF link and it loads in the annotator, or drag a local file onto the window.
  • Drawing tools: Pen, a brush that varies with speed, lines, arrows including elbow and curved, rectangles, ellipses and a set of polygon shapes.
  • Two highlighters: One snaps to words through the text layer, one is freehand and works on scans and images.
  • Text, images and sticky notes: Type on the page in three font families, drop in pictures that can be moved, resized and rotated, or leave a folded note.
  • OCR for scans: Recognise a scanned page on demand and get a real selectable text layer over it, cached for next time.
  • Read aloud: Speaks the page and highlights the sentence it is on, carrying on to the next page by itself.
  • Split view: Two PDFs side by side, driven by one shared toolbar that follows whichever pane has focus.
  • Blank pages: Insert an empty page anywhere in the document and draw on it like any other.
  • Autosave and restore: Markup is keyed to the file itself, so it comes back whether the PDF is reopened from the same link or a local copy.
  • Flattened export: Downloads a real PDF with the markup in the page content, readable anywhere and after uninstalling.
  • Keyboard driven: One key per tool, with the usual shortcuts for undo, redo, save and export.
  • Entirely offline: No servers, no accounts and no uploads. Recognition and export run on the device.

Technical decisions

Replace the viewer instead of drawing over it

Chose
Redirect PDF navigations into an extension page built on PDF.js.
Why
The built-in viewer is a closed plugin with no drawing surface and no way to read its layout, so an overlay could never align with the page underneath it. Owning the render is what makes coordinates, text selection and export all knowable.

Redirect before the request, not after

Chose
Intercept the navigation, with a header check only as the fallback.
Why
Catching it after the response has arrived means the file has already been downloaded once and is about to be downloaded again by the viewer. The header path still exists because PDFs served without a recognisable extension are common, and it skips anything marked as an attachment so ordinary downloads keep working.

Store geometry in PDF points

Chose
The page at scale one, origin top left.
Why
It makes export a single axis flip and nothing else. Storing screen pixels would tie every saved annotation to the zoom level and pixel density it happened to be drawn at, and re-deriving the original position later would be guesswork.

Key saved markup to the file's bytes

Chose
A hash of the document as its identifier.
Why
URLs are not identity. The same paper reached from a different link, or saved and reopened locally, is the same document and should carry the same notes, and two unrelated files behind one address should not inherit each other's.

Store the page order, not page numbers

Chose
Stable per-page identifiers plus an explicit order.
Why
Blank pages can be inserted mid-document. Anything keyed by position silently shifts every later page's markup down by one the next time the file opens, which is the kind of corruption a user notices long after it happened.

Flatten each highlight layer before compositing

Chose
Draw opaque offscreen per colour, then blit once at the group's opacity.
Why
Translucent strokes painted one after another multiply their alpha, so every crossing prints darker than the strokes that made it. A real highlighter does not do that, and neither should this one.

Detect scans from the drawing operations

Chose
Read the operator list rather than checking for a text layer.
Why
A text layer is the wrong signal in both directions: a scan with a hidden recognised layer would be missed, and a vector page with outlined text would be misread as a scan. Counting what the page actually draws separates the two cleanly.

Vendor the OCR engine

Chose
The engine, its WebAssembly core and its language data ship inside the extension.
Why
Not a preference. The extension's content security policy forbids loading code from a CDN and forbids workers created from blob URLs, so every path has to resolve inside the package. It also means recognition keeps working with no network at all.

Ship source with no build step

Chose
Plain unminified JavaScript, third-party libraries as unmodified upstream builds.
Why
An extension asks for broad permissions, so being readable is part of being trustworthy. A reviewer can check what runs without having to reproduce a build, and what ships is exactly what is in the repository.

Build split view out of the viewer itself

Chose
Two embedded instances of the same page, driven by messages from a shell.
Why
The alternative is a second rendering path that has to keep pace with the first one forever. Embedding the real viewer means a pane is never a reduced version of the app, and the only new code is the routing that decides whether a command belongs to every pane or just the focused one.