Skip to content
Open
Binary file modified xkcd-script/font/xkcd-script.otf
Binary file not shown.
27,503 changes: 13,919 additions & 13,584 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.
7 changes: 6 additions & 1 deletion xkcd-script/generator/pt4_additional_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
UPSAMPLE = 12 # upscale factor before potrace; higher = more curve detail
THRESHOLD = 160 # pixel value below which a pixel is considered ink

SPECIALUPSAMPLE = {
'gamma': 8,
}


def _clean_potrace_svg(raw_svg_path, clean_svg_path):
"""Remove potrace artefacts from raw_svg_path and write clean_svg_path.
Expand Down Expand Up @@ -79,8 +83,9 @@ def extract_symbol(arr, y0, y1, x0, x1, name, exclude=None):
if exclude:
for ey0, ey1, ex0, ex1 in exclude:
crop[ey0 - y0:ey1 - y0, ex0 - x0:ex1 - x0] = 255
upsample = SPECIALUPSAMPLE.get(name, UPSAMPLE)
big = Image.fromarray(crop).resize(
(crop.shape[1] * UPSAMPLE, crop.shape[0] * UPSAMPLE),
(crop.shape[1] * upsample, crop.shape[0] * upsample),
Image.BILINEAR)
binary = (np.array(big) >= THRESHOLD).astype(np.uint8) * 255

Expand Down
33 changes: 24 additions & 9 deletions xkcd-script/generator/pt5_svg_to_font.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def create_char(font, chars, fname):
with tmp_symlink(fname) as tmp_fname:
# At last, bring in the SVG image as an outline for this glyph.
c.importOutlines(tmp_fname)
# Call addExtrema() first to ensure the proper operation of boundingBox().
c.addExtrema()

return c

Expand Down Expand Up @@ -252,34 +254,37 @@ def pad_glyph(c):
# Restrict the arm so that it does not pierce through the stem of the next glyph
xxrange = c.foreground.xBoundsAtY(0, 420)
bbox = tuple([xxrange[0], bbox[1], max(xxrange[1], bbox[2] - (rspace + space + 0.12 * 600)), bbox[3]])
# Measure the smoothness of a peak when there is one extremum.
lflatness = c.foreground.yBoundsAtX(bbox[0] - 1, bbox[0] + 20)
rflatness = c.foreground.yBoundsAtX(bbox[2] - 20, bbox[2] + 1)
# In the case of a complex shape, the average depth is calculated from measurements taken at four points.
# However, for a parabola, this is an algorithm that can accurately determine the coefficient of the quadratic term.
roughness = []
for i in range(4):
roughness.append(c.foreground.xBoundsAtY(100 + 100 * i, 150 + 100 * i) or tuple([bbox[2], bbox[0]]))
lroughness = np.sqrt(np.median([(roughness[i][0] - bbox[0])**2 for i in range(4)]))
rroughness = np.sqrt(np.median([(bbox[2] - roughness[i][1])**2 for i in range(4)]))
lroughness = np.median([np.sqrt(max(roughness[i][0] - bbox[0], 0)) for i in range(4)])**2
rroughness = np.median([np.sqrt(max(bbox[2] - roughness[i][1], 0)) for i in range(4)])**2
add_left = 0
if lflatness[1] - lflatness[0] < 0.25 * 600:
if lflatness[1] - lflatness[0] < 0.2 * 600:
add_left = 0
elif lroughness >= 35:
add_left = 0
elif lroughness >= 20:
add_left = 5
elif lroughness >= 10:
elif lroughness >= 11:
add_left = 10
elif lroughness >= 5:
add_left = 15
else:
add_left = 20
add_right = 0
if rflatness[1] - rflatness[0] < 0.25 * 600:
if rflatness[1] - rflatness[0] < 0.2 * 600:
add_right = 0
elif rroughness >= 35:
add_right = 0
elif rroughness >= 20:
add_right = 5
elif rroughness >= 10:
elif rroughness >= 11:
add_right = 10
elif rroughness >= 5:
add_right = 15
Expand All @@ -290,9 +295,12 @@ def pad_glyph(c):
add_right += 10
may_too_wide1 = list('aebdpr')
if c.glyphname in may_too_wide1:
if bbox[2] - bbox[0] > 370:
add_left -= 5
if bbox[2] - bbox[0] + add_left + add_right >= 398:
add_left -= 10
add_right -= 10
elif bbox[2] - bbox[0] + add_left + add_right >= 378:
add_left -= 5
add_right -= 5
scaled_width = bbox[2]
c.width = round(scaled_width + rspace + space / 2 + add_right)
t = psMat.translate(round((-bbox[0]) + space / 2 + add_left), 0)
Expand Down Expand Up @@ -330,6 +338,9 @@ def charname(char):
_per_char_operation = {
('q',): psMat.compose(psMat.scale(0.92), psMat.translate(0, 20)),
('x',): psMat.translate(0, 20),
('j',): psMat.translate(0, -20),
('A',): psMat.translate(0, -10),
('N',): psMat.translate(0, -10),
}

# Pick out particular glyphs that are more pleasant than their latter alternatives.
Expand Down Expand Up @@ -390,7 +401,11 @@ def charname(char):
# Per-character size adjustments: scale about the baseline (origin) to reduce
# overall size while preserving stroke weight gained from changeWeight above.
_operation_matrix = _per_char_operation.get(chars)
if _operation_matrix is not None:
if chars == ('A',) and c.boundingBox()[1] < 25:
pass
elif chars == ('N',) and c.boundingBox()[1] < 25:
pass
elif _operation_matrix is not None:
c.transform(_operation_matrix)

# Apply padding afterward so that it is not affected by scaling.
Expand Down
73 changes: 52 additions & 21 deletions xkcd-script/generator/pt7_font_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,27 +66,25 @@ def _expand_with_variants(font, chars):
def autokern(font):
all_glyphs = [glyph.glyphname for glyph in font.glyphs()
if not glyph.glyphname.startswith(' ')]
ligatures = [name for name in all_glyphs if '_' in name]
ligatures = [name for name in all_glyphs if name[0] != '_' and '_' in name]
upper_ligatures = [ligature for ligature in ligatures if ligature.upper() == ligature]
lower_ligatures = [ligature for ligature in ligatures if ligature.lower() == ligature]

# Expand the broad letter lists to include accented variants from the outset,
# so every rule that references `caps`, `lower`, or `all_chars` covers them too.
caps = _expand_with_variants(font, list('ABCDEFGHIJKLMNOPQRSTUVWXYZ') + upper_ligatures)
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.

I'm worried we don't have much visual test coverage of the kerning between accented variants, and therefore want to be sure how how this lands for those characters, even though I can see that the expand call is done within the kern function.

Perhaps we need another sample which covers a bunch of the different languages that we have added support for?

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.

Yes, taking that point into consideration, in my previous fork, individual kerning was applied only to basic_latin.

lower = _expand_with_variants(font, list('abcdefghijklmnopqrstuvwxyz') + lower_ligatures)
all_chars = caps + lower
caps = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
lower = list('abcdefghijklmnopqrstuvwxyz')
roman = caps + lower

font.addLookup('kerning', 'gpos_pair', (), [['kern', [['latn', ['dflt']]]]])
font.addLookupSubtable('kerning', 'kern')

def kern(sep, left, right, **kwargs):
def kern(sep, left, right, damper=None, **kwargs):
"""Wraps font.autoKern: expands accented variants and leading/trailing ligatures."""
def expand(chars, left_side):
expanded = _expand_with_variants(font, chars)
seen = set(expanded)
for glyph in font.glyphs():
name = glyph.glyphname
if '_' not in name:
if name[0] == '_' or '_' not in name:
continue
parts = name.split('_')
# Left side: ligature's right edge (last component) determines spacing.
Expand All @@ -96,26 +94,59 @@ def expand(chars, left_side):
expanded.append(name)
seen.add(name)
return expanded
font.autoKern('kern', sep, expand(left, left_side=True), expand(right, left_side=False), **kwargs)
lefts = expand(left, left_side=True)
rights = expand(right, left_side=False)
font.autoKern('kern', sep, lefts, rights, **kwargs)
if damper and damper != 1.0:
for l in lefts:
tuples = font[l].getPosSub('kern')
new_table = []
for tup in tuples:
if tup[1] == 'Pair' and tup[2] in rights:
font[l].addPosSub('kern', *(tup[2:5] + (int(tup[5] * damper),) + tup[6:]))

def getkern(left, right):
c = font[left]
tuples = c.getPosSub('kern')
for tup in tuples:
if tup[1] == 'Pair' and tup[2] == right:
return tup[5]
return None

a = font['_pad_space'].width
a = max(a - 20, 0)

# The same combination will be overwritten, so the one written last will take effect.
# autoKern looks at the outline, so even if you change the padding, it absorbs all of it.
# Use `+a` when you want to link the spacing after kerning to the padding.
kern(150, ['/', '\\'], ['/', '\\'])
kern(60+a, ['s'], set(lower) - {'j', 'f'}, minKern=50)
# x has diagonal strokes that leave visual space on its left side.
kern(90+a, set(lower) - {'f'}, ['x'], minKern=40)
kern(60+a, ['s'], set(lower) - {'i', 'j'}, onlyCloser=True)
# Overwrite sf and st. (From experience, it is often just right to adopt the larger of the two
# separation required by the glyphs on the left and right.)
kern(80+a, set(lower) - {'i', 'j'}, ['f', 't', 'x'], onlyCloser=True)
# x has diagonal strokes that leave visual space on its right side. (glyph changed in 3dbc5d1)
kern(80+a, ['x'], set(lower) - {'i', 'j'}, onlyCloser=True)
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.

I'm not keen to add kerning for all letter combinations, and think we should start with changing the bounding box of the character in the first instance.

Kerning should then be used when we need specific tweaks between pairs of letters because of the letter forms.

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.

Kerning originally refers to the process of tightening the space between characters, and by combining the width adjusted to the bounding box with onlyCloser=True, kerning settings remain only for the pairs that really need kerning.
You could say that the CPU is being made to work unnecessarily, but I think it's more reliable than a human constantly keeping all combinations in mind while checking.

That said, it caught my attention when it was mentioned, so I checked the kern table, and it turns out that *j is kerned across the board (except for gj, jj, and the like). The LSB could probably be tightened by about 20–30. Formula (capxrange[0] + bbox[0])/2 was roughly created based on the idea that 'fonts with a less curved lower part of j tend to have tighter spacing between i and j,' so it can be adjusted freely. If tightened, onlyCloser=True will take effect and automatically reduce the kerning settings.

kern(100+a, ['f', 't'], set(lower) - {'i', 'j'}, onlyCloser=True)
# Set *Y altogether first: CY, OY, etc. will have appropriate values set in the latter part.
# CY is a notable example where it is better to use the smaller separation value.
kern(105, roman, ['Y', 'T'], onlyCloser=True, damper=0.75)
kern(35, caps, ['f'], onlyCloser=True, touch=True, damper=0.9)
# F/E are separated from T/J so they can use a tighter target gap.
kern(130, ['F'], set(all_chars) - {'f', 'j'})
kern(140, ['E'], ['V', 'W', 'Y'])
kern(100, ['E'], set(all_chars) - {'f', 'j'})
kern(120, ['T', 'J'], ['R'])
kern(150, ['T', 'J'], set(all_chars) - {'f', 'j'})
# C: loosen from the default (was too tight for Ct/Cf/Cj).
kern(65, ['C'], set(all_chars) - {'f', 'j'})
kern(60, ['O'], set(all_chars) - {'f', 'j'})
kern(110, ['F'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
# Since F and z mesh together and the kerning becomes too large,
# reuse the kerning value of one of the round letterforms.
diff_Fo_Fz = getkern('F', 'o') - getkern('F', 'z')
kern(110 + int(diff_Fo_Fz / 0.75), ['F'], ['z'], onlyCloser=True, damper=0.75)
kern(90, ['E'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(115, ['T', 'J'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(105, ['Y'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(95, ['V'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
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.

Ideally I would like to be reviewing the need for new kerning pairs indivitually - for example, adding V against the full set of letters is a change that I would want to consider in isolation ideally.

kern(65, ['C'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(40, ['E', 'C'], ['V'], onlyCloser=True, touch=True)
kern(60, ['O', 'P'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(70, ['D'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(150, roman, ['j'], onlyCloser=True, damper=0.75)
kern(40, ['L'], roman, onlyCloser=True, touch=True, damper=0.9)


autokern(font)
Expand Down Expand Up @@ -208,7 +239,7 @@ def expand(chars, left_side):
# hinting, which alters the rendered pixel positions of Latin letters. Pin
# all values here (derived from the Latin+diacritic glyph set) so the
# hinting is stable regardless of how many non-Latin glyphs are added.
font.private['BlueValues'] = (-20, 20, 411, 450, 573, 613)
font.private['BlueValues'] = (-10, 20, 411, 441, 573, 603)
font.private['OtherBlues'] = (-241, -190)
font.private['BlueScale'] = 0.0208333
font.private['BlueShift'] = 16
Expand Down
Binary file modified xkcd-script/samples/charmap_arrows.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_basic_latin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_combining_diacritical_marks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_general_punctuation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_greek_and_coptic.png
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.

The gamma change here looks like it is significant, rather than a pixel difference.

The location doesn't look correct in the new version?

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.

Outline before changes:
image

In the charmap before the change, it seems that the position of gamma was shifted as a result of matplotlib's ax.text() trying to fit the outline within the frame. (A similar behavior can also be seen with U+0327. It seems to be an issue at a level different from the font's yMax height.)

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_latin_1_supplement.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_latin_extended_a.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_latin_extended_additional.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_latin_extended_b.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_mathematical_operators.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/charmap_non_latin_other.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/handwriting.png
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.

Looking at the original handwriting sample, I agree that the spacing is better here.

In particular, looking at "unevangelic" the letters are clearly distinct, whereas in the existing sample before this PR, they are on the verge of touching each other.

👍 to the diff.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/ipsum.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified xkcd-script/samples/kerning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading