notes.dt.in.th

Drawing low resolution monochrome text with freetype-wasm

I recently got myself a label printer, which can only print pixels in monochrome (black or white, no grayscale). Separately, earlier this year I bought this gorgeous monospace font Berkeley Mono. It looks great on my terminal… Now I want to print labels using this same font… but the result looks like the first image below:

To get a better printout (second image), I need to find an alternative way to render text — one that can render small text as monochrome pixels well. That’s when I found The FreeType Auto-Hinter.

Now I need a way to use it to generate a monochrome bitmap image. As I’m a web developer, I want something that I can use in a web browser. That’s when I found freetype-wasm.

Lastly, the Local Font Access API lets web applications directly access locally-installed fonts like a desktop font rather than a web font.

Try it out!

First, we need access to your fonts:

Pick a font and size below:

Enter some text to render it:

The FreeType renderer

This function initializes the FreeType library and provides a way to load fonts and render text.

import FreeTypeInit from 'https://cdn.jsdelivr.net/npm/freetype-wasm@0/dist/freetype.js'

// Based on https://github.com/Ciantic/freetype-wasm/blob/master/example/example.js
async function createFreeTypeRenderer() {
  const FreeType = await FreeTypeInit()

  /**
   * Loads a font from a URL or ArrayBuffer.
   */
  const loadFont = async (url, { forceAutoHint = true } = {}) => {
    let font
    if (typeof url === 'string') {
      const response = await fetch(url)
      if (!response.ok) {
        throw new Error('Failed to load font')
      }
      font = await response.arrayBuffer()
    } else if (url instanceof ArrayBuffer) {
      font = url
    } else {
      throw new Error('Invalid font source')
    }

    const [face] = FreeType.LoadFontFromBytes(new Uint8Array(font))

    /**
     * Creates a renderer for a specific font size.
     */
    const withSize = (size, handleNewGlyph = (code, char, glyph) => {}) => {
      const cache = new Map()
      async function updateCache(str) {
        FreeType.SetFont(face.family_name, face.style_name)
        FreeType.SetCharmap(FreeType.FT_ENCODING_UNICODE)
        FreeType.SetPixelSize(0, size)

        // Get char codes without bitmaps
        const codes = []
        for (const char of new Set(str)) {
          const point = char.codePointAt(0)
          if (!cache.has(char) && point !== undefined) {
            codes.push(point)
          }
        }

        // Populate missing bitmaps
        const newGlyphs = FreeType.LoadGlyphs(
          codes,
          FreeType.FT_LOAD_RENDER |
            FreeType.FT_LOAD_MONOCHROME |
            FreeType.FT_LOAD_TARGET_MONO |
            (forceAutoHint ? FreeType.FT_LOAD_FORCE_AUTOHINT : 0)
        )
        for (const [code, glyph] of newGlyphs) {
          const char = String.fromCodePoint(code)
          await handleNewGlyph(code, char, glyph)
          cache.set(char, {
            glyph,
            bitmap: glyph.bitmap.imagedata
              ? await createImageBitmap(glyph.bitmap.imagedata)
              : null,
          })
        }
      }

      /**
       * @param {CanvasRenderingContext2D} ctx
       * @param {string} str
       * @param {number} offsetx
       * @param {number} offsety
       */
      const draw = async (
        ctx,
        str,
        offsetx,
        offsety,
        { letterSpacing = 0 } = {}
      ) => {
        await updateCache(str)
        let prev = null
        for (const char of str) {
          const { glyph, bitmap } = cache.get(char) || {}
          if (glyph) {
            // Kerning
            if (prev) {
              const kerning = FreeType.GetKerning(
                prev.glyph_index,
                glyph.glyph_index,
                0
              )
              offsetx += kerning.x >> 6
            }

            if (bitmap) {
              ctx.drawImage(
                bitmap,
                offsetx + glyph.bitmap_left,
                offsety - glyph.bitmap_top
              )
            }

            offsetx += glyph.advance.x >> 6
            offsetx += letterSpacing
            prev = glyph
          }
        }
      }

      const measure = async (str, { letterSpacing = 0 } = {}) => {
        await updateCache(str)
        let width = 0
        let prev = null
        for (const char of str) {
          const { glyph } = cache.get(char) || {}
          if (glyph) {
            if (prev) {
              const kerning = FreeType.GetKerning(
                prev.glyph_index,
                glyph.glyph_index,
                0
              )
              width += kerning.x >> 6
              width += letterSpacing
            }
            width += glyph.advance.x >> 6
            prev = glyph
          }
        }
        return width
      }

      const getGlyph = async (char) => {
        await updateCache(char)
        return cache.get(char)
      }
      return { draw, measure, getGlyph }
    }
    return { withSize }
  }
  return { loadFont }
}

Example usage

const renderer = await createFreeTypeRenderer()

const berkeleyMono = await window
  .queryLocalFonts({ postscriptNames: ['BerkeleyMono-Bold'] })
  .then((fonts) => fonts[0].blob())
  .then((blob) => blob.arrayBuffer())
  .then((buffer) => renderer.loadFont(buffer))

await berkeleyMono.withSize(19).draw(ctx, 'Hello, world!', 0, 19)