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 modified xkcd-script/font/xkcd-script.otf
Binary file not shown.
27,539 changes: 13,922 additions & 13,617 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
39 changes: 27 additions & 12 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 @@ -244,42 +246,45 @@ def pad_glyph(c):
# 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':
bbox = tuple([(capxrange[0] + bbox[0])/2, bbox[1], capxrange[1], bbox[3]])
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':
# 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)
bbox = tuple([xxrange[0], bbox[1], max(xxrange[1], bbox[2] - (rspace + space + 0.12 * 600)), bbox[3]])
lflatness = c.foreground.yBoundsAtX(bbox[0] - 1, bbox[0] + 20)
rflatness = c.foreground.yBoundsAtX(bbox[2] - 20, bbox[2] + 1)
# Measure the smoothness of a peak when there is one extremum.
lflatness = c.foreground.yBoundsAtX(bbox[0] - 20, bbox[0] + 20)
rflatness = c.foreground.yBoundsAtX(bbox[2] - 20, bbox[2] + 20)
# 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
79 changes: 58 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,65 @@ 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)
# lowercase-lowercase
kern(60+a, ['s'], set(lower) - {'i', 'j', 'f', 't', 'x'}, onlyCloser=True, damper=0.75) # loosen by damper
# 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'], onlyCloser=True, damper=0.75)
kern(90+a, set(lower) - {'i', 'j'}, ['x'], onlyCloser=True, damper=0.75) # kx is fine, fx is tight
kern(80+a, ['x'], set(lower) - {'i', 'j'}, onlyCloser=True, damper=0.75)
kern(100+a, ['f', 't'], set(lower) - {'i', 'j'}, onlyCloser=True, damper=0.75) # oveerwrite fx
# including uppercase
# Set *Y altogether first: CY, OY, etc. will have appropriate values set in the latter part.
kern(105, roman, ['Y', 'T'], onlyCloser=True, damper=0.75)
kern(100, caps, ['f'], onlyCloser=True, damper=0.75)
# 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) # keep FO≈-60
# 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) # keep ES≈-30
kern(45, ['E'], ['V'], onlyCloser=True, touch=True)
kern(115, ['T', 'J'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep Tr≈-105
kern(105, ['Y'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
kern(85, ['V'], caps, onlyCloser=True, damper=0.75)
# C: loosen from the default (was too tight for Cj).
# Compared to E, the lower curve of C tends to come close to the next character,
# but this is considered an intentional design.
kern(60, ['C'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep CK≈-15
kern(25, ['C'], ['V'], onlyCloser=True, touch=True)
kern(60, ['O'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # loosen
kern(100, ['P'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
diff_Po_Pe = getkern('P', 'o') - getkern('P', 'e')
kern(100 + int(diff_Po_Pe / 0.75), ['P'], ['e'], onlyCloser=True, damper=0.75)
kern(35, ['L'], set(roman) - {'j'}, onlyCloser=True, touch=True)


autokern(font)
Expand Down Expand Up @@ -208,7 +245,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.