Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added xkcd-script/font/xkcd-mono.otf
Binary file not shown.
26,351 changes: 26,351 additions & 0 deletions xkcd-script/font/xkcd-mono.sfd

Large diffs are not rendered by default.

Binary file added xkcd-script/font/xkcd-mono.ttf
Binary file not shown.
Binary file added xkcd-script/font/xkcd-mono.woff
Binary file not shown.
Binary file modified xkcd-script/font/xkcd-script.otf
Binary file not shown.
4,485 changes: 3,053 additions & 1,432 deletions xkcd-script/font/xkcd-script.sfd

Large diffs are not rendered by default.

Binary file modified xkcd-script/font/xkcd-script.ttf
Binary file not shown.
Binary file modified xkcd-script/font/xkcd-script.woff
Binary file not shown.
167 changes: 149 additions & 18 deletions xkcd-script/generator/pt5_svg_to_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
import os
import glob
import parse
import string
import unicodedata

SPACE = 0
RSPACE = 20
MONO = True

fnames = sorted(glob.glob('../generated/characters/char_*.svg'))

Expand Down Expand Up @@ -94,6 +96,11 @@ def create_char(font, chars, fname):
c = font.createMappedChar(chars[0])
else:
c = font.createMappedChar(ord(chars[0]))
elif len(chars) >= 3 and chars[1] == '.':
# variant glyph.
variant_name = ''.join(chars)

c = font.createChar(-1, variant_name)
else:
# Multiple characters - this is a ligature. We need to register this in the
# ligature lookup table we created. Not all font-handling libraries will do anything
Expand All @@ -105,6 +112,7 @@ def create_char(font, chars, fname):

c = font.createChar(-1, ligature_name)

c.removePosSub('liga')
c.addPosSub('liga', ligature_tuple)

c.clear()
Expand Down Expand Up @@ -243,15 +251,16 @@ def pad_glyph(c):
space = SPACE
rspace = RSPACE
bbox = c.boundingBox()
if c.glyphname in list('gjpqy'):
glyphname = c.glyphname.split('.')[0]
if glyphname in list('gjpqy'):
# Recalculate the bounding box by excluding the tail of the glyph
# Do not remove the glyph's tail if it is too close to the baseline
capxrange = c.foreground.xBoundsAtY(-40, 600)
if c.glyphname == 'j':
if glyphname == 'j':
bbox = tuple([(capxrange[0]*2 + bbox[0])/3, bbox[1], capxrange[1], bbox[3]])
else:
bbox = tuple([capxrange[0], bbox[1], capxrange[1], bbox[3]])
if c.glyphname == 'f':
if glyphname == 'f':
# Recalculate the bounding box by excluding the arm of the glyph
# Restrict the arm so that it does not pierce through the stem of the next glyph
xxrange = c.foreground.xBoundsAtY(0, 420)
Expand Down Expand Up @@ -292,11 +301,11 @@ def pad_glyph(c):
add_right = 15
else:
add_right = 20
if c.glyphname == 'i':
if glyphname == 'i':
add_left += 10
add_right += 10
may_too_wide1 = list('aebdpr')
if c.glyphname in may_too_wide1:
if glyphname in may_too_wide1:
if bbox[2] - bbox[0] + add_left + add_right >= 398:
add_left -= 10
add_right -= 10
Expand Down Expand Up @@ -331,16 +340,12 @@ def charname(char):
font.hhea_descent = -270; font.hhea_descent_add = False
font.hhea_linegap = 77

# Information to be conveyed to the next stage.
# I wanted to use font.persistent, but it causes an error. Instead, I use a dummy glyph.
font.createChar(-1, '_pad_space')
font['_pad_space'].width = SPACE + RSPACE

# Per-character size scaling applied after changeWeight, to fine-tune individual glyphs
# that end up slightly too large despite correct stroke weight.
_per_char_operation = {
('q',): psMat.compose(psMat.scale(0.92), psMat.translate(0, 20)),
('x',): psMat.translate(0, 20),
('i', '.', 'm', 'o', 'n', 'o'): psMat.translate(0, -20),
('j',): psMat.translate(0, -20),
('A',): psMat.translate(0, -10),
('N',): psMat.translate(0, -10),
Expand All @@ -363,15 +368,8 @@ def charname(char):
('I', ): dict(line=4),
}

for line, position, bbox, fname, chars in characters:
if chars in special_choices:
spec = special_choices[chars]
spec_line = spec.get('line', any)
if spec_line is not any and spec_line != line:
continue

c = create_char(font, chars, fname)

def fit_glyph(c, line, position, bbox, fname, chars):
scale_glyph(
c, bbox,
baseline=_baselines[line],
Expand Down Expand Up @@ -435,6 +433,81 @@ def charname(char):
c.simplify()
c.round()


# ---------------------------------------------------------------------------
# Pass 1: Import the glyphs and check out their features.
# ---------------------------------------------------------------------------
chosen_by_fname = {}
small_caps = {}
left_small = {}
most_monospace = {}
ADDITIONAL_FOR_WEIGHTING = 40
# courier-like monospacing: 0.6em (em=856 internal units ∴0.6em=514)
_monowidth = 856 * 0.6
for line, position, bbox, fname, chars in characters:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for the non-mono form too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, lines 343 to 351 remain as before; it's a loop that converts all SVG files into glyphs, and glyphs with the same name are overwritten so that only the last one remains. Oh, that means at that moment, since the glyph exists, I can just copy and paste it into *.mono.
By the way, I don’t like writing things like ' + 60 * _line_fgs / 856' twice, so I want to reconsider the method for listing monospaced glyphs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll choose glyphs in two passes. The outlines we import the first time will be completely thrown away, but keeping all the outlines in the font object is a hassle, so I avoided it.

if chars in special_choices:
spec = special_choices[chars]
spec_line = spec.get('line', any)
if spec_line is not any and spec_line is None:
continue
if spec_line is not any and spec_line == line:
chosen_by_fname[chars] = fname
else:
chosen_by_fname[chars] = fname
# The 'V' in L7P29 corresponds to left_small, but just like in the 2017 version,
# we'll process the fname order at the top so that it's adopted based on fname order.

c = create_char(font, chars, fname)
fit_glyph(c, line, position, bbox, fname, chars)
_bb_after = c.boundingBox()
if len(chars) == 1 and chars[0] in string.ascii_uppercase:
leftyrange = c.foreground.yBoundsAtX(0, c.width / 2)
if _bb_after[3] <= 600 * 0.85:
small_caps[chars] = fname
continue # exclude from other features
elif leftyrange[1] <= 600 * 0.85:
left_small[chars] = fname
continue # exclude from other features
defending = most_monospace.get(chars, [10.0, ''])
_width = (c.width + ADDITIONAL_FOR_WEIGHTING) / _monowidth
if _bb_after[3] > 660:
pass
elif _width <= 1:
if defending[0] > 1 or defending[0] < _width:
most_monospace[chars] = [_width, fname]
elif defending[0] > _width:
most_monospace[chars] = [_width, fname]

# ---------------------------------------------------------------------------
# Pass 2: Import the glyphs with the proper names.
# ---------------------------------------------------------------------------
for line, position, bbox, fname, chars in characters:
if chars in special_choices:
spec = special_choices[chars]
spec_line = spec.get('line', any)
if spec_line is not any and spec_line is None:
continue

addr = None
if small_caps.get(chars, '') == fname:
addr = '.sc'
if left_small.get(chars, '') == fname:
addr = '.Tx'
if most_monospace.get(chars, [10.0, ''])[1] == fname:
addr = '.mono'
if chosen_by_fname[chars] == fname:
addr = ''
if addr is None:
continue
if addr != '':
if len(chars) == 1:
c = create_char(font, tuple(chars[0] + addr), fname)
fit_glyph(c, line, position, bbox, fname, tuple(chars[0] + addr))
else:
c = create_char(font, chars, fname)
fit_glyph(c, line, position, bbox, fname, chars)


c = font.createMappedChar(32)
c.width = 256

Expand All @@ -443,6 +516,20 @@ def charname(char):
c = font.createChar(0x000D, 'nonmarkingreturn') # U+000D: carriage return, zero-width; required by OpenType
c.width = 256

# Information to be conveyed to the next stage.
font.createChar(-1, '_pad_space')
font['_pad_space'].width = SPACE + RSPACE
TYPICAL_BOUNDINGWIDTH = 200
font.createChar(-1, '_typical_bbox')
c = fontforge.contour()
l = SPACE // 2 + 20
r = SPACE // 2 + TYPICAL_BOUNDINGWIDTH - 20
_ascender_top = font['l'].boundingBox()[3]
_descender_bottom = font['p'].boundingBox()[1]
c.moveTo(l, _descender_bottom).lineTo(l, _ascender_top).lineTo(r, _ascender_top).lineTo(r, _descender_bottom).lineTo(l, _descender_bottom)
font['_typical_bbox'].foreground += c
font['_typical_bbox'].width = TYPICAL_BOUNDINGWIDTH + SPACE + RSPACE


# ---------------------------------------------------------------------------
# Glyphs imported from xkcd comic images
Expand Down Expand Up @@ -891,3 +978,47 @@ def _import_math_centered(name, cp, target_top, weight_delta=0, dst_name=None):
if os.path.exists(font_fname):
os.remove(font_fname)
font.save(font_fname)


# ---------------------------------------------------------------------------
# Select mono glyphs as base set, and save
# ---------------------------------------------------------------------------

if MONO:
font_fname = '../generated/xkcd-mono-pt5.sfd'

font.fontname = 'xkcdMono'
font.familyname = 'xkcd Monospace'
font.fullname = 'xkcd-Monospace-Regular'

c = font.createChar(-1, '_monospace_width')
c.width = int(round(0.6 * font.em))

_need_backup = list('Ii')
for gname in (string.ascii_uppercase + string.ascii_lowercase):
fromname = None
if gname == 'I':
fromname = 'I_hyphen_p_r_o_n_o_u_n'
elif gname + '.mono' in font:
if gname in list('il') or abs(font[gname + '.mono'].width - font[gname].width) >= 15:
fromname = gname + '.mono'
if fromname:
if gname in _need_backup:
font.createChar(-1, gname + '.sansserif')
font.selection.select(gname)
font.copy()
font.selection.select(gname + '.sansserif')
font.paste()
font.selection.select(fromname)
font.cut()
font.selection.select(gname)
font.paste()

font.removeLookup('ligatures')
panosetuple = font.os2_panose
panosetuple = tuple([2]) + panosetuple[1:3] + tuple([9]) + panosetuple[4::]
font.os2_panose = panosetuple

if os.path.exists(font_fname):
os.remove(font_fname)
font.save(font_fname)
65 changes: 50 additions & 15 deletions xkcd-script/generator/pt6_derived_chars.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,36 @@
Reads the base SFD produced by pt5_svg_to_font.py, adds derived glyphs, saves.
"""
import math
import sys
import fontforge
import psMat

font_fname = '../generated/xkcd-script-pt6.sfd'
font = fontforge.open('../generated/xkcd-script-pt5.sfd')
if len(sys.argv) >= 2:
bodyname = sys.argv[1]
else:
bodyname = 'xkcd-script'

font_fname = f'../generated/{bodyname}-pt6.sfd'
font = fontforge.open(f'../generated/{bodyname}-pt5.sfd')

def fix_width(c, width):
space1 = (width - c.width) // 2
space2 = width - c.width - space1
if c.width == 0: # combination chars
space1 = 0 #width - c.width
space2 = 0
c.width = c.width + space1
t = psMat.translate(space2, 0)
c.transform(t)

_pad_space = font['_pad_space'].width

if '_monospace_width' in font:
_monospace_width = font['_monospace_width'].width

font.selection.all()
for c in font.selection.byGlyphs:
fix_width(c, _monospace_width)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -277,9 +302,10 @@ def _accented(cp, base_name, mark_name, gap=20, x_adj=0):
c.width = 0

# Vertical pipe: re-use the I glyph (same stroke, same weight).
c = font.createChar(-1, 'I.sansserif')
c.addReference('I')
c.width = font['I'].width
if 'I.sansserif' not in font:
c = font.createChar(-1, 'I.sansserif')
c.addReference('I')
c.width = font['I'].width
pipe = font.createMappedChar(ord('|'))
pipe.clear()
pipe.addReference('I.sansserif', psMat.compose(psMat.scale(1, 1.3), psMat.translate(0, -0.2 * font.ascent)))
Expand Down Expand Up @@ -398,7 +424,7 @@ def _accented(cp, base_name, mark_name, gap=20, x_adj=0):
_bb = _breve_mark.boundingBox()
_breve_mark.transform(psMat.translate(-(_bb[0] + _bb[2]) / 2, 0))
_breve_mark.transform(psMat.scale(0.5, 1))
_breve_mark.transform(psMat.translate(-220, 0))
_breve_mark.width = 0

# --- Marks composed from existing mark glyphs ---

Expand Down Expand Up @@ -460,8 +486,11 @@ def _accented(cp, base_name, mark_name, gap=20, x_adj=0):
# Without GPOS anchors the renderer places marks at their native coordinates,
# so we translate here to ensure they appear above even tall letters like l/L/b/h.
# (Pre-composed glyphs are unaffected — _place_above computes its own dy.)
_ascender_top = font['l'].boundingBox()[3]
# Inter-stage contract: all above combining marks sit on top-center of _typical_bbox
_typical_bbox = font['_typical_bbox'].boundingBox()
_ascender_top = _typical_bbox[3]
_combining_gap = 20
_center_top = (_typical_bbox[0] + _typical_bbox[2]) / 2 - font['_typical_bbox'].width

for cp, mark in [
(0x0300, _grave_mark),
Expand All @@ -480,36 +509,37 @@ def _accented(cp, base_name, mark_name, gap=20, x_adj=0):
c.clear()
mark_bb = font[mark.glyphname].boundingBox()
dy = _ascender_top + _combining_gap - mark_bb[1]
c.addReference(mark.glyphname, psMat.translate(0, dy))
c.addReference(mark.glyphname, psMat.translate(_center_top, dy))
c.width = 0

# Below combining marks share the macron-below shape at different vertical positions.
_descender_bottom = font['p'].boundingBox()[1]
_descender_bottom = font['_typical_bbox'].boundingBox()[1]
_mb_bb = font['_macron_below_mark'].boundingBox()
_center_bottom = (_typical_bbox[0] + _typical_bbox[2]) / 2 - font['_typical_bbox'].width

# U+0331 ◌̱ COMBINING MACRON BELOW — below the descender.
_c0331 = font.createMappedChar(0x0331)
_c0331.clear()
_c0331.addReference('_macron_below_mark', psMat.translate(0, _descender_bottom - _combining_gap - _mb_bb[3]))
_c0331.addReference('_macron_below_mark', psMat.translate(_center_bottom, _descender_bottom - _combining_gap - _mb_bb[3]))
_c0331.width = 0

# U+0332 ◌̲ COMBINING LOW LINE — just below the baseline (underline position).
_c0332 = font.createMappedChar(0x0332)
_c0332.clear()
_c0332.addReference('_macron_below_mark', psMat.translate(0, -_combining_gap - _mb_bb[3]))
_c0332.addReference('_macron_below_mark', psMat.translate(_center_bottom, -_combining_gap - _mb_bb[3]))
_c0332.width = 0

# U+0320 ◌̠ COMBINING MINUS SIGN BELOW — halfway between baseline and descender.
_c0320 = font.createMappedChar(0x0320)
_c0320.clear()
_c0320.addReference('_macron_below_mark', psMat.translate(0, _descender_bottom // 2 - _combining_gap - _mb_bb[3]))
_c0320.addReference('_macron_below_mark', psMat.translate(_center_bottom, _descender_bottom // 2 - _combining_gap - _mb_bb[3]))
_c0320.width = 0

# U+0327 ◌̧ COMBINING CEDILLA — hook cedilla shape, positioned below descender.
_hc_bb = font['_hook_cedilla_mark'].boundingBox()
_c0327 = font.createMappedChar(0x0327)
_c0327.clear()
_c0327.addReference('_hook_cedilla_mark', psMat.translate(0, _descender_bottom - _combining_gap - _hc_bb[3]))
_c0327.addReference('_hook_cedilla_mark', psMat.translate(_center_bottom, _descender_bottom - _combining_gap - _hc_bb[3]))
_c0327.width = 0


Expand Down Expand Up @@ -633,7 +663,7 @@ def _make_lslash(font, cp, base_name, crossbar_name, y_frac, x_center):
c.width = font[base_name].width
base_bb = font[base_name].boundingBox()
target_y = base_bb[1] + (base_bb[3] - base_bb[1]) * y_frac
c.addReference(crossbar_name, psMat.translate(x_center, target_y))
c.addReference(crossbar_name, psMat.translate(round(base_bb[0]) - 20 + x_center, target_y))
return c


Expand Down Expand Up @@ -898,7 +928,7 @@ def _make_eng(font, cp, base_name, comma_name, x_frac=0.88, x_offset=0, y_offset

# ij U+0133 / IJ U+0132: Dutch IJ digraph ligatures.
# Position so the ink edges have the same gap as adjacent letters would after kerning (~40 units).
for cp, left, right in [(0x0133, 'i', 'j'), (0x0132, 'I', 'J')]:
for cp, left, right in [(0x0133, 'i', 'j'), (0x0132, 'I.sansserif', 'J')]:
_ij = font.createMappedChar(cp)
_ij.clear()
_ij.addReference(left)
Expand Down Expand Up @@ -1527,4 +1557,9 @@ def make_display_operator(font, src_name, dst_name, target_h, weight=0, rbear=0)
# Save
# ---------------------------------------------------------------------------

if '_monospace_width' in font:
font.selection.all()
for c in font.selection.byGlyphs:
fix_width(c, _monospace_width)

font.save(font_fname)
Loading
Loading