Table of Contents

Summary

I wanted to make my own handwriting font. I also wanted to be able to generate fonts quickly from the handwriting samples I can draw on my ta [ State ]: EDITED, shown value does not take effect until you set or save it.

https://pages.sachachua.com/sachac-hand/README.html This README as HTML
https://github.com/sachac/sachac-hand Github repo
./files/test.html Test pages

License

Blog post

I wanted to make a font based on my handwriting using only free software. It turns out that FontForge can be scripted with Python. I know just a little about Python and even less about typography, but I managed to hack together something that worked for me. If you're reading this on my blog at https://sachachua.com/blog/ , you'll probably see the new font being used on the blog post titles. Whee!

My rough notes are at https://github.com/sachac/sachac-hand/ . I wanted to write it as a literate program using Org Babel blocks. It's not really fully reproducible yet, but it might be a handy starting point. The basic workflow was:

  1. Generate a template using other fonts as the base.
  2. Import the template into Medibang Paint on my phone and draw letters on a different layer. (I almost forgot the letter q, so I had to add it at the last minute.)
  3. Export just the layer with my writing.
  4. Cut the image into separate glyphs using Python and autotrace each one.
  5. Import each glyph into FontForge as an SVG and a PNG.
  6. Set the left side and right side bearing, overriding as needed based on a table.
  7. Figure out kerning classes.
  8. Hand-tweak the contours and kerning.
  9. Use sfnt2woff to export the web font file for use on my blog, and modify the stylesheet to include it.

I really liked being able to specify kerning classes through an Org Mode table like this:

  None o,a,c,e,d,g,q,w f,t,x,v,y,z h,b,l,i,k j m,n,p,r,u s T zero
None 0 0 0 0 0 0 0 0 0
f 0 -102 -61 -30 0 -60 0 -120 -70
t 0 -70 -41 -25 0 0 0 -120 -10
r 0 -82 -41 -25 0 -20 0 -120 29
k 0 -50 -81 -20 0 -20 -48 -120 -79
l 0 -41 -50 0 0 0 0 -120 -52
v 0 -40 -35 -30 0 0 0 -120 30
b,o,p 0 -20 -80 0 0 0 0 -120 43
a 0 -23 -60 0 0 0 0 -120 7
W 0 -40 -30 -20 0 0 0 -120 17
T 0 -190 -120 -60 0 -130 0 0 -188
F 0 -100 -90 -60 0 -70 -100 -40 -166
two 0 0 0 0 0 0 0 0 -53

I had a hard time defining classes using the FontForge interface because I occasionally ended up clearing my glyph selection, so it was great being able to just edit my columns and rows.

Clearly my kerning is still very rough–no actual values for j, for example–but it's a start. Also, I can probably figure out how to combine this with character pair kerning and have two tables for easier tweaking.

A- insisted on tracing my handwriting template a few times, so I might actually be able to go through the same process to convert her handwriting into a font. Whee!

Things I needed to install

sudo apt-get install fontforge python3-fontforge python3-numpy python3-sqlalchemy python3-pandas python3-pymysql python3-nltk woff-tools woff2 python3-yattag python3-livereload

I compiled autotrace based on my fork at https://github.com/sachac/autotrace so that it uses Graphicsmagick instead of Imagemagick.

I also needed (setenv "LD_LIBRARY_PATH" "/usr/local/lib"). There are probably a bunch of other prerequisites I've forgotten to write down.

Errors fixed along the way

  • FileNotFoundError: [Errno 2] No such file or directory: '/home/sacha/.local/lib/python3.8/site-packages/aglfn/agl-aglfn/aglfn.txt'
    • symlink or copy the one from /usr/share/aglfn to the right place

General font code

Parameters and common functions

import numpy as np
import pandas as pd
import aglfn
import fontforge
import subprocess

params = {'template': 'template-256.png',
  'sample_file': 'sample.png',
  'name_list': 'aglfn.txt',
  'new_font_file': 'sachacHand.sfd',
  'new_otf': 'sachacHand.otf',
  'new_font_name': 'sachacHand',
  'new_family_name': 'sachacHand',
  'new_full_name': 'sachacHand',
  'text_color': 'lightgray',
  'glyph_dir': 'glyphs/',
  'letters': 'HOnodpagscebhklftijmnruwvxyzCGABRDLEFIJKMNPQSTUVWXYZ0123456789?:;-–—=!\'’"“”@/\\~_#$%&()*+,.<>[]^`{|}q',
  'direction': 'vertical',
  'rows': 10, 
  'columns': 10, 
  'x_height': 368,
  'em': 1000, 
  'em_width': 1000, 
  'row_padding': 0,
  'ascent': 800, 
  'descent': 200, 
  'height': 500, 
  'width': 500, 
  'caps': 650,
  'line_width': 3,
  'text': "Python+FontForge+Org: I made a font based on my handwriting!"
  }
fontforge.loadNamelist(params['name_list'])
params['font_size'] = int(params['em'])
params['baseline'] = params['em'] - params['descent']

def transpose_letters(letters, width, height):
  return ''.join(np.reshape(list(letters.ljust(width * height)), (height, width)).transpose().reshape(-1))

# Return glyph name of s, or s if none (possibly variant)
def glyph_name(s):
  return aglfn.name(s) or s

def get_glyph(font, g):
  pos = font.findEncodingSlot(g)
  if pos == -1:
    return font.createChar(pos, g)
  else:
    return font[pos]

def glyph_matrix(font=None, matrix=None, letters=None, rows=0, columns=0, direction='horizontal', **kwargs):
  if matrix:
    if isinstance(matrix[0], str):
      # Split each
      matrix = [x.split(',') for x in matrix]
    else:
      matrix = matrix[:]  # copy the list
  else:
    matrix = np.reshape(list(letters.ljust(rows * columns))[0:rows * columns], (rows, columns))
    if direction == 'vertical':
      matrix = matrix.transpose()
  matrix = [[glyph_name(x) if x != 'None' else None for x in row] for row in matrix]
  if font:
    for r, row in enumerate(matrix):
      for c, col in enumerate(row):
        if col is None: continue
        matrix[r][c] = get_glyph(font, col)
  return matrix

def glyph_filename_base(glyph_name):
  try:
    return 'uni%s-%s' % (hex(ord(aglfn.to_glyph(glyph_name))).replace('0x', '').zfill(4), glyph_name)
  except:
    return glyph_name

def load_font(params):
  if type(params) == str:
    return fontforge.open(params)
  else:
    return fontforge.open(params['new_font_file'])

def save_font(font, font_filename=None, **kwargs):
  if font_filename is None:
    font_filename = font.fontname + '.sfd'
  font.save(font_filename)
  font.generate(font_filename.replace('.sfd', '.otf'))
  subprocess.call(['sfnt2woff', font_filename.replace('.sfd', '.otf')])

import orgbabelhelper as ob
def out(df, **kwargs):
  print(ob.dataframe_to_orgtable(df, **kwargs))

Generate guidelines

Code to make the template

from PIL import Image, ImageFont, ImageDraw

#LETTERS = 'abcd'
# Baseline is red
# Top of glyph is light blue
# Bottom of glyph is blue
def draw_letter(column, row, letter, params):
  draw = params['draw']
  sized_padding = int(params['row_padding'] * params['em'] / params['height'])
  origin = (column * params['em_width'], row * (params['em'] + sized_padding))
  draw.line((origin[0], origin[1], origin[0] + params['em_width'], origin[1]), fill='lightblue', width=params['line_width'])
  draw.line((origin[0], origin[1], origin[0], origin[1] + params['em']), fill='lightgray', width=params['line_width'])
  draw.line((origin[0], origin[1] + params['ascent'] - params['x_height'], origin[0] + params['em_width'], origin[1] + params['ascent'] - params['x_height']), fill='lightgray', width=params['line_width'])
  draw.line((origin[0], origin[1] + params['ascent'], origin[0] + params['em_width'], origin[1] + params['ascent']), fill='red', width=params['line_width'])
  draw.line((origin[0], origin[1] + params['ascent'] - params['caps'], origin[0] + params['em_width'], origin[1] + params['ascent'] - params['caps']), fill='lightgreen', width=params['line_width'])
  draw.line((origin[0], origin[1] + params['em'], origin[0] + params['em_width'], origin[1] + params['em']), fill='blue', width=params['line_width'])
  width, height = draw.textsize(letter, font=params['font'])
  draw.text((origin[0] + (params['em_width'] - width) / 2, origin[1]), letter, font=params['font'], fill=params['text_color'])

def make_template(params):
  sized_padding = int(params['row_padding'] * params['em'] / params['height'])
  img = Image.new('RGB', (params['columns'] * params['em_width'], params['rows'] * (params['em'] + sized_padding)), 'white')
  params['draw'] = ImageDraw.Draw(img)
  params['font'] = ImageFont.truetype(params['font_name'], params['font_size'])
  matrix = glyph_matrix(**params)
  for r, row in enumerate(matrix):
    for c, ch in enumerate(row):
      draw_letter(c, r, ch, params)
  img.thumbnail((params['columns'] * params['width'], params['rows'] * (params['height'] + params['row_padding'])))
  img.save(params['template'])
  return params['template']

Actually make the templates

<<params>>
<<def_make_template>>
#make_template({**params, 'font_name': '/home/sacha/.fonts/Romochka.otf', 'template': 'template-romochka.png', 'row_padding': 15}) 
#make_template({**params, 'font_name': '/home/sacha/.fonts/Breip.ttf', 'template': 'template-breip.png', 'row_padding': 15}) 
make_template({**params, 'font_name': '/home/sacha/.fonts/KGPrimaryDots.ttf', 
  'letters': 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890?:;-–—=!\'’"“”@/\\~_#$%&()*+,.<>[]^`{|}', 'direction': 'horizontal', 'text_color': 'black',
  'template': 'template-kg.png', 'ascent': 800, 'descent': 200, 'caps': 600, 'x_height': 340, 'row_padding': 50}) 
make_template({**params, 'font_name': 'sachacHand.otf', 'template': 'template-sachacHand.png', 'row_padding': 50})
return make_template({**params, 'font_name': 'sachacHand.otf', 
'template': 'template-sample.png', 'direction': 'horizontal', 'rows': 4, 'columns': 4, 'height': 100, 'width': 100, 'row_padding': 100 }) 

template-sample.png

Cut into glyphs

import os
import libxml2
from PIL import Image, ImageOps
import subprocess
def cut_glyphs(sample_file="", letters="", direction="", columns=0, rows=0, height=0, width=0, row_padding=0, glyph_dir='glyphs', matrix=None, force=False, **kwargs):
  im = Image.open(sample_file).convert('1')
  if not os.path.exists(glyph_dir):
    os.makedirs(glyph_dir)
  matrix = glyph_matrix(matrix=matrix, letters=letters, direction=direction, columns=columns, rows=rows)
  for r, row in enumerate(matrix):
    top = r * (height + row_padding)
    bottom = top + height
    for c, ch in enumerate(row):
      if ch is None: continue
      filename = os.path.join(glyph_dir, glyph_filename_base(ch) + '.pbm')
      if os.path.exists(filename) and not force: continue
      left = c * width
      right = left + width
      small = im.crop((left, top, right, bottom))
      small.save(filename)
      svg = filename.replace('.pbm', '.svg')
      png = filename.replace('.pbm', '.png')
      small.save(png)
      subprocess.call(['autotrace', '-output-file', svg, filename])
      doc = libxml2.parseFile(svg)
      root = doc.children
      child = root.children
      child.next.unlinkNode()
      doc.saveFile(svg)

Import SVG outlines into font

import fontforge
import os
import aglfn

def set_up_font_info(font, new_family_name="", new_font_name="", new_full_name="", em=1000, descent=200, ascent=800, **kwargs):
  font.encoding = 'UnicodeFull'
  font.fontname = new_font_name
  font.familyname = new_family_name
  font.fullname = new_full_name
  font.em = em
  font.descent = descent
  font.ascent = ascent
  return font

def import_glyphs(font, glyph_dir='glyphs', letters=None, columns=None, rows=None, direction=None, matrix=None, height=0, **kwargs):
  old_em = font.em
  font.em = height
  matrix = glyph_matrix(font=font, matrix=matrix, letters=letters, columns=columns, rows=rows, direction=direction)
  for row in matrix:
    for g in row:
      if g is None: continue
      try:
        base = glyph_filename_base(g.glyphname)
        svg_filename = os.path.join(glyph_dir, base + '.svg')
        png_filename = os.path.join(glyph_dir, base + '.png')
        g.clear()
        g.importOutlines(png_filename)
        g.importOutlines(svg_filename)
      except Exception as e:
        print("Error with ", g, e)
  font.em = old_em
  return font

Adjust bearings

import re
# Return glyph name without .suffix
def glyph_base_name(x):
  m = re.match(r"([^.]+)\..+", x)
  return m.group(1) if m else x
def glyph_suffix(x):
  m = re.match(r"([^.]+)\.(.+)", x)
  return m.group(2) if m else ''

def set_bearings(font, bearings, **kwargs):
  bearing_dict = {}
  for row in bearings[1:]:
    bearing_dict[row[0]] = row
  for g in font:
    key = font[g].glyphname
    m = glyph_base_name(key)
    if not key in bearing_dict:
      if m and m in bearing_dict:
        key = m
      else:
        key = 'Default'
    if bearing_dict[key][1] != '':
      font[g].left_side_bearing = bearing_dict[key][1]
    else:
      font[g].left_side_bearing = bearing_dict['Default'][1]
    if bearing_dict[key][2] != '':
      font[g].right_side_bearing = bearing_dict[key][2]
    else:
      font[g].right_side_bearing = bearing_dict['Default'][2]
  if 'space' not in bearing_dict:
    space = font.createMappedChar('space')
    space.width = int(font.em / 5)
  return font

Kern the font

Kern by classes

NOTE: This removes the old kerning table.

def get_classes(row):
  result = []
  for x in row:
    if x == "" or x == "None" or x is None:
      result.append(None)
    elif isinstance(x, str):
      result.append(x.split(','))
    else:
      result.append(x)
  return result

def kern_classes(font, kerning_matrix):
  try:
    font.removeLookup('kern')
    print("Old table removed.")
  except:
    print("Starting from scratch")    
  font.addLookup("kern", "gpos_pair", 0, [["kern",[["latn",["dflt"]]]]])
  offsets = np.asarray(kerning_matrix)
  classes_right = [None if (x == "" or x == "None") else x.split(",") for x in offsets[0,1:]]
  classes_left = [None if (x == "" or x == "None") else x.split(',') for x in offsets[1:,0]]
  offset_list = [0 if x == "" else int(x) for x in offsets[1:,1:].reshape(-1)]
  #print('left', len(classes_left), classes_left)
  #print('right', len(classes_right), classes_right)
  #print('offset', len(offset_list), offset_list)
  font.addKerningClass("kern", "kern-1", classes_left, classes_right, offset_list)
  return font

Kern by character

While trying to figure out kerning, I came across this issue that described how you sometimes need a character-pair kern table instead of just class-based kerning. Since I had figured out character-based kerning before I figured out class-based kerning, it was easy to restore my Python code that takes the same kerning matrix and generates character pairs. Here's what that code looks like.

def kern_by_char(font, kerning_matrix):
  # Add kerning by character as backup
  font.addLookupSubtable("kern", "kern-2")
  offsets = np.asarray(kerning_matrix)
  classes_right = [None if (x == "" or x == "None") else x.split(",") for x in offsets[0,1:]]
  classes_left = [None if (x == "" or x == "None") else x.split(',') for x in offsets[1:,0]]
  for r, row in enumerate(classes_left):
    if row is None: continue
    for first_letter in row:
      g = font.createMappedChar(first_letter)
      for c, column in enumerate(classes_right):
        if column is None: continue
        for second_letter in column:
          if kerning_matrix[r + 1][c + 1]:
            g.addPosSub("kern-2", second_letter, 0, 0, kerning_matrix[r + 1][c + 1], 0, 0, 0, 0, 0)
  return font

Hand-tweak the glyphs

def copy_glyphs(font, edited):
  edited.selection.all()
  edited.copy()
  font.selection.all()
  font.paste()
  return font

Generate fonts

I wanted to be able to easily compare different versions of my font: my original glyphs versus my tweaked glyphs, simple spacing versus kerned. This was a hassle with FontForge, since I had to open different font files in different Metrics windows. If I execute a little bit of source code in my Org Mode, though, I can use my test web page to view all the different versions. By arranging my Emacs windows a certain way and adding :eval no to the Org Babel blocks I'm not currently using, I can easily change the relevant table entries and evaluate the whole buffer to regenerate the font versions, including exports to OTF and WOFF.

This code helps me update my hand-edited fonts.

def kern_existing_font(filename=None, font=None, bearings=None, kerning_matrix=None, **kwargs):
  if font is None:
    font = load_font(filename)
  font = set_bearings(font, bearings)
  font = kern_classes(font, kerning_matrix)
  font = kern_by_char(font, kerning_matrix)
  save_font(font)
  with open("test-%s.html" % font.fontname, 'w') as f:
    f.write(test_font_html(font.fontname + '.woff'))
  return font
<<params>>
<<def_cut_glyphs>>
<<def_import_glyphs>>
<<def_set_bearings>>
<<def_kern_classes>>
<<def_kern_by_char>>
<<def_kern_existing_font>>
<<def_test_font_html>>

Generate sachacHand Light

  Left Right
Default 60 60
A 60 -50
B 60 0
C 60 -30
c   40
b   40
D   10
d 30 30
e 30 40
E 70 10
F 70 0
f 0 -20
G 60 30
g 20 60
H 80 80
h 40 40
I 80 50
i   30
J 40 30
j -70 40
k 40 20
K 80 0
H   10
L 80 10
l   0
M 60 30
m 40  
N 70 10
O 70 10
o 40 40
P 70 0
p   40
Q 70 10
q 20 30
R 70 -10
r   40
S 60 60
s 20 40
T   -10
t -10 20
U 70 20
u 40 40
V   -10
v 20 20
W 70 20
w 40 40
X   -10
x 10 20
y 20 30
Y 40 0
Z   -10
z 10 20

Rows are first characters, columns are second characters.

  None o,a,c,e,d,g,q,w f,t x,v,z h,b,l,i j m,n,p,r,u k y s T F zero
None 0 0 0 0 0 0 0     0 0   0
f 0 -30 -61 -20   0       0 -150   -70
t 0 -50 -41 -20   0 0     0 -150   -10
i     -40               -150    
r 0 -32 -40     0       0 -170   29
k 0 -10 -50     0       -48 -150   -79
l 0 -10 -20   0 0 0     0 -110   -20
v 0 -40 -35 -15   0 0     0 -170   30
b,o,p 0   -40   0 0 0     0 -170   43
n,m     -30               -170    
a 0 -23 -30   0 0 0     0 -170   7
W 0 -40 -30 -10   0 0     0      
T 0 -150 -120 -120 -30 -40 -130   -100 -80 0    
F 0 -90 -90 -70 -30 0 -70   -50 -80 -40    
P 0 -100 -70 -50   0 -70   -30 -80 -20    
g           40         -120    
q,d,h,y,j         30 30 30 30 30   -100    
c,e,s,u,w,x,z                     -120    
V   -70 30 30   -80 -20   -40 -40 -10    
A   30 60 30 30   20 40 20 -80 -120 20 20
Y   20 60 30 30   20 20 40 20 -10    
M,N,H,I   20 10 40 30   10 20 20        
O,Q,D,U     50 40 30 -20 30 20 30   -70    
J     40 20 20 -20 10 10 30   -30    
C   10 40 10 30   30 30 20   -30    
E   -10 50   10 -20 10   20        
L   -10 -10     -30     20   -90    
P   -50 30 20 20     20 20   -30    
K,R   20 20 20 10   20 20 20   -60    
G   20 40 30 30   20 20 20   -100 10  
B,S,X,Z   20 40 30 30   20 20 20 20 -20 10  
<<def_all>>
font = fontforge.open('sachacHandLightEdited.sfd')
font.fontname = 'sachacHand-Light'
font.familyname = 'sachacHand'
font.fullname = 'sachacHand Light'
font.os2_weight = 200
font.os2_family_class = 10 * 256 + 8
font.os2_vendor = 'SC83'
with open('../LICENSE', 'r') as file:
    font.copyright = file.read()
kern_existing_font(font=font, bearings=bearings, kerning_matrix=kerning_matrix)

Generate sachacHand Regular

  Left Right
Default 30 30
A 40 -90
B 20 0
C 40 -30
b   40
D 60 10
d   -10
e   20
E 60 20
F 70 20
f -50 -10
G 40 30
g 20 40
I 70 50
i   30
J -10 30
j -40 50
k 40 20
K 50 0
H 50 30
L 60 10
l 40 40
M 70 40
m 40  
N 70 30
O 40 10
P 60 0
p   20
Q 40 10
q 20 30
R 50 -10
S 20 30
s 20 40
T   -10
t -40 0
U 60 10
u 20  
V   -10
v 20 20
W 50 20
X   -10
x 10 20
y 20 30
Y 40 20
Z   -10
z 10 20
  None m,n,p,r h,b,l,i,k o,a,c,e,d,g,q,w,u f,t x,v,z j y s T J F,B,D,E,H,I,K,L,M,N,P,R V A,C,G,K,O,Q,S,W U X Y Z zero
None                     110                
f   -10 20 -60 0   -90   -40 -190 -80 20              
t   20   -20 10   -70     -100 10                
i       -30 10   -90     -160 -20       -20        
r     -10 -70     -90   -40 -190 -100   -10   -50 -50 -10 -50  
k   -10 -10 -20 -10   -90     -100 10       -30   -30   -10
l   -20     10   -50 -20   -100 10   -20   -30   -30    
v       -30 10   -50     -100 10       -30 -30 -20    
b,o,p       -20 10   -90     -100 10   -10   -30 -30 -30 -10  
n,m         10   -90     -100 10   -10   -20   -30 -10  
a       -30   -20 -90   -10 -140 -30   -60   -40 -20 -40    
W         20         -100 10       -20        
T   -70 -30 -100 -70 -90 -120 -30 -80 -100 -50                
F       -50     -70     -100 -50                
g       -10 10   -50     -140 10       -20        
d   10 10   20 10 -50 10   -100 10               10
h,q,y,j   10     20 10 -50 10   -130 10       -20       10
c,e,s,u,w,x,z       -20 10 10 -50     -130 10       -40 -40 -20    
V   -20   -70 30 30 -80 -40 -40 -30 0                
A   20 30 30 60 50   20 20 -10 60 20 20 20         20
Y   20 30 20 60 30 -50 40 20 -10 40   30            
M,N,H,I   20 20 0 50 30 -50 20     40   30            
O,Q,D,U   30 40   50 40 -20 30   -70 40   20            
J   10 20   40 20 -20 30   -30 80   20            
C   30 30 10 40 10   20   -30 80   20            
E   10 10 -10 50   -20 20     110                
L       -10 -10   -30 20   -90 20                
P     20 -50 30 20   20   -30 80                
K,R   20 10 20 20 20   20   -60 50                
G   20 30 20 40 30   20   -100 10 10              
B,S,X,Z   20 30 20 40 30   20 20 -20 90 10              
<<def_all>>
font = fontforge.open('sachacHandRegularEdited.sfd')
font.fontname = 'sachacHand-Regular'
font.familyname = 'sachacHand'
font.fullname = 'sachacHand Regular'
font.os2_weight = 400
font.os2_family_class = 10 * 256 + 8
font.os2_vendor = 'SC83'
with open('../LICENSE', 'r') as file:
    font.copyright = file.read()
kern_existing_font(filename="sachacHandRegularEdited.sfd",bearings=bearings, kerning_matrix=kerning_matrix)
Bad name when parsing aglfn for unicode 41
Old table removed.
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/tmp/babel-RQDYFr/python-8wNyrc", line 383, in <module>
    kern_existing_font(filename="sachacHandRegularEdited.sfd",bearings=bearings, kerning_matrix=kerning_matrix)
  File "/tmp/babel-RQDYFr/python-8wNyrc", line 240, in kern_existing_font
    f.write(test_font_html(font.fontname + '.woff'))
  File "/tmp/babel-RQDYFr/python-8wNyrc", line 314, in test_font_html
    for v in variants:
TypeError: 'NoneType' object is not iterable

Generate sachacHand Bold

For cutting the glyphs:

<<params>>
params = {**params, 
          'row_padding': 50,
          'sample_file': 'sample-sachacHand-regular.png',
          'new_font_file': 'sachacHandRegular.sfd',
          'new_otf': 'sachacHandRegular.otf',
          'letters': None,
          'matrix':
            ['H,e,q,A,M,Y,8,\',#,<',
             'O,b,r,B,N,Z,9,quoteright,$,>',
             'n,h,u,R,P,0,?,",[',
             'o,k,w,D,Q,1,:,quotedblleft,&,]',
             'd,l,v,L,S,2,;,quotedblright,(,^',
             'p,f,x,E,T,3,-,@,),`',
             'a,t,y,F,U,4,endash,/,*,{',
             'g,i,z,I,V,5,emdash,\\,+,|',
             's,j,C,J,W,6,=,~,comma,}',
             'c,m,G,K,X,7,!,_,.,I.alt1']}
<<def_all>>
#cut_glyphs(**params)

Kerning:

  Left Right
Default 30 30
A 30 -50
B 60 0
C 20 -30
b   40
D 40 10
d   -50
e   20
E 50 20
F 50 0
f -50 -80
G 40 30
g 20 40
H 50 50
I 60 50
i   30
J -10 30
j -20 30
k 40 20
K 70 0
H   10
L 60 10
l   0
M 60  
m 40  
N 60 10
O 40 10
P 60 0
p   20
Q 40 10
q 20 30
R 50 -10
S 30 30
s 20 40
T   -10
t -40 0
U 60 20
u 20  
V   -10
v 20 20
W 50 20
X   -10
x 10 20
y 20 30
Y 40 0
Z   -10
z 10 20
  None o,a,c,e,d,g,q,w f,t x,v,z h,b,l,i j m,n,p,r,u k y s T F V zero
None               20            
n,m     20     -90         -100   -100  
f   -10 0   20 -90 10 20   -40 -190 20    
t   -20 10     -70 20 20     -100      
i   -30 10     -90         -160      
r   -70     -10 -90       -40 -190      
k   -20 -10   -10 -90 -10       -100     -10
l     10         20     -100      
v   -30 10     -50         -100      
b,o,p   -20 10     -90         -100      
a           -90       -10 -100      
W     20               -100      
T   -120 -70 -90 -30 -120 -70 -30 -30 -80 -100      
F   -90       -70         -100      
g     10     -50         -100      
q,d,h,y,j     20 10 10 -50 10 10 10   -100     10
c,e,s,u,w,x,z   -20 10 10   -50         -100      
V   -70 30 30   -80 -20   -40 -40 -10      
A   30 60 30 30   20 40 20 20 -10 20   20
Y   20 60 30 30   20 20 40 20 -10      
M,N,H,I   20 50 40 30   10 20 20          
O,Q,D,U     50 40 30 -20 30 20 30   -70      
J     40 20 20 -20 10 10 30   -30      
C   10 40 10 30   30 30 20   -30      
E   -10 50   10 -20 10   20          
L   -10 -10     -30     20   -90      
P   -50 30 20 20     20 20   -30      
K,R   20 20 20 10   20 20 20   -60      
G   20 40 30 30   20 20 20   -100 10    
B,S,X,Z   20 40 30 30   20 20 20 20 -20 10    
<<def_all>>
#font = fontforge.font()
#font = import_glyphs(font, **params)
font = fontforge.open('sachacHandBoldEdited.sfd')
font.fontname = 'sachacHand-Bold'
font.familyname = 'sachacHand'
font.fullname = 'sachacHand Bold'
font.os2_weight = 600
font.os2_family_class = 10 * 256 + 8
font.os2_vendor = 'SC83'
with open('../LICENSE', 'r') as file:
  font.copyright = file.read()
kern_existing_font(font=font, bearings=bearings, kerning_matrix=kerning_matrix)

Variants

Import the glyphs for variant1 and variant2

Expanding the kerning matrix:

  • Specify list of variant glyphs to add to existing classes if not specified
  • Specify suffixes, try each glyph to see if it exists
  • Check the font to see what other glyphs are specified, add to those classes
<<def_all>>
def get_stylistic_set(font, suffix):
  return [g for g in font if suffix in g]
def add_character_variants(font, sets):
  if not 'calt' in font.gsub_lookups:
    font.addLookup('calt', 'gsub_contextchain', 0, [['calt', [['latn', ['dflt']]]]])
  prev_tag = ''
  for i, sub in enumerate(sets):
    if not sub in font.gsub_lookups: 
      font.addLookup(sub, 'gsub_single', 0, [])
      font.addLookupSubtable(sub, sub + '-1')
    alt_set = get_stylistic_set(font, sub)
    for g in alt_set:
      get_glyph(font, glyph_base_name(g)).addPosSub(sub + '-1', g)
    default = [glyph_base_name(g) for g in alt_set]
    prev_set = [glyph_base_name(g) + prev_tag for g in alt_set]
    print('%d | %d @<ss%02d>' % (i + 1, 1, i + 1))
    print(default)
    default = default + ['0']
    try: font.removeLookupSubtable('calt-%d' % (i + 1))
    except Exception: pass
    print(prev_set)
    if i == 0:
      font.addContextualSubtable('calt', 'calt-%d' % (i + 1), 'class', '%d | %d @<ss%02d>' % (i + 1, 1, i + 1),
                                 bclasses=(None, default), mclasses=(None, default))
    else:
      font.addContextualSubtable('calt', 'calt-%d' % (i + 1), 'class', '%d | %d @<ss%02d>' % (i + 1, 1, i + 1),
                                 bclasses=(None, default, prev_set), mclasses=(None, default, prev_set))
    prev_tag = '.' + sub    
  return font

font = fontforge.open('sachacHandRegularEdited.sfd')
params = {**params, 
          'row_padding': 50,
          'sample_file': 'sample-sachacHand-regular-variant1.png',
          'new_font_file': 'sachacHandRegular-Variants.sfd',
          'new_otf': 'sachacHandRegular-Variants.otf',
          'letters': None,
          'matrix':
            ['H.ss01,e.ss01,q.ss01,A.ss01,M.ss01,Y.ss01,eight.ss01,quotesingle.ss01,numbersign.ss01,less.ss01',
             'O.ss01,b.ss01,r.ss01,B.ss01,N.ss01,Z.ss01,nine.ss01,quoteright.ss01,dollar.ss01,greater.ss01',
             'n.ss01,h.ss01,u.ss01,R.ss01,P.ss01,zero.crossed,question.ss01,quotedbl.ss01,bracketleft.ss01',
             'o.ss01,k.ss01,w.ss01,D.ss01,Q.ss01,one.ss01,colon.ss01,quotedblleft.ss01,ampersand,bracketright.ss01',
             'd.ss01,l.ss01,v.ss01,L.ss01,S.ss01,two.ss01,semicolon.ss01,quotedblright.ss01,parenleft.ss01,asciicircum.ss01',
             'p.ss01,f.ss01,x.ss01,E.ss01,T.ss01,three.ss01,hyphen.ss01,at.ss01,parenright.ss01,grave.ss01',
             'a.ss01,t.ss01,y.ss01,F.ss01,U.ss01,four.ss01,endash.ss01,slash.ss01,asterisk.ss01,braceleft.ss01',
             'g.ss01,i.ss01,z.ss01,I.ss01,V.ss01,five.ss01,emdash.ss01,backslash.ss01,plus.ss01,bar.ss01',
             's.ss01,j.ss01,C.ss01,J.ss01,W.ss01,six.ss01,equal.ss01,asciitilde.ss01,comma.ss01,braceright.ss01',
             'c.ss01,m.ss01,G.ss01,K.ss01,X.ss01,seven.ss01,exclam.ss01,underscore.ss01,period.ss01,zero.ss01']}
cut_glyphs(**params)
matrix = glyph_matrix(font=font, matrix=params['matrix'])
import_glyphs(font, **params)
params = {**params, 
          'sample_file': 'sample-sachacHand-bold.png',
          'matrix':
            ['H.ss02,e.ss02,q.ss02,A.ss02,M.ss02,Y.ss02,eight.ss02,quotesingle.ss02,numbersign.ss02,less.ss02',
             'O.ss02,b.ss02,r.ss02,B.ss02,N.ss02,Z.ss02,nine.ss02,quoteright.ss02,dollar.ss02,greater.ss02',
             'n.ss02,h.ss02,u.ss02,R.ss02,P.ss02,zero.ss02,question.ss02,quotedbl.ss02,bracketleft.ss02',
             'o.ss02,k.ss02,w.ss02,D.ss02,Q.ss02,one.ss02,colon.ss02,quotedblleft.ss02,ampersand,bracketright.ss02',
             'd.ss02,l.ss02,v.ss02,L.ss02,S.ss02,two.ss02,semicolon.ss02,quotedblright.ss02,parenleft.ss02,asciicircum.ss02',
             'p.ss02,f.ss02,x.ss02,E.ss02,T.ss02,three.ss02,hyphen.ss02,at.ss02,parenright.ss02,grave.ss02',
             'a.ss02,t.ss02,y.ss02,F.ss02,U.ss02,four.ss02,endash.ss02,slash.ss02,asterisk.ss02,braceleft.ss02',
             'g.ss02,i.ss02,z.ss02,I.ss02,V.ss02,five.ss02,emdash.ss02,backslash.ss02,plus.ss02,bar.ss02',
             's.ss02,j.ss02,C.ss02,J.ss02,W.ss02,six.ss02,equal.ss02,asciitilde.ss02,comma.ss02,braceright.ss02',
             'c.ss02,m.ss02,G.ss02,K.ss02,X.ss02,seven.ss02,exclam.ss02,underscore.ss02,period.ss02,None']}
cut_glyphs(**params)
import_glyphs(font, **params)
set_bearings(font, bearings)
variants = ['ss01', 'ss02']

def expand_classes(array, new_glyphs):
  not_found = []
  for g in new_glyphs:
    found_exact = None
    found_base = None
    base = glyph_base_name(g)
    for i, class_glyphs in enumerate(array):
      if class_glyphs is None: continue
      if isinstance(class_glyphs, str):
        class_glyphs = class_glyphs.split(',')
        array[i] = class_glyphs
      for glyph in class_glyphs:
        if glyph == g:
          found_exact = i
          break
        if glyph == base:
          found_base = i
          break
    if found_exact: continue
    elif found_base: array[found_base].append(g)
    else: not_found.append(g)
  return ([','.join(x) for x in array], not_found)

# def expand_kerning_matrix(font=font, kerning_matrix=kerning_matrix, new_glyphs=[]):
#   classes_right = [None if (x == "" or x == "None") else x.split(",") for x in offsets[0,1:]]
#   classes_left = [None if (x == "" or x == "None") else x.split(',') for x in offsets[1:,0]]
#   right_glyphs = np.asarray(offsets[0,1:]).reshape(-1)
#   # Expand all the right glyphs
#   for i, c in enumerate(kerning_matrix[0]):
#     if c is None: continue
#     glyphs = c.split(',')
#     for g in glyphs:

alt_set = get_stylistic_set(font, 'ss02')
(classes_right, not_found) = expand_classes(list(kerning_matrix[0]), alt_set)
(classes_left, not_found) = expand_classes([x[0] for x in kerning_matrix], alt_set)
kerning_matrix[0] = classes_right
for i, c in enumerate(classes_left):
  kerning_matrix[i][0] = c
font = kern_classes(font, kerning_matrix)
font = kern_by_char(font, kerning_matrix)
add_character_variants(font, variants)
#font.mergeFeature('sachacHand-Regular-V.fea')
font.familyname = 'sachacHand'
font.fullname = 'sachacHand Regular Variants'
font.os2_weight = 400
font.os2_family_class = 10 * 256 + 8
font.os2_vendor = 'SC83'
font.fontname = 'sachacHand-Regular-V'
# TODO Just plop them into different fonts, darn it.
save_font(font)
with open("test-%s.html" % font.fontname, 'w') as f:
  f.write(test_font_html(font.fontname + '.woff', variants=variants))

Bad name when parsing aglfn for unicode 41
Old table removed.
1 | 1 @<ss01>
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period', 'zero']
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period', 'zero']
2 | 1 @<ss02>
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'zero', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period']
['H.ss01', 'e.ss01', 'q.ss01', 'A.ss01', 'M.ss01', 'Y.ss01', 'eight.ss01', 'quotesingle.ss01', 'numbersign.ss01', 'less.ss01', 'O.ss01', 'b.ss01', 'r.ss01', 'B.ss01', 'N.ss01', 'Z.ss01', 'nine.ss01', 'quoteright.ss01', 'dollar.ss01', 'greater.ss01', 'n.ss01', 'h.ss01', 'u.ss01', 'R.ss01', 'P.ss01', 'zero.ss01', 'question.ss01', 'quotedbl.ss01', 'bracketleft.ss01', 'o.ss01', 'k.ss01', 'w.ss01', 'D.ss01', 'Q.ss01', 'one.ss01', 'colon.ss01', 'quotedblleft.ss01', 'bracketright.ss01', 'd.ss01', 'l.ss01', 'v.ss01', 'L.ss01', 'S.ss01', 'two.ss01', 'semicolon.ss01', 'quotedblright.ss01', 'parenleft.ss01', 'asciicircum.ss01', 'p.ss01', 'f.ss01', 'x.ss01', 'E.ss01', 'T.ss01', 'three.ss01', 'hyphen.ss01', 'at.ss01', 'parenright.ss01', 'grave.ss01', 'a.ss01', 't.ss01', 'y.ss01', 'F.ss01', 'U.ss01', 'four.ss01', 'endash.ss01', 'slash.ss01', 'asterisk.ss01', 'braceleft.ss01', 'g.ss01', 'i.ss01', 'z.ss01', 'I.ss01', 'V.ss01', 'five.ss01', 'emdash.ss01', 'backslash.ss01', 'plus.ss01', 'bar.ss01', 's.ss01', 'j.ss01', 'C.ss01', 'J.ss01', 'W.ss01', 'six.ss01', 'equal.ss01', 'asciitilde.ss01', 'comma.ss01', 'braceright.ss01', 'c.ss01', 'm.ss01', 'G.ss01', 'K.ss01', 'X.ss01', 'seven.ss01', 'exclam.ss01', 'underscore.ss01', 'period.ss01']

Okay, why isn't it triggering when we start off with 0?

Bad name when parsing aglfn for unicode 41
1 | 1 @<ss01>
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period', 'zero']
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period', 'zero']
2 | 1 @<ss02>
['H', 'e', 'q', 'A', 'M', 'Y', 'eight', 'quotesingle', 'numbersign', 'less', 'O', 'b', 'r', 'B', 'N', 'Z', 'nine', 'quoteright', 'dollar', 'greater', 'n', 'h', 'u', 'R', 'P', 'zero', 'question', 'quotedbl', 'bracketleft', 'o', 'k', 'w', 'D', 'Q', 'one', 'colon', 'quotedblleft', 'bracketright', 'd', 'l', 'v', 'L', 'S', 'two', 'semicolon', 'quotedblright', 'parenleft', 'asciicircum', 'p', 'f', 'x', 'E', 'T', 'three', 'hyphen', 'at', 'parenright', 'grave', 'a', 't', 'y', 'F', 'U', 'four', 'endash', 'slash', 'asterisk', 'braceleft', 'g', 'i', 'z', 'I', 'V', 'five', 'emdash', 'backslash', 'plus', 'bar', 's', 'j', 'C', 'J', 'W', 'six', 'equal', 'asciitilde', 'comma', 'braceright', 'c', 'm', 'G', 'K', 'X', 'seven', 'exclam', 'underscore', 'period']
['H.ss01', 'e.ss01', 'q.ss01', 'A.ss01', 'M.ss01', 'Y.ss01', 'eight.ss01', 'quotesingle.ss01', 'numbersign.ss01', 'less.ss01', 'O.ss01', 'b.ss01', 'r.ss01', 'B.ss01', 'N.ss01', 'Z.ss01', 'nine.ss01', 'quoteright.ss01', 'dollar.ss01', 'greater.ss01', 'n.ss01', 'h.ss01', 'u.ss01', 'R.ss01', 'P.ss01', 'zero.ss01', 'question.ss01', 'quotedbl.ss01', 'bracketleft.ss01', 'o.ss01', 'k.ss01', 'w.ss01', 'D.ss01', 'Q.ss01', 'one.ss01', 'colon.ss01', 'quotedblleft.ss01', 'bracketright.ss01', 'd.ss01', 'l.ss01', 'v.ss01', 'L.ss01', 'S.ss01', 'two.ss01', 'semicolon.ss01', 'quotedblright.ss01', 'parenleft.ss01', 'asciicircum.ss01', 'p.ss01', 'f.ss01', 'x.ss01', 'E.ss01', 'T.ss01', 'three.ss01', 'hyphen.ss01', 'at.ss01', 'parenright.ss01', 'grave.ss01', 'a.ss01', 't.ss01', 'y.ss01', 'F.ss01', 'U.ss01', 'four.ss01', 'endash.ss01', 'slash.ss01', 'asterisk.ss01', 'braceleft.ss01', 'g.ss01', 'i.ss01', 'z.ss01', 'I.ss01', 'V.ss01', 'five.ss01', 'emdash.ss01', 'backslash.ss01', 'plus.ss01', 'bar.ss01', 's.ss01', 'j.ss01', 'C.ss01', 'J.ss01', 'W.ss01', 'six.ss01', 'equal.ss01', 'asciitilde.ss01', 'comma.ss01', 'braceright.ss01', 'c.ss01', 'm.ss01', 'G.ss01', 'K.ss01', 'X.ss01', 'seven.ss01', 'exclam.ss01', 'underscore.ss01', 'period.ss01']

Okay, how do I space and kern the variants more efficiently?

font-feature-settings: "calt" 0; turns off variants. Works in Chrome, too.

Test the fonts

This lets me quickly try text with different versions of my font. I can also look at lots of kerning pairs at the same time.

Resources:

Output Font filename Class
test-regular.html sachacHand.woff regular
test-bold.html sachacHandBold.woff bold
test-black.html sachacHandBlack.woff black
[['test-regular.html', 'sachacHand.woff', 'regular'], ['test-bold.html', 'sachacHandBold.woff', 'bold'], ['test-black.html', 'sachacHandBlack.woff', 'black']]
[{'output': 'test-regular.html', 'font_filename': 'sachacHand.woff', 'klass': 'regular'}, {'output': 'test-bold.html', 'font_filename': 'sachacHandBold.woff', 'klass': 'bold'}, {'output': 'test-black.html', 'font_filename': 'sachacHandBlack.woff', 'klass': 'black'}]
strings = ["hhhhnnnnnnhhhhhnnnnnn", 
           "ooonoonnonnn",
           "nnannnnbnnnncnnnndnnnnennnnfnnnngnnnnhnnnninnnnjnn",
           "nnknnnnlnnnnmnnnnnnnnnonnnnpnnnnqnnnnrnnnnsnnnntnn",
           "nnunnnnvnnnnwnnnnxnnnnynnnnznn",
           "HHHOHHOOHOOO",
           "HHAHHHHBHHHHCHHHHDHHHHEHHHHFHHHHGHHHHHHHHHIHHHHJHH",
           "HHKHHHHLHHHHMHHHHNHHHHOHHHHPHHHHQHHHHRHHHHSHHHHTHH",
           "HHUHHHHVHHHHWHHHHXHHHHYHHHHZHH",
           "Having fun kerning using Org Mode and FontForge",
           "Python+FontForge+Org: I made a font based on my handwriting!",
           "Monthly review: May 2020",
           "Emacs News 2020-06-01"]
def test_strings(strings):
  doc, tag, text, line = Doc().ttl()
  with doc.tag('table', style='border-bottom: 1px solid gray; width: 100%; border-collapse: collapse'):
    for s in strings:
      for i, f in enumerate(fonts):
        style = 'border-top: 1px solid gray' if (i == 0) else ""
        with tag('tr', klass=f[0], style=style):
          line('td', f[0])
          line('td', s)
  return doc.getvalue()
def test_kerning_matrix(font):
  sub = font.getLookupSubtables(font.gpos_lookups[0])
  doc, tag, text, line = Doc().ttl()
  for s in sub:
    if font.isKerningClass(s):
      (classes_left, classes_right, array) = font.getKerningClass(s)
      kerning = np.array(array).reshape(len(classes_left), len(classes_right))
      with tag('table', style='border-collapse: collapse'):
        for r, row in enumerate(classes_left):
          if row is None: continue
          for j, first_letter in enumerate(row):
            if first_letter == None: continue
            style = "border-top: 1px solid gray" if j == 0 else ""
            g1 = aglfn.to_glyph(glyph_base_name(first_letter))
            c1 = glyph_suffix(first_letter)
            with tag('tr', style=style):
              line('td', first_letter)
              for c, column in enumerate(classes_right):
                if column is None: continue
                for i, second_letter in enumerate(column):
                  if second_letter is None: continue
                  g2 = aglfn.to_glyph(glyph_base_name(second_letter))
                  c2 = glyph_suffix(second_letter)
                  klass = "kerned" if kerning[r][c] else "default"
                  style = "border-left: 1px solid gray" if i == 0 else ""
                  with tag('td', klass=klass, style=style):
                    doc.asis('<span class="base">n</span><span class="%s" title="%s">%s</span><span class="%s" title="%s">%s</span><span class="base">n</span>' % (c1, first_letter, g1, c2, second_letter, g2))
  return doc.getvalue()
from yattag import Doc
import numpy as np
import fontforge
import aglfn

def test_glyphs(font, count=1):
  return ''.join([(aglfn.to_glyph(g) or "") * count for g in font if (font[g].isWorthOutputting() and font[g].unicode > -1)])

def test_font_html(font_filename=None, variants=None):
  doc, tag, text, line = Doc().ttl()
  font = fontforge.open(font_filename)
  name = font.fontname
  with tag('html'):
    with tag('head'): 
      doc.asis('<link rel="stylesheet" type="text/css" href="style.css" />')
      doc.asis('<meta charset="UTF-8">')
      with tag('style'):
        doc.asis("@font-face { font-family: '%s'; src: url('%s'); }\n" % (name, font_filename))
        doc.asis("body { font-family: '%s'; }\n" % name)
        doc.asis(".bold { font-weight: bold } .italic { font-style: italic } .oblique { font-style: oblique }")
        doc.asis(".small-caps { font-variant: small-caps }")
        for v in variants:
          doc.asis('.%s { font-feature-settings: "calt" off, "%s" on; }' % (v, v))
    with tag('body'):
      with tag('a', href='index.html'):
        text('Back to index')
      with tag('div', style='float: right'):
        with tag('a', href=font.fullname + '.woff'):
          text('WOFF')
        text(' | ')
        with tag('a', href=font.fullname + '.otf'):
          text('OTF')
      line('h1', font.fullname)
      line('h2', 'Glyphs and sizes')
      with tag('table'):
        for size in [10, 14, 20, 24, 36, 72]:
          with tag('tr', style='font-size: %dpt' % size):
            line('td', size)
            line('td', test_glyphs(font))
      line('h2', 'Variants')
      line('div', test_glyphs(font, 4))
      with tag('table', klass='nocalt'):
        for v in variants:
          with tag('tr'):
            line('td', v)
            with tag('td', klass=v):
              text(test_glyphs(font))

#       line('h2', 'Accents')
#       line('div', '''
# ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ ­ ® ¯ ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾ ¿ À Á Â Ã'
# Ä Å Æ Ç È É Ê Ë Ì Í Î Ï Ð Ñ Ò Ó Ô Õ Ö × Ø Ù Ú Û Ü Ý Þ ß à á â ã ä å æ
# ç è é ê ë ì í î ï ð ñ ò ó ô õ ö ÷ ø ù ú û ü ý þ ÿ Ā ā Ă ă Ą ą Ć ć Ĉ ĉ
# Ċ ċ Č č Ď ď Đ đ Ē ē Ĕ ĕ Ė ė Ę ę Ě ě Ĝ ĝ Ğ ğ Ġ ġ Ģ ģ Ĥ ĥ Ħ ħ Ĩ
# ĩ Ī ī Ĭ ĭ Į į İ ı IJ ij Ĵ ĵ Ķ ķ ĸ Ĺ ĺ Ļ ļ Ľ ľ Ŀ ŀ Ł ł Ń ń Ņ ņ Ň ň ʼn Ŋ ŋ
# Ō ō Ŏ ŏ Ő ő Œ œ Ŕ ŕ Ŗ ŗ Ř ř Ś ś Ŝ ŝ Ş ş Š š Ţ ţ Ť ť Ŧ ŧ Ũ ũ Ū ū Ŭ ŭ Ů
# ů Ű ű Ų ų Ŵ ŵ Ŷ ŷ Ÿ Ź ź Ż ż Ž ž ſ Ǎ ǎ Ǐ ǐ Ǒ ǒ Ǔ ǔ Ǖ ǖ Ǘ ǘ Ǚ ǚ Ǜ ǜ
# Ə ƒ Ơ ơ Ư Ư Ǻ ǻ Ǽ ǽ Ǿ ǿ''')
      line('h2', 'Transformations')
      with tag('table'):
        for t in ['normal', 'bold', 'italic', 'oblique', 'bold italic', 'bold oblique', 'small-caps', 'bold small-caps']:
          with tag('tr', klass=t):
            line('td', t)
            line('td', test_glyphs(font))
      line('h2', 'Test strings')
      for s in strings:
        with tag('table'):
          for v in variants:
            with tag('tr'):
              line('td', v)
              with tag('td', klass=v + ' nocalt'):
                text(s)
      line('h2', 'Kerning matrix')
      with tag('div', klass='nocalt'):
        doc.asis(test_kerning_matrix(font))
      line('h2', 'License')
      with tag('pre', klass='license'):
        text(font.copyright)
      # http://famira.com/article/letterproef
  font.close()
  return doc.getvalue()
<<def_test_html>>
font_files = ['sachacHand-Light.woff', 'sachacHand-Regular.woff', 'sachacHand-Bold.woff']
fonts = {}

# Write the main page
with open('index.html', 'w') as f:
  doc, tag, text, line = Doc().ttl()
  with tag('html'):
    with tag('head'): 
      doc.asis('<link rel="stylesheet" type="text/css" href="style.css" />')
      with tag('style'):
        for p in font_files:
          fonts[p] = fontforge.open(p)
          doc.asis("@font-face { font-family: '%s'; src: url('%s'); }\n" % (fonts[p].fontname, p))
          doc.asis(".%s { font-family: '%s'; }" % (fonts[p].fontname, fonts[p].fontname))
    with tag('body'):
      with tag('a', href='https://github.com/sachac/sachac-hand'):
        text('View source code on Github')
      line('h1', 'Summary')
      line('h2', 'Glyphs')
      with tag('table'):
        for p in fonts:
          with tag('tr', klass=fonts[p].fontname):
            with tag('td'):
              with tag('a', href='test-%s.html' % fonts[p].fontname):
                text(fonts[p].fullname)
            line('td', test_glyphs(fonts[p]))
      line('h2', 'Strings')
      with tag('table', style='border-bottom: 1px solid gray; width: 100%; border-collapse: collapse'):
        for s in strings:
          for i, p in enumerate(fonts):
            style = 'border-top: 1px solid gray' if (i == 0) else ""
            with tag('tr', klass=fonts[p].fontname, style=style):
              with tag('td'):
                with tag('a', href='test-%s.html' % fonts[p].fontname):
                  text(fonts[p].fullname)
              line('td', s)
  f.write(doc.getvalue())

Oh, can I get livereload working? There's a python3-livereload… Ah, it's as simple as running livereload.

Ideas

DONE Copy glyphs from hand-edited font

TODO Alternate glyphs

TODO Ligatures

TODO Accents

Generating a zero-width version?

Export glyphs, autotrace them, and load them into a different font

import os
<<params>>
def export_glyphs(font, directory):
  for g in font:
    if font[g].isWorthOutputting():
      filename = os.path.join(directory, g)
      font[g].export(filename + ".png", params['em'], 1)
      subprocess.call(["convert", filename + ".png", filename + ".pbm"])
      subprocess.call(["autotrace", "-centerline", "-output-file", filename + ".svg", filename + ".pbm"])
def zero_glyphs(font, directory):
  for g in font:
    glyph = font[g]
    if glyph.isWorthOutputting():
      glyph.clear()
      glyph.importOutlines(os.path.join(directory, g + '.svg'))
  return font
font = load_font(params['new_font_file'])
directory = 'exported-glyphs'
# export_glyphs(font, directory)
font = zero_glyphs(font, directory)
font.fontname = 'sachacHand-Zero'
font.fullname = 'sachacHand Zero'
font.weight = 'Zero'
save_font(font, {**params, "new_font_file": "sachacHandZero.sfd", "new_otf": "sachacHandZero.otf"})

Huh. I want the latest version so that I can pass keyword arguments.

1023,/home/sacha/vendor/fontforge% cd build cmake -GNinja .. -DENABLE_FONTFORGE_EXTRAS=ON ninja ninja install

https://superuser.com/questions/1337567/how-do-i-convert-a-ttf-into-individual-png-character-images

TODO Manually edit the glyphs to make them look okay

TODO Double up the paths and close them

TODO Make a font for A-

<<params>>
params = {**params, 
          'sample_file': 'a-kiddo-sample.png',
          'new_font_file': 'aKiddoHand.sfd',
          'new_otf': 'aKiddoHand.otf',
          'new_font_name': 'aKiddoHand',
          'new_family_name': 'aKiddoHand',
          'new_full_name': 'aKiddoHand'}

Extra stuff

Get information from my blog database

cd ~/code/docker/blog
docker-compose up mysql

Figure out what glyphs I want based on my blog headings

from dotenv import load_dotenv
from sqlalchemy import create_engine
import os
import pandas as pd
import pymysql
load_dotenv(dotenv_path="/home/sacha/code/docker/blog/.env", verbose=True)

sqlEngine       = create_engine('mysql+pymysql://' + os.getenv('PYTHON_DB'), pool_recycle=3600)
dbConnection    = sqlEngine.connect()

Make test page with blog headings

<<connect-to-db>>
from yattag import Doc, indent
doc, tag, text, line = Doc().ttl()
with tag('html'):
  with tag('head'):
    doc.asis('<link rel="stylesheet" type="text/css" href="style.css" />')
  with tag('body', klass="blog-heading"):
    result = dbConnection.execute("select id, post_title from wp_posts WHERE post_type='post' AND post_status='publish' AND post_password='' order by id desc")
    for row in result:
      with tag('h2'):
        with tag('a', href="https://sachachua.com/blog/p/%s" % row['id']):
          text(row['post_title'])
dbConnection.close()
with open('test-blog.html', 'w') as f:
  f.write(indent(doc.getvalue(), indent_text=True))

Check glyphs

<<connect-to-db>>
df           = pd.read_sql("select post_title from wp_posts WHERE post_type='post' AND post_status='publish'", dbConnection);
# Debugging
#q = df[~df['post_title'].str.match('^[A-Za-z0-9\? "\'(),\-:\.\*;/@\!\[\]=_&\?\$\+#^{}\~]+$')]
#print(q)
from collections import Counter
df['filtered'] = df.post_title.str.replace('[A-Za-z0-9\? "\'(),\-:\.\*;/@\!\[\]=_&\?\$\+#^{}\~]+', '')
#print(df['filtered'].apply(list).sum())
res = Counter(df.filtered.apply(list).sum())
return res.most_common()
 65
à 57
39
ƒ 33
13
£ 8
\x81 4
¤ 4
» 4
¦ 3
¿ 3
3
2
¥ 2
¨ 2
2
ō 2
% 2
\t 1
1
Ÿ 1
Š 1
œ 1
¬ 1
ª 1
ž 1
< 1
> 1
¹ 1
1
§ 1
¸ 1
Ž 1
¼ 1
Π1
\xa0 1
\x8d 1
1
« 1
ā 1
ē 1
č 1

Look up posts with weird glyphs

<<connect-to-db>>
df           = pd.read_sql("select id, post_title from wp_posts WHERE post_type='post' AND post_status='publish' AND post_title LIKE %(char)s limit 10;", dbConnection, params={"char": '%' + char + '%'});
print(df)
      id                                         post_title
0   7059    Wiki organization challenge – thinking out loud
1   7330   Setting up my new tablet PC – apps, config, etc.
2  22038  Work on the business from the outside, not in ...

Get frequency of pairs of characters

<<connect-to-db>>
df = pd.read_sql("select post_title from wp_posts WHERE post_type='post' AND post_status='publish'", dbConnection);
from collections import Counter
s = df.post_title.apply(list).sum()
res = Counter('{}{}'.format(a, b) for a, b in zip(s, s[1:]))
common = res.most_common(100)
return ''.join([x[0] for x in common])
innge g s  treeron aanesy entit orndthn ee: ted atarr hetont, acstou o fekne rieWe smaalewo 20roea mle w 2itvi e pk rimedietioomchev cly01edlil ve i braisseha Wotdece dcotahih looouticurel laseccssila

Copy metrics from my edited font

Get the glyph bearings

import fontforge
import numpy as np
import pandas as pd
f = fontforge.open("/home/sacha/code/font/files/SachaHandEdited.sfd")
return list(map(lambda g: [g.glyphname, g.left_side_bearing, g.right_side_bearing], f.glyphs()))
a 39.0 38.0
b 39.0 38.59677350874102
c 38.807172523099524 39.0
d 38.853036079593494 37.70218462414317
e 23.0 39.0
f 22.0 28.0
g 39.0 38.839263397187665
h 42.44897959183673 32.244897959183675
i 39.0 39.0
j 29.0 37.07269908475212
k 38.7232 38.0
l 38.849996883261696 24.0
m 38.88120540762966 61.872974804436524
n 38.41699749411689 50.09722712588024
o 38.861850745445174 38.36155030599474
p 38.72189349112426 38.806185204215126
q 38.635016803781454 38.0
r 39.183503419072274 39.0
s 39.0 38.0
t 39.0 39.0
u 38.68004732178092 38.39916483580083
v 39.0 39.0
w 38.5881853639986 38.21114561800016
x 39.0 39.0
y -25.0 36.43496760281849
z 39.0 39.0
A 39.38789400666183 39.0
B 39.0 37.98737993209943
C 39.16280761404536 38.0
D 39.0 39.51459156482764
E 39.0 39.0
F 39.0 38.0
G 39.0 38.966489765633526
H 39.0 38.0
I 38.96694214876033 39.25
J 39.0 38.464468801750854
K 38.59617220614814 38.0
L 39.0 38.0
M 38.745166004060955 38.0
N 38.73987423309397 38.115654115187624
O 38.98891966759004 38.81665596263048
P 39.107438016528924 38.65155124501666
Q 39.08006855188009 38.01570072979803
R 39.0 38.0
S 39.0 37.81373873377618
T 39.0 38.0
U 38.75 37.93218925782895
V 38.64979175001243 38.0
W 39.0 38.97697312351511
X 39.0 39.0
Y 39.2011995420152 38.493344292403606
Z 38.920094771357476 39.0
zero 39.02557980683008 38.934353847767
one 39.0 37.86668813070091
two 39.0 38.0
three 39.0 38.30090715487154
four 38.61480785064145 38.0
five 39.0 38.759568693514495
six 39.2019689704218 38.50115350183796
seven 39.0 39.45880036173975
eight 39.30732386691426 38.81767097798502
nine 39.04800948718441 37.956930045381114
question 39.35264826217293 38.26531143335521
colon 38.5 38.70624687253556
semicolon 39.0 39.27324858612964
hyphen 39.0 38.0
equal 39.0 38.0
exclam 38.783020821373505 39.0
quotesingle 39.0 -1.7598547334076642
at 39.229928128979466 38.0
slash 39.0 38.0
backslash 39.0 39.0
quotedbl 38.86626375007093 37.95034254612182
asciitilde 38.68727157672891 38.0
underscore 39.0 39.0
numbersign 39.0 38.740379553133494
dollar 39.0 38.734693877551024
percent 39.200007286174 38.10774096287298
ampersand 38.96710425694502 38.68428307198798
parenleft 39.286819706621706 39.0
parenright 39.0 39.05824335912013
asterisk 39.0 38.0
plus 39.0 38.0
comma 38.96546178699183 38.55278640450004
period 38.83875395420776 37.87092262792087
less 38.97840529870042 39.0
greater 39.0 37.69246464578106
bracketleft 38.788380868145794 38.0
bracketright 39.0 39.0
asciicircum 39.0 38.0
grave 39.0 39.0
braceleft 38.7827057593821 39.0
bar 39.0 38.406427221172024
braceright 39.0 38.206693605650514
space 0.0 243.0

Get the kerning information

<<params>>
def show_kerning_classes(f):
  kern_name = f.gpos_lookups[0]
  lookup_info = f.getLookupInfo(kern_name)
  sub = f.getLookupSubtables(kern_name)
  for subtable in sub:
    (classes_left, classes_right, array) = f.getKerningClass(subtable)
    classes_left = list(map(lambda x: 'None' if x is None else ','.join(x), classes_left))
    classes_right = list(map(lambda x: 'None' if x is None else ','.join(x), classes_right))
    kerning = np.array(array).reshape(len(classes_left), len(classes_right))
    df = pd.DataFrame(data=kerning, index=classes_left, columns=classes_right)
    out(df)
import fontforge
<<def_show_kerning_classes>>
show_kerning_classes(fontforge.open(font))

Copy it to my website

scp sachacHand-Regular.woff web:~/sacha-v3/

Other resources

http://ctan.localhost.net.ar/fonts/amiri/tools/build.py

Back to top | E-mail me