Fixing KaTeX and html-to-image Blank Image Output: Causes and Solutions

Mar 09, 2026 647 views

You render a beautiful math equation with KaTeX. You export it to PNG using html-to-image. You open the file — blank white rectangle.

This is exactly what I ran into while building BlockTeXu, a block-based visual LaTeX editor. The structural layout was intact — fraction bars, integral positions, all correct — but every character had vanished.

BlockTeXu site

The Naive Approach

import { toPng } from 'html-to-image';
const dataUrl = await toPng(element);

Looks straightforward. But with KaTeX in the mix, this approach reliably produces a blank image every time.

Why It Happens: SVG foreignObject + Web Fonts

html-to-image works by serializing the DOM into an SVG <foreignObject>, drawing that onto a Canvas element, then exporting the Canvas as a PNG.

The core issue: @font-face web fonts do not load reliably inside an SVG <foreignObject>.

KaTeX relies on a suite of custom typefaces for mathematical rendering — KaTeX_Main, KaTeX_Math, and others — all declared via @font-face. In a standard HTML document context, these resolve without issue. But the moment the DOM gets serialized into an SVG blob, the browser treats it as an isolated document. The fonts fail to resolve before the canvas capture executes, leaving glyphs unrendered.

The visual result is striking in the worst way: equations appear as if rendered in invisible ink — structural scaffolding intact, all typographic content absent.

What Doesn't Work: document.fonts.ready

The instinctive first fix is to await font readiness before capturing:

await document.fonts.ready;
const dataUrl = await toPng(element);

document.fonts.ready resolves once the host HTML document's font stack is loaded — but it has no visibility into the font resolution lifecycle inside an SVG <foreignObject>. The capture still fires before the SVG's internal font context has had a chance to settle.

What Works: Call toPng Twice

await document.fonts.ready;
// First call: triggers font embedding into the SVG blob
await toPng(element, options);
// Second call: fonts are now cached/embedded — capture is clean
const dataUrl = await toPng(element, options);

The first toPng invocation forces html-to-image to attempt font embedding into the SVG, which populates the library's internal font cache. By the time the second call executes, those fonts are already embedded and the render comes out clean.

It's counterintuitive, but it's a recognized workaround documented in the html-to-image issue tracker. The library doesn't expose a dedicated "await font embedding" hook, so the double-invocation pattern is the pragmatic solution until upstream support improves.

The Full Implementation

import { toPng } from 'html-to-image';
const PNG_OPTIONS = {
backgroundColor: '#ffffff',
pixelRatio: 3, // retina-quality output
};
export async function exportElementToPng(
element: HTMLElement,
filename: string
): Promise<void> {
await document.fonts.ready;
// First call primes the font cache
await toPng(element, PNG_OPTIONS);
// Second call is the real capture
const dataUrl = await toPng(element, PNG_OPTIONS);
const link = document.createElement('a');
link.download = `${filename}.png`;
link.href = dataUrl;
link.click();
}

Setting pixelRatio: 3 produces 3× resolution output — essential for sharp rendering on high-DPI and retina displays. Drop it, and exported equations will look noticeably soft on modern screens.

Capturing a Dynamically Created DOM Node

BlockTeXu also supports a "save all stock items as a single image" feature. That use case introduces an additional constraint: there's no pre-existing element to target. Instead, a temporary DOM node must be created programmatically, KaTeX rendered into it, the capture performed, and the node torn down afterward.

export async function renderLatexToImage(
latex: string,
filename: string
): Promise<void> {
// Create a container positioned off-screen
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.padding = '40px';
container.style.fontSize = '24px';
container.style.background = '#ffffff';
document.body.appendChild(container);
// Render math into the container
const { default: katex } = await import('katex');
katex.render(latex, container, {
throwOnError: false,
displayMode: true,
});
try {
await exportElementToPng(container, filename);
} finally {
// Guaranteed cleanup — even if export throws
document.body.removeChild(container);
}
}

Two implementation details here deserve closer attention.

left: '-9999px' — displaces the container well beyond the visible viewport, preventing any perceptible flash of unstyled content during rendering. The more intuitive alternatives, display: none or visibility: hidden, would suppress visual output but simultaneously prevent html-to-image from performing the layout measurement and rasterization it depends on. Visibility and geometry are tightly coupled in the browser's rendering pipeline — hide the element from layout, and you lose the capture.

try...finally — enforces deterministic DOM cleanup regardless of whether the export operation resolves or rejects. This is a critical correctness guarantee. Without it, any exception thrown during the PNG export phase would leave orphaned, invisible nodes accumulating silently in the document body — a subtle memory and DOM hygiene issue that only surfaces under failure conditions.

Should You Switch to html2canvas?

html2canvas takes a fundamentally different rendering path — it traverses the live DOM and paints directly to a Canvas element, bypassing the SVG <foreignObject> serialization step that makes font embedding problematic in html-to-image. In theory, this sidesteps the font-loading race condition entirely.

Switching was considered and ultimately ruled out for three reasons:

  1. The double-call workaround resolved the issue immediately, with zero additional configuration overhead
  2. html2canvas introduces its own rendering inconsistencies when dealing with KaTeX's layered, specificity-heavy CSS output
  3. Migrating to a different capture library solely to work around a problem that was already solved would introduce unnecessary surface area for regression

That said, if you're architecting a new pipeline from scratch and need robust cross-environment rendering fidelity, it's worth running a direct benchmark between html2canvas and html-to-image against your actual KaTeX output before committing to either.

Summary

Approach Result
toPng(element) Blank image — fonts not embedded
await document.fonts.ready then toPng Still blank — fonts.ready does not extend to SVG foreign objects
toPng twice Works — first invocation primes the internal font cache
Off-screen DOM + try...finally Works — guarantees cleanup even under failure conditions

When combining KaTeX with html-to-image, the double-call pattern is effectively non-negotiable. It's a narrow, well-defined fix for a non-obvious rendering pipeline quirk — the kind of thing that costs hours to diagnose the first time. Now you don't have to.

Next post: Building a drag-and-drop block editor with the HTML5 DnD API — covering palette drops, workspace reordering, and nested slot drops in a single cohesive system, with no external library dependencies.

Try BlockTeXu at blocktexu.com/en

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

KaTeX + html-to-image Outputs a Blank White Image — Here'...