Skip to content

sachac/sachac-hand

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

52 Commits
 
 
 
 
 
 
 
 

Repository files navigation

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 tablet.

https://pages.sachachua.com/sachac-hand/README.htmlThis README as HTML
https://github.com/sachac/sachac-handGithub repo
./files/test.htmlTest pages

License

Feel free to use the font under the SIL Open Font License. That means you can freely create and distribute things that use the font.

Feel free to use the code under the GNU GPL v3+ license.

See LICENSE for more details.

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:

Noneo,a,c,e,d,g,q,wf,t,x,v,y,zh,b,l,i,kjm,n,p,r,usTzero
None000000000
f0-102-61-300-600-120-70
t0-70-41-25000-120-10
r0-82-41-250-200-12029
k0-50-81-200-20-48-120-79
l0-41-500000-120-52
v0-40-35-30000-12030
b,o,p0-20-800000-12043
a0-23-600000-1207
W0-40-30-20000-12017
T0-190-120-600-13000-188
F0-100-90-600-70-100-40-166
two00000000-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': '/usr/share/aglfn/glyphlist.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!"
  }
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 or not pos in font:
    return font.createChar(ord(aglfn.to_glyph(g)), 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()
  if hasattr(font, 'findEncodingSlot'):
    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 = fontforge.open(font_filename)
  #font.generate(font_filename.replace('.sfd', '.otf'))
  #font.generate(font_filename.replace('.sfd', '.woff'))

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': 'sachacHand-Regular.otf', 'template': 'template-sachacHand.png', 'row_padding': 50})
make_template({**params, 'font_name': 'sachacHand.otf', 'template': 'template-sample.png', 'direction': 'horizontal', 'height': 1000, 'width': 1000, 'row_padding': 100 }) 
#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 }) 

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('L')
  thresh = 200
  fn = lambda x : 255 if x > thresh else 0
  im = im.point(fn, mode='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(aglfn.name(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
import psMat
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)
  if params['scale']:
    scale = psMat.scale(params['scale']) 
  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)
        if params['scale']:
          g.transform(scale) 
      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 = g
    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 = int(bearing_dict[key][1] * (params['scale'] or 1))
    else:
      font[g].left_side_bearing = int(bearing_dict['Default'][1] * (params['scale'] or 1))
    if bearing_dict[key][2] != '':
      font[g].right_side_bearing = int(bearing_dict[key][2] * (params['scale'] or 1))
    else:
      font[g].right_side_bearing = int(bearing_dict['Default'][2] * (params['scale'] or 1))
  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(int(x) * (params['scale'] or 1))) 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, int((params['scale'] or 1) * 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)
  print("Saving %s" % filename)
  save_font(font, font_filename=filename)
  #with open("test-%s.html" % font.fontname, 'w') as f:
  #  f.write(test_font_html(font.fontname + '.woff'))
  return font
<<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

LeftRight
Default6060
A60-50
B600
C60-30
c40
b40
D10
d3030
e3040
E7010
F700
f0-20
G6030
g2060
H8080
h4040
I8050
i30
J4030
j-7040
k4020
K800
H10
L8010
l0
M6030
m40
N7010
O7010
o4040
P700
p40
Q7010
q2030
R70-10
r40
S6060
s2040
T-10
t-1020
U7020
u4040
V-10
v2020
W7020
w4040
X-10
x1020
y2030
Y400
Z-10
z1020

Rows are first characters, columns are second characters.

Noneo,a,c,e,d,g,q,wf,tx,v,zh,b,l,ijm,n,p,r,ukysTFzero
None0000000000
f0-30-61-2000-150-70
t0-50-41-20000-150-10
i-40-150
r0-32-4000-17029
k0-10-500-48-150-79
l0-10-200000-110-20
v0-40-35-15000-17030
b,o,p0-400000-17043
n,m-30-170
a0-23-300000-1707
W0-40-30-10000
T0-150-120-120-30-40-130-100-800
F0-90-90-70-300-70-50-80-40
P0-100-70-500-70-30-80-20
g40-120
q,d,h,y,j3030303030-100
c,e,s,u,w,x,z-120
V-703030-80-20-40-40-10
A30603030204020-80-1202020
Y2060303020204020-10
M,N,H,I20104030102020
O,Q,D,U504030-20302030-70
J402020-20101030-30
C10401030303020-30
E-105010-201020
L-10-10-3020-90
P-503020202020-30
K,R20202010202020-60
G20403030202020-10010
B,S,X,Z2040303020202020-2010
<<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

LeftRight
Default3030
A40-90
B200
C40-30
b40
D6010
d-10
e20
E6020
F7020
f-50-10
G4030
g2040
I7050
i30
J-1030
j-4050
k4020
K500
H5030
L6010
l4040
M7040
m40
N7030
O4010
P600
p20
Q4010
q2030
R50-10
S2030
s2040
T-10
t-400
U6010
u20
V-10
v2020
W5020
X-10
x1020
y2030
Y4020
Z-10
z1020
Nonem,n,p,rh,b,l,i,ko,a,c,e,d,g,q,w,uf,tx,v,zjysTJF,B,D,E,H,I,K,L,M,N,P,RVA,C,G,K,O,Q,S,WUXYZzero
None110
f-1020-600-90-40-190-8020
t20-2010-70-10010
i-3010-90-160-20-20
r-10-80-90-40-190-100-10-50-50-10-50
k-10-10-20-10-90-10010-30-30-10
l-2010-50-20-10010-20-30-30
v-3010-50-10010-30-30-20
b,o,p-2010-90-10010-10-30-30-30-10
n,m10-90-10010-10-20-30-10
a-30-20-90-10-140-30-60-40-20-40
W20-10010-20
T-70-30-100-70-90-120-30-80-100-50
F-50-70-100-50
g-1010-50-14010-20
d10102010-5010-1001010
h,q,y,j102010-5010-13010-2010
c,e,s,u,w,x,z-201010-50-13010-40-40-20
V-20-703030-80-40-40-300
A20303060502020-106020202020
Y2030206030-504020-104030
M,N,H,I202005030-50204030
O,Q,D,U30405040-2030-704020
J10204020-2030-308020
C303010401020-308020
E1010-1050-2020110
L-10-10-3020-9020
P20-50302020-3080
K,R201020202020-6050
G203020403020-1001010
B,S,X,Z20302040302020-209010
<<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)

Generate sachacHand from iPad sample

For cutting the glyphs:

<<params>>
<<def_all>>
params = {**params, 
          'sample_file': '../samples/output.png',
          'direction': 'horizontal',
          'height': 1000, 'width': 1000, 'row_padding': 100,
          'scale': 1.2,
          'name_list': 'glyphlist.txt',
          'direction': 'horizontal',
          'new_font_file': 'sachacHand-ipad.sfd',
          'new_otf': 'sachacHand-ipad.otf'}

Kerning:

LeftRight
Default3030
A30-4
B600
C20-30
b40
D4010
d13
e20
E5020
F500
f-50-80
G4030
g2040
H5050
h1414
I6050
i30
J-1030
j-2030
k4020
K700
H10
L6010
l0
M60
m40
N6010
n35
o5
O4010
P600
p815
Q4010
q2030
R50-10
S3030
s2040
T-10
t-400
U6020
u20
V-10
v2020
W5020
X-10
x1020
y2030
Y400
Z-10
z1020
exclam50
Noneo,a,c,e,d,g,q,wf,tx,v,zh,b,l,ijm,n,p,r,ukysTFVzero
None20
n,m20-90-100-100
f-10020-901020-40-19020
t-2010-702020-100
i-3010-90-160
r-70-10-90-60-220
k-20-10-10-90-10-100-10
l1020-100
v-3010-50-100
b,o,p10-90-100
a-90-10-100
W20-100
T-120-70-90-30-120-70-30-30-80-100
F-90-70-100
g10-50-100
q,d,h,y,j2010-50101010-10010
c,e,s,u,w,x,z-201010-10-50-100
V-703030-80-20-40-40-10
A3060303020402020-102020
Y2060303020204020-10
M,N,H,I20504030102020
O,Q,D,U504030-20302030-70
J402020-20101030-30
C10401030303020-30
E-105010-201020
L-10-10-3020-90
P-503020202020-30
K,R20202010202020-60
G20403030202020-10010
B,S,X,Z2040303020202020-2010
W-70
<<font_params>>
cut_glyphs(**params)

https://www.youtube.com/watch?v=WqSQU7nuTsc https://www.tug.org/TUGboat/tb24-3/williams.pdf https://typedrawers.com/discussion/1357/how-can-i-randomize-letters-in-a-typeface http://learn.scannerlicker.net/2015/06/12/making-a-font-maximal-part-iii/

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('sachacHand-Regular-V.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'
font.buildOrReplaceAALTFeatures()
# 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))

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

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:

OutputFont filenameClass
test-regular.htmlsachacHand.woffregular
test-bold.htmlsachacHandBold.woffbold
test-black.htmlsachacHandBlack.woffblack
test-new.htmlsachacHand-New.woff2new
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",
           "Projects"]

def test_strings(strings, font, variants=None):
  doc, tag, text, line = Doc().ttl()
  line('h2', 'Test strings')
  if variants:
    for s in strings:
      with tag('table'):
        with tag('tr'):
          with tag('td', 'nocalt'):
            text(s)
        for v in variants:
          with tag('tr'):
            line('td', v)
            with tag('td', klass=v + ' nocalt'):
              text(s)
  else:
    with tag('table'):
      for s in strings:
        with tag('tr'):
          with tag('td'):
            text(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 }")
        if variants:
          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))
      if variants:
        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', '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', 'Size')
      with tag('div'):
        line('span', "Hello world")
        line('span', "Hello world", klass='basefont')
      with tag('table'):
        with tag('tr'):
          line('td', test_glyphs(font))
          line('td.base', test_glyphs(font))
      doc.asis(test_strings(strings, font, variants))
      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

Copy glyphs from hand-edited font

  • State “DONE” from “TODO” [2020-06-06 Sat 22:33]

Alternate glyphs

Ligatures

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

Manually edit the glyphs to make them look okay

Double up the paths and close them

https://wiki.inkscape.org/wiki/index.php/CalligraphedOutlineFill ?

import inkex
<<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()

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)

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])

#+RESULTS[5a3f821b4bbfcb462cebc176c66bcb697c6bf4f2]: digrams

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()))

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

About

Working on a handwriting font

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages