QuirePDF / Engineering case study

Editing the words a PDF already has

Most browser PDF editors do not edit a PDF. They draw a white box over a paragraph and type a new one on top in Helvetica, and the result looks like a correction rather than a document. This one finds the paragraph the file already contains, opens it in the file's own embedded typeface, and writes it back on the baseline it was measured at.

My role: sole engineer — block detection, editor, writer, converter engine, the 180-page static site. 2026.

00 / Why

A file you can edit without handing it over

A PDF is often the least convenient document anyone owns: a contract with the wrong date, a CV with an old phone number, a form that has to be printed to be filled in. The usual answer is to upload it to a stranger's server and pay a monthly fee for the privilege, which is a poor trade for a signed lease or a passport scan.

So nothing is uploaded here, because there is nowhere to upload it to. The product is a folder of static files: pdf.js reads the document in the tab, pdf-lib writes the new one, and the bytes never leave the machine they arrived on. That also decides the business model for you — there is no per-file cost to recover, so there is no paywall, no watermark and no account.

01 / Architecture

Four parts, all of them on the client

A Reading

pdf.js renders each page to a canvas and hands over the text layer: every run of glyphs with its transform, its width and the name of the font it was set in. That is the raw material — a PDF has no paragraphs, only positioned runs.

B Block detection

Runs are grouped into lines, lines into paragraphs, by gap, leading, size and alignment. The unit of editing is a paragraph the document already has, never a rectangle drawn by hand — a hand-drawn box always cuts one in half or takes half of the next column with it.

C The editor

A paragraph is reopened as ordinary editable HTML, set in the very font the file carries — pdf.js exposes a sanitised copy of every embedded face, and the browser will use it if you name it.

D Writing

pdf-lib embeds that same face back into the file, subsetted to the glyphs actually used, and draws the paragraph at the position and spacing measured on screen. A rewritten CV keeps its typeface at a cost of about eight kilobytes.

02 / The hard part

Nothing may move by one pixel

The whole feature stands or falls on one promise: clicking a paragraph changes nothing, and editing one changes only what you edited. Four things had to be true for that, and each was wrong first.

1 — Do not show the copy until it is needed

Any re-render of the same words differs from the print by something: where the spacing correction falls between letters, how the glyphs are hinted, where the baseline sits in the line box. So a click shows only a frame. The editable copy is laid out invisibly behind the printed words — it has to exist, to be measured and described — and is revealed only when the paragraph is actually lifted, by a double-click or by a drag past three pixels.

Measured: the page canvas is byte-identical before and after a click, across every paragraph on the page and at every zoom level.

2 — Make the browser as wide as the print

The file states how wide each run was printed. The browser sets the same words in the same face and lands a few per cent off, because a PDF advances by its own metrics and a text engine by its own. Each run is therefore calibrated with letter-spacing until it matches the width the document reports.

app/ui/blockLayer.ts

// The correction the letters could not absorb goes on the spaces,
// because on a tracked line that is exactly where it came from.
const cap = parseFloat(cs.fontSize) * 0.12
const track = clamp(-cap, cap, (want - got) / text.length)
s.style.letterSpacing = `${track}px`

const spaces = text.length - text.replace(/ /g, '').length
if (spaces) {
  const spread = RULER.measureText(text).width + track * text.length
  s.style.wordSpacing = `${(want - spread) / spaces}px`
}

Why the second half exists: an embedded subset carries the glyphs the page used, and a PDF encodes word gaps as positioning rather than as characters — so the face has no space of its own and the browser borrows one from the next family in the stack. For prose that is harmless. For a heading set in a monospaced face it is not: Courier New's space is 5.76px against the 1.92px the file advances by, and nine of them put “E X P E R I E N C E” eighteen per cent over its measure. Correcting the letters could never give that back. Correcting the spaces took the error to 0.02%.

3 — Measure the baseline, do not guess it

Where a baseline sits under the top of a line is a fact about the typeface — its ascent — and not a constant. Three parts of the program had each guessed it separately, at 1.02, 0.975 and about 0.91 of the type size, so every rewritten paragraph landed a tenth of a size below the words it replaced. The browser knows the real number; it is measured once and carried through to the writer.

Measured against the ink on the raster, rather than against the text layer: cap height within 0.012px, baseline within 0.309px.

4 — Let the line breaks stand

A rewritten paragraph arrives already broken into lines: the browser wrapped it, in the document's own face, and each line came back as its own run. The writer used to wrap it a second time against a width measured for a different typeface — and a name set in Segoe UI Black is wider in its own face than in the stand-in the box was sized for, so “Mykhailo Nahreba” came back out of a move as two lines. A run that already knows where its line ends now says so.

03 / The rest of it

Not only an editor

62 conversionsPDF to Word, Excel, EPUB, SVG, images and back again. The heavy engine — a megabyte and a half of WebAssembly for HEIC alone — is a separate module fetched only by the pages that need it, so a PNG-to-JPG page does not pay for it.
105 fillable formsReal US government PDFs — W-9, I-9, 1040, DS-11 — filled through their own AcroForm fields, so the download stays fillable in any reader instead of being flattened to a picture.
Cyrillic that survivesEach word is checked against the embedded face's own character map before it is drawn. Words the face can set, it sets; the rest fall back to a Unicode face, so a Ukrainian sentence typed into an English CV neither breaks the export nor loses the document's typeface for everything else.
Redaction that means itA black box over a word leaves the word in the file, where anyone can select and copy it — the single most common way redaction is got wrong. A redacted page is rebuilt from a picture of itself instead. The cost is that the page stops being selectable text, and that is the honest trade.
180 static pagesThe site is generated from one manifest and served as files. Vite compiles only the editor, because pointing it at the project root quietly drops every page in a subdirectory.

Still in beta: the editor covers ordinary paragraphs well and unusual typesetting — Type3 fonts, heavily tracked display faces — less well. Re-editing a file this editor already exported leaves the original text under the patch that hides it.

04 / Try it

Open something you would not upload

That is the point of it. Drop in a contract, a passport scan, a signed lease — it is opened by the tab, not by a server.