DEV Community

PetrDev
PetrDev

Posted on Originally published at site2pdf.online

Making a screenshot PDF searchable — no OCR, because we rendered the page

We archive whole web pages as PDFs. Under the hood each page is a full-height screenshot dropped onto a PDF page — which 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… an empty page with one big image.

The fix is the same trick a "searchable scan" uses: draw the real text invisibly, on top of 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.

Here's how we built it with pdf-lib and @pdf-lib/fontkit, and the one part that turned out to be genuinely hard.

The shape of it

  1. While the page is still open in the headless browser, ask the DOM where every word is.
  2. Assemble the PDF: embed the screenshot as the page background.
  3. 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), we 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,
});
Enter fullscreen mode Exit fullscreen mode

getClientRects() gives 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 menu, off-canvas nav, text-indent: -9999px SR-only 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. We 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 we 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 });
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


We do this on every capture at Site2PDF — it archives a page (or a whole site) as a PDF/PNG/JPG, and the PDF comes out searchable by default. If you want the end-user version of this rather than the engineering one, we wrote up how to save a website as PDF.

Top comments (0)