If you generate PDFs from web pages by screenshotting them, you've probably hit this: the PDF looks perfect and is completely useless the moment you want to use the text. Ctrl+F finds nothing. You can't copy a sentence. A screen reader opens the document and sees one big image on an otherwise empty page.
We archive whole pages this way — a full-height screenshot dropped onto a PDF page — so we had exactly this problem. Here's how we gave those PDFs a real, invisible, searchable text layer with pdf-lib and @pdf-lib/fontkit, and the one part that turned out to be genuinely hard.
The trick is the same one a "searchable scan" uses: draw the real text invisibly over the image, at the exact coordinates where each word appears. The difference is that a scanner needs OCR to guess the text — we rendered the page ourselves, so we already have the ground truth. No OCR, no guessing.
The shape of it
- While the page is still open in the headless browser, ask the DOM where every word is.
- Assemble the PDF: embed the screenshot as the page background.
- For each word,
drawText it at its coordinates with opacity: 0.
Steps 1 and 3 are easy. The trap is in which words you're allowed to draw.
Step 1 — ask the browser where the words are
Running inside the page (Puppeteer's page.evaluate), walk every text node and measure each word with a Range:
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
// ...for each word in each text node:
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
const rects = range.getClientRects();
if (!rects.length) continue; // display:none or empty line box
const b = rects[0]; // first rect = where the word starts
out.push({
t: word,
x: b.left + window.scrollX, // document coordinates, not viewport
y: b.top + window.scrollY,
w: b.width, h: b.height,
fs: parseFloat(getComputedStyle(el).fontSize) || 12,
});
getClientRects() returns viewport coordinates, so we add scrollX/scrollY to get document coordinates — the ones that line up with a full-page screenshot. A word wrapped across two lines returns several rects; the first is where a search should land, so that's the one we keep.
Step 2 — the hidden-text trap (the hard part)
The naive version above shipped a bug immediately. On a real page, search started highlighting words that weren't in the picture — items inside a collapsed dropdown, off-canvas nav, text-indent: -9999px screen-reader text. The image showed them collapsed; the text layer had them anyway.
The tempting assumption is "hidden text has no client rects." It's false. Only display: none produces no rects. Every other way of hiding something still reports perfectly good coordinates:
visibility: hidden
opacity: 0
max-height: 0 + overflow: hidden (the collapsed accordion / submenu)
- parked offscreen with
text-indent: -9999px or a negative position
So each word has to be checked against its whole ancestor chain, not just its own box. Memoise one walk up the tree per element (a deep DOM would otherwise re-read computed styles thousands of times):
function chainInfo(el) {
if (cache.has(el)) return cache.get(el);
const above = el.parentElement ? chainInfo(el.parentElement)
: { visible: true, opacity: 1, clips: [] };
let info;
if (!above.visible) {
info = { visible: false, opacity: 0, clips: above.clips };
} else {
const cs = getComputedStyle(el);
const opacity = above.opacity * (parseFloat(cs.opacity) || 0); // multiplied down the chain
const visible = cs.visibility === 'visible' && opacity > 0.05;
const clips = /\b(hidden|clip)\b/.test(cs.overflow)
? above.clips.concat([el.getBoundingClientRect()])
: above.clips;
info = { visible, opacity, clips };
}
cache.set(el, info);
return info;
}
A word survives only if its chain is visible, its multiplied opacity is above a floor, it intersects every clipping ancestor (a zero-height collapsed container clips its contents to nothing), and it isn't parked entirely outside the document box. Clipping is judged on zero intersection, not partial — a word half-cut by a container is still half in the picture, and dropping it would lose real text.
That single ancestor-walk is the difference between "searchable PDF" and "PDF that lies about what's in it."
Step 3 — flip the Y axis
The DOM measures from the top-left; PDF space is bottom-left. So when we draw, Y inverts, plus a small nudge so the selection highlight sits on the glyphs rather than below them:
const y = imageH - b.y - b.h + b.h * 0.2;
Step 4 — the font problem
pdf-lib's built-in fonts are WinAnsi. They can't encode Cyrillic, Greek or CJK — and they even choke on curly typographic quotes, of which there are four on our own English blog. Draw an unencodable glyph and the call throws.
So embed a real TrueType font, subset to only the glyphs actually used, via fontkit:
const fontkit = require('@pdf-lib/fontkit');
doc.registerFontkit(fontkit);
const font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true });
subset: true is what keeps the cost near zero — only the handful of glyphs on the page get embedded, not the whole 300 KB face. We probe a small candidate list and take the first that exists on the box (DejaVu Sans on our Linux servers covers Latin + Cyrillic + Greek; Arial/Segoe locally). If none is found, we ship the image without a text layer rather than failing the capture — losing search is not losing the archive.
Step 5 — draw it invisibly, and don't let one glyph kill the archive
for (const b of boxes) {
const y = imageH - b.y - b.h + b.h * 0.2;
try {
pdfPage.drawText(b.t, { x: b.x, y, size: b.fs, font, opacity: 0 });
drawn++;
} catch (e) {
skipped++; // a glyph even this font can't encode — skip the word, keep the PDF
}
}
opacity: 0 is the whole point: the image is what you see, the text is what you search. And the per-word try/catch matters — a single exotic glyph should cost you one unsearchable word, not the entire multi-page document.
Did it actually work?
Measured on a Cyrillic page, before/after:
- 626 of 626 visible words got coordinates and a text entry
- file grew 7086 KB → 7147 KB — +0.9%
/ToUnicode is present in the output, so the text is genuinely searchable and copyable, not just present
That +0.9% buys you Ctrl+F, copy-paste, and a document a screen reader can actually read — on what is still, pixel for pixel, an exact image of the page.