Ask Anvil

Answers to questions about automating PDFs, e-signatures, Webforms, and other paperwork problems.
PDFs
Categories

How do I inline images and fonts into an HTML template before converting it to PDF?

Your invoice renders perfectly in the browser, then the generated PDF comes back with a broken-image box where the logo was and a fallback serif where your brand font should be. The renderer did not fail; it finished before the assets arrived.

Rather than tuning wait conditions, you can delete the problem: read each local asset off disk, base64 encode it, and rewrite the reference as a data URI. The HTML you hand the renderer then refers to nothing it has to fetch.

The helper

const fs = require('node:fs')
const path = require('node:path')

const MIME = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.svg': 'image/svg+xml',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff',
  '.ttf': 'font/ttf',
  '.otf': 'font/otf',
}

function inlineAssets (html, assetDir) {
  const cache = new Map()

  const toDataUri = (ref) => {
    // Leave anything already inlined or remote alone.
    if (/^(data:|https?:|\/\/)/i.test(ref)) return null
    const clean = ref.split('?')[0].split('#')[0]
    if (cache.has(clean)) return cache.get(clean)

    const file = path.resolve(assetDir, clean)
    if (!file.startsWith(path.resolve(assetDir))) return null
    if (!fs.existsSync(file)) return null

    const mime = MIME[path.extname(file).toLowerCase()]
    if (!mime) return null

    const uri = `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`
    cache.set(clean, uri)
    return uri
  }

  return html
    .replace(/(<img\b[^>]*?\bsrc=)(["'])(.*?)\2/gi,
      (m, pre, q, ref) => { const u = toDataUri(ref); return u ? `${pre}${q}${u}${q}` : m })
    .replace(/url\(\s*(["']?)(.*?)\1\s*\)/gi,
      (m, q, ref) => { const u = toDataUri(ref); return u ? `url(${q}${u}${q})` : m })
}

module.exports = { inlineAssets }

Call it on the way into the renderer:

const fs = require('node:fs')
const { inlineAssets } = require('./inline-assets')

// Hand the renderer markup that references nothing it has to go fetch.
const html = inlineAssets(fs.readFileSync('invoice.html', 'utf8'), './templates')

What the two replacements cover

Templates refer to assets in exactly two places: the src attribute on an img tag, and url() inside CSS, which catches @font-face sources and background images alike. Anything that is already a data URI or points at a remote host is skipped and passed through untouched, so you can inline what you ship with the template and still let the renderer fetch what you deliberately host elsewhere. Files that do not exist, or whose extension is not in the map, are also left alone rather than silently blanked.

The cache matters more than it looks. A logo repeated in a table header would otherwise be re-encoded and re-embedded on every occurrence. The data URI format itself is the one defined in RFC 2397, data:[<mediatype>][;base64],<data>, and the media types in the map are the registered ones: image/png, image/jpeg, image/gif and image/svg+xml for images, and font/woff2, font/woff, font/ttf and font/otf for fonts (the font top-level type comes from RFC 8081).

Caveats

Regex over HTML is fine for templates you own and control. If the markup can come from somewhere else, parse it with a real HTML parser instead of pattern matching tags.

Base64 costs about 33 percent in size, four characters for every three bytes, and those bytes now live inside the HTML string instead of arriving on a parallel connection. Inline fonts, icons and logos. Leave large photography on a host the renderer can reach.

The resolve-and-prefix check is deliberate. Without it, a template containing something like ../../private/signature.png would read a file outside your asset directory. Keep it if template content can ever originate with a user.

Inlining removes the fetch, not the font application step. In Puppeteer, page.setContent takes SetContentWaitForOptions, whose waitUntil excludes networkidle0 and networkidle2 and defaults to load. If you use web fonts, it is still worth awaiting document.fonts.ready before calling page.pdf(), since that promise resolves only once loading and layout of the used fonts are done.

Back to All Questions

The fastest way to build software for documents

Anvil Document SDK is a comprehensive toolbox for product teams launching document flows where PDF filling, signing, and complex conditional scenarios are necessary.
Explore Anvil
Anvil Webforms