Word-by-word Aligner lets each line use its own font, including one you upload, and then export the diagram as PNG or PDF. The export has to match the preview down to the glyph. Getting there, we ran into a problem that shows up in any HTML-to-image or SVG-to-canvas pipeline that leans on custom fonts, so it is worth writing down even if your app is nothing like this one.
The bug: exports in a fallback font
The export starts from the SVG the preview already produces. To turn that into a PNG, the standard move is to load the SVG into an Image and draw it onto a canvas:
const dataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('SVG image decode failed'));
img.src = dataUrl;
});
// ... drawImage onto a canvas, then canvas.toBlob()
This works until the SVG references a custom font through an embedded @font-face. When the browser renders an SVG inside an Image, the font load runs asynchronously, and the image often decodes before the font is ready. The first frame paints in a fallback family, and that frame is what the canvas captures. The preview looks right, the PNG comes out in the wrong font.
Waiting for fonts before drawing helps, but the Image rendering path gives no reliable signal that the embedded face has finished loading, so the race stays flaky.
The fix: remove the font from the raster path
What finally held was to stop depending on a font at raster time at all. Before rasterizing, every piece of text set in an uploaded font is converted to vector outlines. After that the SVG carries shapes, so there is no font left to load and nothing to race:
export async function convertCustomFontTextToPaths(svg: string, lines: LineV2[]): Promise<string> {
const customFamilies = collectCustomFamiliesFromLines(lines);
if (customFamilies.size === 0) return svg;
// ... load each font, shape with HarfBuzz, replace <text> with <path>
}
Two libraries do the work. HarfBuzz (through harfbuzzjs) shapes the text, meaning it decides which glyphs render and where they go, using the same engine family browsers do, so ligatures, contextual forms, and right-to-left ordering come out the way they do on screen. opentype.js then turns each shaped glyph into an SVG path. Shaping and outline extraction are separate jobs on purpose: HarfBuzz decides which glyphs go where, opentype.js draws them.
hbFont.setScale(fontSizePx, fontSizePx);
const buffer = hb.createBuffer();
buffer.addText(text);
buffer.guessSegmentProperties();
hb.shape(hbFont, buffer, 'kern,liga,rlig,clig,calt,ccmp');
const glyphs = buffer.json(); // [{ g, cl, dx, dy, ax, ay }, ...]
Each entry gives a glyph id (g), an advance (ax), and an offset (dx, dy). Walking them with a running pen position places each glyph, and opentype.js produces the path:
const glyph = otFont.glyphs.get(g.g);
const p = glyph.getPath(xPen + g.dx, baselineShift + g.dy, fontSizePx);
paths.push(`<path fill="${fill}" d="${p.toPathData(3)}"/>`);
Two details that bite
Glyph outlines use a coordinate system with the Y axis pointing up, the way type designers think. SVG points Y down. So the baseline is mapped across rather than copied. The SVG text uses dominant-baseline="central", which becomes a vertical shift derived from the font’s own ascender and descender:
function baselineOffsetY(baseline: string, font: opentype.Font, fontSize: number): number {
const ascentPx = (font.ascender / font.unitsPerEm) * fontSize;
const descentPxSigned = (font.descender / font.unitsPerEm) * fontSize;
if (baseline === 'central' || baseline === 'middle') return (ascentPx + descentPxSigned) / 2;
// ... other baselines
return 0;
}
The other detail is knowing when to leave text alone. Google Fonts stay as real <text>. They load reliably from small woff2 data URLs, keep the file smaller, and stay selectable in an SVG export, so only uploaded fonts go through the path conversion. And if HarfBuzz fails to load for any reason, the code falls back to shaping with opentype.js on its own, which still handles ligatures, rather than emitting a broken image.

When you need this
If your export is a screenshot of a DOM node you control, a library like html-to-image may be enough, and none of this is needed. The path conversion earns its keep once arbitrary user fonts are in play and the output has to be exact, which is the situation for design tools, label and certificate generators, anything that prints what someone typed in their own typeface. The shape of the fix stays the same wherever it applies: shape the text with HarfBuzz, draw the glyphs with opentype.js, and hand the rasterizer an SVG that no longer needs a font.
Word-by-word Aligner is free and runs in the browser at aligner.tinygods.dev. The code is open source (MIT) at github.com/tinygodsdev/bitext-word-alignment; the conversion lives in src/lib/fonts/text-to-paths.ts and the rasterizer in src/lib/export/svg-raster.ts.