Skip to content
Crixaa
← All posts

A thumbnail should be the document

The Crixaa team4 min read

The template list needs a small picture of each template, clear enough that you recognise the one you want without reading the name. The obvious way to produce it is to screenshot the editor canvas, and that is the wrong picture.

The canvas is not the document

The Crixaa editor and the PDF renderer share a measurement authority: the canvas asks the PDF engine what it would compute rather than guessing. That gets the two very close. It does not make them the same image, and was never meant to.

The canvas is still a browser surface. It has its own rasteriser, its own hinting and antialiasing, and it draws things that exist only for the person editing: selection outlines, alignment guides, the page boundary, chips standing in for unbound variables. A screenshot of it is a faithful record of the editing session, not of the document.

The drift also lands where it matters most. At thumbnail size you cannot read a word; what survives the downscale is shape — where the blocks sit, whether the table fits, where the page ends — which is precisely the part a browser approximation cannot guarantee.

Take a GST invoice with fourteen line items. In the real PDF the tax summary drops below the fold and the document runs to two pages; on the canvas, with different rounding across fourteen row heights, it fits on one. The screenshot is a picture of a page that does not exist. Variables make it worse: the canvas shows a chip reading {{invoice_total}} where the document shows ₹4,72,500, and those are not the same width.

Render it the way you render everything else

The fix is not a better screenshot. It is to stop screenshotting.

When a version is committed, we run it through the same generation path that produces a customer's PDF — same engine, same embedded fonts, same pagination, populated with the template's sample values — then rasterise page one. The thumbnail is not a likeness of the document. It is the document, small.

ApproachWhat it drawsNeeds a browser
Canvas screenshotThe editor's view of a templateYes
Pipeline renderPage one of the PDF a customer receivesNo

A screenshot can be wrong without anyone noticing, because the only thing that would reveal the drift is a comparison against a real PDF — the step you skipped by screenshotting.

No browser means better coverage

The screenshot approach has a quieter problem: it only produces an image where a browser happened to be. Versions committed through the API never open an editor, and neither do templates duplicated from another workspace or untouched since the day someone changed a font.

So the list ends up with images for whatever a human recently looked at and blanks everywhere else — coverage as a function of who opened what. Rendering server-side removes the question, along with the headless browser, the browser pool, and any chance of the screenshot container's fonts drifting from the render container's.

Rasterise once, downscale to the rest

We serve more than one size. The tempting implementation renders the PDF once per size at the matching DPI, which doubles the expensive step to save the cheap one.

Render once, comfortably above your largest size, and resample from that master:

import sharp from 'sharp'

// One PDF render, one raster, every size derived from it.
const master = await rasterisePageOne(pdfBytes, { dpi: 216 })
const sizes = { card: 480, row: 160 }

await Promise.all(
  Object.entries(sizes).map(([name, width]) =>
    sharp(master).resize({ width }).webp().toFile(`${versionId}-${name}.webp`),
  ),
)

It also buys consistency: every size comes from the same pixels, so a card and a row can never disagree. Add a third size later and you resample the stored master rather than re-running the pipeline against a version whose fonts may have moved on since.

The image is derived, and disposable

The commit is the fact. The thumbnail is a cache of it, and it has to be built like one.

The render happens after the commit transaction, out of band, and a failure logs a warning and leaves the thumbnail missing. The list shows a placeholder. Nobody is blocked.

const version = await commitVersion(input) // the fact

// Best effort, after the transaction, never inside it.
queue.add('thumbnail', { versionId: version.id }).catch((err) => {
  log.warn({ err, versionId: version.id }, 'thumbnail enqueue failed')
})

return version

Get this backwards and the failure mode is memorable: thumbnail rendering starts throwing, and suddenly nobody in the organisation can commit a template because the list needs a picture. An image is never worth failing a write for.

It also means the whole set is rebuildable on demand, which is the only reason a change to the render pipeline is a routine deploy rather than a migration.

The short version

If the thumbnail is not produced by the thing that produces the document, it is an illustration, and illustrations drift silently. Render page one through the real pipeline, once, above your largest size, and downscale from there.

Then treat the result as what it is: something that may be missing, may be rebuilt, and must never be able to fail the commit that asked for it.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.