Skip to content

Handwriting with the Canvas API

Published
Reading time
10 minutes
Using SVG and the Canvas API to draw custom typefaces and create hand-drawn effects

This is the fourth article about the technical details behind my generative art project Ceramics. In this one, we’re diving into how I made the little hand-numbered marks in the corner of each mint.

When I first started working on Ceramics I was playing with the concept of carving/engraving, and alongside the clay-based materials, I experimented with metals (you can take the girl out of the jewellery industry, but you’ll never take the jewellery out of the girl!). One of the features I developed for that was a hallmarking system; hallmarks are an international standard for authenticating the quality of precious metal objects and identifying their maker and year of production. In the UK hallmarking laws date back to the 11th century. I thought it would be cool to develop my own digital ‘maker’s mark’ and an assay office symbol representing the Ethereum blockchain.

I decided to break off the metal-based work into a separate project and develop the clay-based work into a long-form collection, but I was disappointed to leave behind the hallmark because it was such a cool touch that I felt elevated each composition. Towards the end of developing Ceramics it felt like something was missing so I came back to the idea of hallmarks; the pottery industry isn’t regulated to the same degree as precious metals, but it is common for potters to mark their work with a custom stamp or scrawled signature. For Ceramics, I wanted to include a maker’s mark and number each piece with the unique mint number, in a similar fashion to numbering print runs. Since Ceramics is all about tactility and human touch it felt important to have the numbering feel hand-written, which is what led me on this journey. Enough with the backstory, on with the code!

Using fonts

Importing fonts

My first idea was to use a cursive font to approximate handwriting; I knew I could subset the numerals (cough cough) and end up with quite a small file size. The CanvasRenderingContext2D.fontCanvasRenderingContext2D.font property takes the same values as the CSS font shorthand property, so fonts can be loaded using the @font-face at-rule or using the Font Loading API. We want to use the latter so we can be sure the font is loaded before drawing on the canvas.

.js
const font = new FontFace('Caveat', 'url(caveat.woff2)')
document.fonts.add(font)
font.load()
 
document.fonts.ready.then(() => {
  c.font = '16px Caveat'
  c.fillText('Hello world', 0, 0)
})
.js
const font = new FontFace('Caveat', 'url(caveat.woff2)')
document.fonts.add(font)
font.load()
 
document.fonts.ready.then(() => {
  c.font = '16px Caveat'
  c.fillText('Hello world', 0, 0)
})

The problem with this method is that most blockchain-based projects like Ceramics can’t have external dependencies (or are restricted to specific libraries), so loading a font from an external source is a no-go.

Embedding fonts

The method I’m most familiar with for embedding fonts is using a Base64 data URI, this used to be a common way to make icon glyphs before SVG was widely supported on the web. You can generate this string from any font file using FontSquirrel’s webfont generator and checking ‘Base64 Encode’ in the expert options.

One of the bonuses of using this technique is that you can use the CSS @font-face at-rule and not worry about whether the font is loaded – as long as the CSS comes before your JavaScript it will always be there. Here I’m creating the CSS declaration in JavaScript so all our logic can be in one place.

.js
const style = document.createElement('style')
style.textContent = `@font-face {font-family: 'Caveat'; src: url(data:application/font-woff2;charset=utf-8;base64,...) format('woff2');}`
document.head.appendChild(style)
 
c.font = '16px Caveat'
c.fillText('Hello world', 0, 0)
.js
const style = document.createElement('style')
style.textContent = `@font-face {font-family: 'Caveat'; src: url(data:application/font-woff2;charset=utf-8;base64,...) format('woff2');}`
document.head.appendChild(style)
 
c.font = '16px Caveat'
c.fillText('Hello world', 0, 0)

And now the downside – Base64-encoded fonts can be quite chunky. Subsetting just the numerals and hash glyph from Caveat ended up with an 8kB data URI. That is not a big deal at all for loading a font on a website, but for a blockchain-based generative art project it gets very costly very quickly. This depends on the chain and transaction fee, but if I were to upload 8kB to the Ethereum blockchain at the time of writing it would cost £130, and uploading the whole character set would cost over £2000. Can you see why people don’t do this very often?

Using SVG

Embedding 8kB of fonts wasn’t the end of the world for me, but I did question whether it had the intended effect; sure the font looks like handwriting but it didn’t have that personal, unique feel, so I started thinking about other ways to draw numbers that would be a bit more naturalistic, as well as more lightweight.

Hand-drawn paths

If I want the numbers to look like they’ve been hand-carved, why not use my actual handwriting? I scribbled some example numbers in a notebook and traced them in Figma, refining the paths for readability and balance.

Instead of tracing the outline of each character and filling them in, I drew them as strokes; this reduces the amount of data for each character and will give us more flexibility later on when rendering them.

The Canvas API’s Path2DPath2D constructor allows you to pass in an SVG path (the bit in the dd attribute of an SVG <path><path> element), so we can store just the paths for each character and draw them directly onto the canvas.

I exported all the characters as SVGs with a 100*100 viewBox and ran them through SVGO for minification. I also assigned a relative width to each character for kerning.

Code snippet
.js
const CHAR_PATHS = {
  1: [0.4, 'M54 6c0 4 3 6 1 20l-9 49c-2 14 0 17-1 21'],
  ...
}
const CHAR_HEIGHT = 100
 
// smooth out the edges
c.lineCap = 'round'
c.lineJoin = 'round'
 
// scale size here so everything else can be out of 1
c.scale(CHAR_HEIGHT, CHAR_HEIGHT)
 
text.split('').forEach((char) => {
  // get relative width and path of character
  const [charWidth, charPath] = CHAR_PATHS[char]
 
  // centre character
  c.translate(charWidth / 2, 0)
 
  c.save()
  // scale to compensate for 100*100 viewBox
  c.scale(1 / 100, 1 / 100)
  const path = new Path2D(charPath)
  c.stroke(path)
  c.restore()
 
  // shift right
  c.translate(charWidth / 2, 0)
})
.js
const CHAR_PATHS = {
  1: [0.4, 'M54 6c0 4 3 6 1 20l-9 49c-2 14 0 17-1 21'],
  ...
}
const CHAR_HEIGHT = 100
 
// smooth out the edges
c.lineCap = 'round'
c.lineJoin = 'round'
 
// scale size here so everything else can be out of 1
c.scale(CHAR_HEIGHT, CHAR_HEIGHT)
 
text.split('').forEach((char) => {
  // get relative width and path of character
  const [charWidth, charPath] = CHAR_PATHS[char]
 
  // centre character
  c.translate(charWidth / 2, 0)
 
  c.save()
  // scale to compensate for 100*100 viewBox
  c.scale(1 / 100, 1 / 100)
  const path = new Path2D(charPath)
  c.stroke(path)
  c.restore()
 
  // shift right
  c.translate(charWidth / 2, 0)
})

Randomising glyphs

To add more colour to the lettering I drew out several glyphs for each character so I could randomly pick and choose them; if a mint number had any repeating digits this would make them look much more natural (and couldn’t be done with a typeface!).

The order didn’t particularly matter, but I wanted to make sure that in a triple-repeat number (like #111) there weren’t any repeating glyphs. I did this by picking the first occurrence of a digit randomly, and incrementing the index of it for any subsequent matching digits.

Code snippet
.js
const CHAR_PATHS = {
  1: [
    0.4,
    'M54 6c0 4 3 6 1 20l-9 49c-2 14 0 17-1 21',
    'M48 6c1 8 4 20 4 37s-5 40-6 50',
    'M53 8c1 17-3 68-2 85',
  ],
  // ...
}
 
// keep track of which paths have been used for each character
let pickedPathIndex = {}
 
text.split('').forEach((char) => {
  const [charWidth, ...charPaths] = CHAR_PATHS[char]
 
  const pathCount = charPaths.length
  // if we've never used this character, pick a random path
  // if we've already used this character, use the next path
  const pathIndex =
    pickedPathIndex[char] === undefined
      ? Math.floor(Math.random() * pathCount)
      : (pickedPathIndex[char] + 1) % pathCount
  // save path choice for next time
  pickedPathIndex[char] = pathIndex
 
  c.translate(charWidth / 2, 0)
  c.save()
  c.scale(1 / 100, 1 / 100)
  const path = new Path2D(charPaths[pathIndex])
  c.stroke(path)
  c.restore()
  c.translate(charWidth / 2, 0)
})
.js
const CHAR_PATHS = {
  1: [
    0.4,
    'M54 6c0 4 3 6 1 20l-9 49c-2 14 0 17-1 21',
    'M48 6c1 8 4 20 4 37s-5 40-6 50',
    'M53 8c1 17-3 68-2 85',
  ],
  // ...
}
 
// keep track of which paths have been used for each character
let pickedPathIndex = {}
 
text.split('').forEach((char) => {
  const [charWidth, ...charPaths] = CHAR_PATHS[char]
 
  const pathCount = charPaths.length
  // if we've never used this character, pick a random path
  // if we've already used this character, use the next path
  const pathIndex =
    pickedPathIndex[char] === undefined
      ? Math.floor(Math.random() * pathCount)
      : (pickedPathIndex[char] + 1) % pathCount
  // save path choice for next time
  pickedPathIndex[char] = pathIndex
 
  c.translate(charWidth / 2, 0)
  c.save()
  c.scale(1 / 100, 1 / 100)
  const path = new Path2D(charPaths[pathIndex])
  c.stroke(path)
  c.restore()
  c.translate(charWidth / 2, 0)
})

Variable stroke width

This is starting to look good, but the consistent thickness of the strokes gives them away as not being drawn by hand. The Canvas API doesn’t include the ability to vary the width of a stroke, but it’s commonly achieved by drawing lots of circles at different sizes along the path of a stroke.

I thought this technique wouldn’t be possible because we’re using SVG paths (as opposed to a mathematical curve where you could calculate an arbitrary position along the path) BUT then I discovered the getPointAtLengthgetPointAtLength method on SVGGeometryElement. To use this method we need to create an SVG path element rather than using the Canvas Path2D constructor.

Code snippet
.js
import { createNoise2D } from 'simplex-noise'
const noise2D = createNoise2D()
 
const PATH_STEP = 5
 
// the element needs to be in the SVG namespace rather than default HTML namespace
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
 
text.split('').forEach((char, charI) => {
  const [charWidth, ...charPaths] = CHAR_PATHS[char]
  // get the pathIndex...
 
  c.translate(charWidth / 2, 0)
  c.save()
  c.scale(1 / 100, 1 / 100)
 
  svgPath.setAttributeNS(null, 'd', charPaths[pathIndex])
  const pathLength = svgPath.getTotalLength()
 
  for (let l = 0; l < pathLength; l += PATH_STEP) {
    const p = svgPath.getPointAtLength(l)
 
    // use noise to sequentially vary the stroke width
    let strokeVariance = noise2D(charI * 0.1, l * 0.01)
    const radius = 10 * (1 + strokeVariance * 0.2)
 
    c.beginPath()
    c.arc(p.x, p.y, radius, 0, Math.PI * 2)
    c.fill()
  }
 
  c.restore()
  c.translate(charWidth / 2, 0)
})
.js
import { createNoise2D } from 'simplex-noise'
const noise2D = createNoise2D()
 
const PATH_STEP = 5
 
// the element needs to be in the SVG namespace rather than default HTML namespace
const svgPath = document.createElementNS('http://www.w3.org/2000/svg', 'path')
 
text.split('').forEach((char, charI) => {
  const [charWidth, ...charPaths] = CHAR_PATHS[char]
  // get the pathIndex...
 
  c.translate(charWidth / 2, 0)
  c.save()
  c.scale(1 / 100, 1 / 100)
 
  svgPath.setAttributeNS(null, 'd', charPaths[pathIndex])
  const pathLength = svgPath.getTotalLength()
 
  for (let l = 0; l < pathLength; l += PATH_STEP) {
    const p = svgPath.getPointAtLength(l)
 
    // use noise to sequentially vary the stroke width
    let strokeVariance = noise2D(charI * 0.1, l * 0.01)
    const radius = 10 * (1 + strokeVariance * 0.2)
 
    c.beginPath()
    c.arc(p.x, p.y, radius, 0, Math.PI * 2)
    c.fill()
  }
 
  c.restore()
  c.translate(charWidth / 2, 0)
})

To add more variability to the characters I also randomly scaled the X and Y axes between 90% and 110%.

Making it 3D

If you’ve read my first article you’d know that I used a bump map to create the depth effect in Ceramics. I used a complex arrangement of linear gradients to make the strokes, but for this text I decided to use the simpler radiant gradient method – basically swapping out the circles from the last demo with radial gradients blended on top of one another. This does create a strange effect if the circles are too far away from each other but since the numbering was pretty small on the final piece this wasn’t a concern.

Final thoughts

Although the numbering is a small visual element in each Ceramics mint, I think it’s very impactful in contextualising the scale of each piece and adding that extra hand-made touch.

There aren’t many generative art projects that use textual elements because it’s often impossible to import a custom font, but I hope this article shows there are other ways to incorporate typography into generative work. These techniques could definitely be extended to entire lettersets and ligatures, if you use them please share it with me!

On a personal note, Ceramics went live five weeks ago and I’ve been pretty quiet since because my health has been particularly poor. I’m behind on setting up a print service but hope to work on that in the next couple of weeks, and I have a few more technical deep-dives to write before this project is all wrapped up.

I’m so grateful for all the support of my work, truthfully I’m completely incapable of having an actual job at the moment (with long COVID I have the stamina to work at most a couple of hours a day, and often not for days at a time), so being able to live off my art and have the flexibility to work at whatever pace my body allows is everything to me. My deepest thanks to my collectors, I hope I can share some more work with you soon.