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
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.
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.
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.
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
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.