You click a line in a PDF, type, and download. The new text uses the same font, the old words are really gone (not hidden under a white box), and the file never leaves your browser. Here is how the Aivdo PDF editor does it, and the bugs I hit on the way.
Why most free PDF editors get this wrong
A PDF is not a document with paragraphs. It is a list of drawing instructions: "use font F2 at 11 pt, move to x=72 y=730, draw these glyph codes". There is no line, no word, no "edit" built in.
So most browser editors do the easy thing: draw a white box over the old line and type new text on top in Helvetica. That fails in two ways:
- The font changes. Your Calibri invoice suddenly has one Helvetica line.
- The old text is still there. It is hidden visually, but search, copy-paste and screen readers still find it. On an invoice, that is a real problem.
I wanted both fixed, with the PDF processed entirely in the browser so nothing is ever uploaded.
Step 1: turning text runs into clickable lines
pdf.js renders each page to a canvas and getTextContent() returns text runs: a string, a transform matrix (position and size in PDF units) and a width. A run is often a single word or even a few letters, so the editor merges runs into lines when they share a font, size and baseline and sit close together.
Each line becomes a transparent div placed over the canvas. Hover shows an outline, click makes it editable. The background and text colour are sampled from the rendered canvas so the editing box blends in.
The table-cell gotcha. My first version merged a whole table row into one line, so editing "1,200" replaced "North 1,200 1,450". The cause: the PDF pads table cells with a single wide space run (118 pt wide). Treating whitespace-only runs as separators, and only joining runs with a word-sized gap, fixed it.
The colour gotcha. White text on a red banner came back red-on-red. Sampling around the box picked up the white page outside the banner. Now the background is the most common colour inside the box, and the text colour is the average of the highest-contrast pixels, which ignores the soft anti-aliased edges.
Step 2: recognising the font
The obvious idea is to reuse the font embedded in the PDF. It rarely works. Almost every PDF embeds a subset: only the letters it already uses (my test invoice carried 23 glyphs of Carlito), often stored under private codes instead of real characters. Type a new letter and it simply is not there.
So the editor reads the font name (for example BCDEEE+Calibri-Bold), strips the subset prefix, works out family and style, and picks the best real font in this order:
- A font file the user uploaded (.ttf or .otf).
- The same font installed on the user's computer, via the Local Font Access API (Chrome and Edge, with permission).
- A complete (non-subset) font embedded in the PDF.
- A bundled open-licence font with the same letter widths as the original.
- DejaVu Sans, only for individual characters the chosen font lacks (₹, ₽, Greek, Cyrillic).
| Font in the PDF | Bundled match | Same letter widths |
|---|---|---|
| Arial, Helvetica | Liberation Sans | Yes |
| Times New Roman, Times | Liberation Serif | Yes |
| Courier New, Courier | Liberation Mono | Yes |
| Calibri | Carlito | Yes |
| Cambria | Caladea | Yes |
| Anything else | Closest serif, sans or mono | No: user can add the exact font |
Matching letter widths matters more than looks: an edited line takes the same space as before, so it does not run into the next column. The UI shows this as a coloured dot: green for exact or same-width fonts, amber for a look-alike, with a one-click Use exact font button.
Step 3: really removing the old words
This is the part most editors skip. With pdf-lib I decompress the page's content stream and walk it operator by operator, tracking where every piece of text lands:
-
cm,q,Qfor the graphics transform -
BT,Tm,Td,TD,T*,TLfor the text position -
Tf,Tc,Tw,Tzfor font, size and spacing -
Tj,TJ,',"for the text itself
When a show operator starts on an edited line, I cannot simply delete it. Anything drawn after it in the same text block would slide left, because the pen position moves by the width of what was drawn. So the operator is replaced with an empty TJ that moves the pen by exactly the same amount:
// width of the original string in text-space units, from the font's /Widths
const adv = glyphWidths(str, font) * fontSize * hScale;
// "[ -n ] TJ" draws nothing but advances the pen by n/1000 em
const kern = -(adv / (fontSize * hScale)) * 1000;
replace(op, `[${kern.toFixed(3)}] TJ`);
The widths come from the font dictionary (/Widths, or /W for Type0 fonts). The 14 standard fonts such as Helvetica carry no widths in the file at all, so those come from pdf-lib's built-in metrics. The new text is then drawn with the chosen font at the original baseline.
Step 4: the editor checks its own work
Rewriting content streams is risky, so every save is verified before the user gets the file. The editor reopens the new PDF with pdf.js, confirms the page count, checks that every line the user did not touch is still present, and renders each changed page.
If anything fails, it rebuilds the file the conservative way: the original text stays and is covered with a box in the sampled background colour. The user always gets a correct-looking file. Fonts it cannot parse (Type3, unusual encodings) and encrypted PDFs take that safe path, or get a plain-language message instead of a broken download.
Testing on 11 real-world PDF types
All 11 pass. Each output was checked outside the browser with qpdf, Poppler and pypdf: valid file, same page count, new text present, old text gone, untouched text intact.
| Test PDF | What it proved |
|---|---|
| Office invoice (LibreOffice, subset fonts) | Same font kept, ₹ added, old text removed |
| Letter in Helvetica and Times (not embedded) | Standard-14 widths work, added text lands where clicked |
| 150-page report | Opens in about 2 s, edit on page 120, saves in under 1 s |
| Fillable job-application form | Text, dropdown, checkbox, radio and multi-line values saved; form still fillable |
| Newsletter: red banner, table, image, two columns | White banner text kept, one table cell edited alone |
| Scanned page | Clear "no text" message; Add text and White-out work |
| Password-protected | Friendly "remove the password first" message |
| Edit-restricted | Friendly message instead of a broken file |
| Euro, rouble, rupee, Cyrillic, Greek | Correct characters via per-letter fallback |
| Rotated page | Edit lands in the right place |
| 30 full-page photos | Fast on a heavy file |
Looking at the rendered results mattered as much as the automated checks: the red-on-red banner and the merged table row both passed the text checks before I saw them.
Gotchas if you build one
- pdf-lib's font subsetter drops glyphs in some fonts (Carlito lost most letters). Embedding the full font is reliable; it adds roughly 0.4 to 0.8 MB per font used.
-
Background tabs stall saving. pdf-lib parses and saves in small timed steps, and browsers throttle timers in hidden tabs. Passing
parseSpeed: ParseSpeeds.FastestandobjectsPerTick: Infinityfixed it. -
pdf.js page rendering waits for animation frames, which never fire in a hidden tab. For off-screen verification renders,
intent: 'print'avoids that. - Clicking with the "add text" tool loses focus to the browser's own mousedown handling. Starting the edit a moment after the click fixed it.
- Do not reuse a subset font for new text, even if its cmap claims a character exists: the codes are often private, and you get "?" boxes.
-
Do not give your page's
<body>and your tool buttons the samedata-attribute. A click anywhere selected a tool called "pdfedit". An hour I will not get back.
Try it
The editor is free, needs no sign-up, adds no watermark and runs entirely in your browser: aivdo.io/pdf-editor. Drop a PDF, click any line, type, download. It also fills forms, adds text and has a white-out tool.
Stack: pdf.js 3.11 for rendering and text extraction, pdf-lib 1.17 with @pdf-lib/fontkit for writing and font embedding, a small hand-written content-stream parser for text removal, and Liberation, Carlito, Caladea and DejaVu fonts (open licences). No server, no build step.
If you try it on a PDF that breaks, I would genuinely like to hear about it in the comments.
Top comments (0)