DEV Community

hellokai
hellokai

Posted on

Rendering Custom Fonts to a 2048px PNG with Canvas

A browser preview can look correct while the downloaded image is wrong.

The usual failure is timing: CSS eventually applies the custom font to the preview, but Canvas draws once. If the font is not ready at that exact moment, fillText() can silently use a fallback face. The user sees one design and downloads another.

I ran into this while building GraffForge, a browser-based graffiti text tool. The free editor compares the same user-entered word across multiple bundled styles, then exports the selected result as a transparent 2048 × 2048 PNG. That gave the export path a clear contract:

  • preserve the exact text;
  • use the selected font;
  • keep spacing, outline, shadow, and skew;
  • fit inside a safe area;
  • preserve real transparency;
  • never upload the user's text or image.

Here is the approach that made the output deterministic.

1. Treat export as a separate rendering target

Do not enlarge the preview DOM and take a screenshot. Create a fresh Canvas with explicit bitmap dimensions:

const EXPORT_SIZE = 2048;

const canvas = document.createElement('canvas');
canvas.width = EXPORT_SIZE;
canvas.height = EXPORT_SIZE;

const context = canvas.getContext('2d');
if (!context) {
  throw new Error('Canvas rendering is unavailable.');
}
Enter fullscreen mode Exit fullscreen mode

The width and height attributes define the actual PNG pixel dimensions. CSS sizing and devicePixelRatio are useful for an on-screen preview, but neither should determine the export contract.

A fixed bitmap size also makes automated verification straightforward.

2. Load the font before measuring anything

Canvas does not redraw automatically when a font finishes loading. Load the exact family, weight, size, and text before calling measureText():

await document.fonts.load(
  `400 160px "${fontFamily}"`,
  text
);
Enter fullscreen mode Exit fullscreen mode

Passing the actual text is useful because the browser can confirm that the required glyphs are available.

After this point, set the Canvas font explicitly:

context.font = `400 ${fontSize}px "${fontFamily}"`;
Enter fullscreen mode Exit fullscreen mode

For a larger font system, I also recommend checking document.fonts.check() in tests. A preview looking correct is not sufficient proof that every export font loaded.

3. Measure custom letter spacing yourself

Canvas text measurement does not give you portable control over per-character spacing. For short display text, measuring and drawing one character at a time is predictable:

function measureSpacedText(ctx, text, spacing) {
  const characters = Array.from(text);

  const glyphWidth = characters.reduce(
    (total, character) =>
      total + ctx.measureText(character).width,
    0
  );

  return (
    glyphWidth +
    Math.max(0, characters.length - 1) * spacing
  );
}
Enter fullscreen mode Exit fullscreen mode

Drawing uses the same measurement logic:

function drawSpacedText(
  ctx,
  text,
  centerX,
  centerY,
  spacing,
  stroke
) {
  const characters = Array.from(text);
  const totalWidth = measureSpacedText(
    ctx,
    text,
    spacing
  );

  let cursor = centerX - totalWidth / 2;

  for (const character of characters) {
    const width = ctx.measureText(character).width;
    const x = cursor + width / 2;

    if (stroke) ctx.strokeText(character, x, centerY);
    else ctx.fillText(character, x, centerY);

    cursor += width + spacing;
  }
}
Enter fullscreen mode Exit fullscreen mode

Array.from() is sufficient for the short Latin-oriented text in this tool because it iterates Unicode code points rather than UTF-16 code units. A general multilingual editor should use Intl.Segmenter so grapheme clusters such as combined emoji stay intact.

4. Fit the result into a safe width

A fixed export does not mean fixed text size. Start from the requested size, measure it, and scale down only when needed:

const desiredSize = Math.max(
  320,
  settings.fontSize * 9
);

context.font =
  `400 ${desiredSize}px "${fontFamily}"`;

const desiredSpacing = settings.letterSpacing * 9;
const measured = measureSpacedText(
  context,
  text,
  desiredSpacing
);

const safeWidth = EXPORT_SIZE * 0.78;
const scale = Math.min(
  1,
  safeWidth / Math.max(measured, 1)
);

const fontSize = Math.floor(desiredSize * scale);
const spacing = desiredSpacing * scale;
Enter fullscreen mode Exit fullscreen mode

The important detail is scaling the spacing and effect widths with the text. If only the font size changes, the outline and shadow no longer match the preview.

5. Preserve transparency and draw effects in order

Canvas starts transparent. Leave it that way when the user requests a transparent PNG:

if (!settings.transparentBackground) {
  context.fillStyle = settings.backgroundColor;
  context.fillRect(
    0,
    0,
    EXPORT_SIZE,
    EXPORT_SIZE
  );
}
Enter fullscreen mode Exit fullscreen mode

Then configure the effects and draw the outline before the fill:

context.textAlign = 'center';
context.textBaseline = 'middle';
context.lineJoin = 'round';
context.miterLimit = 2;

context.shadowColor = settings.shadowColor;
context.shadowBlur = settings.shadowBlur * 6;
context.shadowOffsetX = settings.shadowOffsetX * 6;
context.shadowOffsetY = settings.shadowOffsetY * 6;

context.strokeStyle = settings.outlineColor;
context.lineWidth = settings.outlineWidth * 7;

drawSpacedText(
  context,
  text,
  EXPORT_SIZE / 2,
  EXPORT_SIZE / 2,
  spacing,
  true
);

context.fillStyle = settings.fillColor;

drawSpacedText(
  context,
  text,
  EXPORT_SIZE / 2,
  EXPORT_SIZE / 2,
  spacing,
  false
);
Enter fullscreen mode Exit fullscreen mode

Use save() and restore() around transforms such as skew so the export function does not leak state into another drawing operation.

6. Export a Blob and clean up the URL

const blob = await new Promise((resolve, reject) => {
  canvas.toBlob((result) => {
    if (result) resolve(result);
    else reject(new Error('PNG export failed.'));
  }, 'image/png');
});

const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');

anchor.href = url;
anchor.download = 'graffiti-text.png';
anchor.click();

setTimeout(() => URL.revokeObjectURL(url), 0);
Enter fullscreen mode Exit fullscreen mode

A Blob avoids building a large base64 string in memory, and revoking the object URL prevents repeated exports from leaking resources.

7. Test the file, not just the button

An end-to-end test should download the PNG and verify its contents:

  1. confirm the PNG signature;
  2. read the IHDR width and height;
  3. assert both dimensions are 2048;
  4. decode pixels and confirm that transparent export contains alpha values below 255;
  5. wait for fonts and verify every configured family is available;
  6. test representative short, long, and spaced words.

GraffForge currently runs this journey across 12 styles and a representative set of 50 words. That catches fallback fonts, clipping, incorrect dimensions, and fake transparency much earlier than visual inspection alone.

Takeaways

Reliable Canvas export comes from treating the downloaded file as a real rendering target:

  • load fonts before measuring;
  • use one measurement algorithm for layout and drawing;
  • scale text, spacing, and effects together;
  • keep bitmap dimensions explicit;
  • leave Canvas untouched for real transparency;
  • verify the resulting PNG bytes in automated tests.

If you want to try the finished behavior, the live graffiti font generator is available at:

https://graffforge.com/graffiti-font-generator

The interesting part is not the graffiti styling itself. It is making sure the image the user downloads is the same design they approved in the browser.

Top comments (0)