Fixing KaTeX and html-to-image Blank Image Output: Causes and Solutions
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.
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:
- The double-call workaround resolved the issue immediately, with zero additional configuration overhead
-
html2canvasintroduces its own rendering inconsistencies when dealing with KaTeX's layered, specificity-heavy CSS output - 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
