Skip to content

Commit c1a4a3b

Browse files
authored
Merge pull request #97 from dummy-index/fix/kerning_etc
Fix outline of gamma, fix pad_glyph(), add more kernings with smarter operation
2 parents 8122c69 + 23c2ec9 commit c1a4a3b

22 files changed

Lines changed: 14186 additions & 13798 deletions

xkcd-script/font/xkcd-script.otf

11.1 KB
Binary file not shown.

xkcd-script/font/xkcd-script.sfd

Lines changed: 14081 additions & 13760 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

xkcd-script/font/xkcd-script.ttf

5.64 KB
Binary file not shown.

xkcd-script/font/xkcd-script.woff

508 Bytes
Binary file not shown.

xkcd-script/generator/pt4_additional_sources.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
UPSAMPLE = 12 # upscale factor before potrace; higher = more curve detail
1818
THRESHOLD = 160 # pixel value below which a pixel is considered ink
1919

20+
SPECIALUPSAMPLE = {
21+
'gamma': 8,
22+
}
23+
2024

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

xkcd-script/generator/pt5_svg_to_font.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import parse
1919
import unicodedata
2020

21+
SPACE = 0
22+
RSPACE = 20
23+
2124
fnames = sorted(glob.glob('../generated/characters/char_*.svg'))
2225

2326
characters = []
@@ -110,6 +113,8 @@ def create_char(font, chars, fname):
110113
with tmp_symlink(fname) as tmp_fname:
111114
# At last, bring in the SVG image as an outline for this glyph.
112115
c.importOutlines(tmp_fname)
116+
# Call addExtrema() first to ensure the proper operation of boundingBox().
117+
c.addExtrema()
113118

114119
return c
115120

@@ -235,51 +240,53 @@ def pad_glyph(c):
235240
# Put horizontal padding around the glyph. I choose a number here that looks reasonable,
236241
# there are far more sophisticated means of doing this (like looking at the original image,
237242
# and calculating how much space there should be).
238-
space = 0
239-
rspace = 20
240-
c.font['_pad_space'].width = space + rspace
243+
space = SPACE
244+
rspace = RSPACE
241245
bbox = c.boundingBox()
242246
if c.glyphname in list('gjpqy'):
243247
# Recalculate the bounding box by excluding the tail of the glyph
244248
# Do not remove the glyph's tail if it is too close to the baseline
245249
capxrange = c.foreground.xBoundsAtY(-40, 600)
246250
if c.glyphname == 'j':
247-
bbox = tuple([(capxrange[0] + bbox[0])/2, bbox[1], capxrange[1], bbox[3]])
251+
bbox = tuple([(capxrange[0]*2 + bbox[0])/3, bbox[1], capxrange[1], bbox[3]])
248252
else:
249253
bbox = tuple([capxrange[0], bbox[1], capxrange[1], bbox[3]])
250254
if c.glyphname == 'f':
251255
# Recalculate the bounding box by excluding the arm of the glyph
252256
# Restrict the arm so that it does not pierce through the stem of the next glyph
253257
xxrange = c.foreground.xBoundsAtY(0, 420)
254258
bbox = tuple([xxrange[0], bbox[1], max(xxrange[1], bbox[2] - (rspace + space + 0.12 * 600)), bbox[3]])
255-
lflatness = c.foreground.yBoundsAtX(bbox[0] - 1, bbox[0] + 20)
256-
rflatness = c.foreground.yBoundsAtX(bbox[2] - 20, bbox[2] + 1)
259+
# Measure the smoothness of a peak when there is one extremum.
260+
lflatness = c.foreground.yBoundsAtX(bbox[0] - 20, bbox[0] + 20)
261+
rflatness = c.foreground.yBoundsAtX(bbox[2] - 20, bbox[2] + 20)
262+
# In the case of a complex shape, the average depth is calculated from measurements taken at four points.
263+
# However, for a parabola, this is an algorithm that can accurately determine the coefficient of the quadratic term.
257264
roughness = []
258265
for i in range(4):
259266
roughness.append(c.foreground.xBoundsAtY(100 + 100 * i, 150 + 100 * i) or tuple([bbox[2], bbox[0]]))
260-
lroughness = np.sqrt(np.median([(roughness[i][0] - bbox[0])**2 for i in range(4)]))
261-
rroughness = np.sqrt(np.median([(bbox[2] - roughness[i][1])**2 for i in range(4)]))
267+
lroughness = np.median([np.sqrt(max(roughness[i][0] - bbox[0], 0)) for i in range(4)])**2
268+
rroughness = np.median([np.sqrt(max(bbox[2] - roughness[i][1], 0)) for i in range(4)])**2
262269
add_left = 0
263-
if lflatness[1] - lflatness[0] < 0.25 * 600:
270+
if lflatness[1] - lflatness[0] < 0.2 * 600:
264271
add_left = 0
265272
elif lroughness >= 35:
266273
add_left = 0
267274
elif lroughness >= 20:
268275
add_left = 5
269-
elif lroughness >= 10:
276+
elif lroughness >= 11:
270277
add_left = 10
271278
elif lroughness >= 5:
272279
add_left = 15
273280
else:
274281
add_left = 20
275282
add_right = 0
276-
if rflatness[1] - rflatness[0] < 0.25 * 600:
283+
if rflatness[1] - rflatness[0] < 0.2 * 600:
277284
add_right = 0
278285
elif rroughness >= 35:
279286
add_right = 0
280287
elif rroughness >= 20:
281288
add_right = 5
282-
elif rroughness >= 10:
289+
elif rroughness >= 11:
283290
add_right = 10
284291
elif rroughness >= 5:
285292
add_right = 15
@@ -290,9 +297,12 @@ def pad_glyph(c):
290297
add_right += 10
291298
may_too_wide1 = list('aebdpr')
292299
if c.glyphname in may_too_wide1:
293-
if bbox[2] - bbox[0] > 370:
294-
add_left -= 5
300+
if bbox[2] - bbox[0] + add_left + add_right >= 398:
301+
add_left -= 10
295302
add_right -= 10
303+
elif bbox[2] - bbox[0] + add_left + add_right >= 378:
304+
add_left -= 5
305+
add_right -= 5
296306
scaled_width = bbox[2]
297307
c.width = round(scaled_width + rspace + space / 2 + add_right)
298308
t = psMat.translate(round((-bbox[0]) + space / 2 + add_left), 0)
@@ -324,12 +334,16 @@ def charname(char):
324334
# Information to be conveyed to the next stage.
325335
# I wanted to use font.persistent, but it causes an error. Instead, I use a dummy glyph.
326336
font.createChar(-1, '_pad_space')
337+
font['_pad_space'].width = SPACE + RSPACE
327338

328339
# Per-character size scaling applied after changeWeight, to fine-tune individual glyphs
329340
# that end up slightly too large despite correct stroke weight.
330341
_per_char_operation = {
331342
('q',): psMat.compose(psMat.scale(0.92), psMat.translate(0, 20)),
332343
('x',): psMat.translate(0, 20),
344+
('j',): psMat.translate(0, -20),
345+
('A',): psMat.translate(0, -10),
346+
('N',): psMat.translate(0, -10),
333347
}
334348

335349
# Per-character weight nudge applied after the per-line scale correction.
@@ -407,7 +421,11 @@ def charname(char):
407421
# Per-character size adjustments: scale about the baseline (origin) to reduce
408422
# overall size while preserving stroke weight gained from changeWeight above.
409423
_operation_matrix = _per_char_operation.get(chars)
410-
if _operation_matrix is not None:
424+
if chars == ('A',) and c.boundingBox()[1] < 25:
425+
pass
426+
elif chars == ('N',) and c.boundingBox()[1] < 25:
427+
pass
428+
elif _operation_matrix is not None:
411429
c.transform(_operation_matrix)
412430

413431
# Apply padding afterward so that it is not affected by scaling.

xkcd-script/generator/pt7_font_properties.py

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -66,27 +66,25 @@ def _expand_with_variants(font, chars):
6666
def autokern(font):
6767
all_glyphs = [glyph.glyphname for glyph in font.glyphs()
6868
if not glyph.glyphname.startswith(' ')]
69-
ligatures = [name for name in all_glyphs if '_' in name]
69+
ligatures = [name for name in all_glyphs if name[0] != '_' and '_' in name]
7070
upper_ligatures = [ligature for ligature in ligatures if ligature.upper() == ligature]
7171
lower_ligatures = [ligature for ligature in ligatures if ligature.lower() == ligature]
7272

73-
# Expand the broad letter lists to include accented variants from the outset,
74-
# so every rule that references `caps`, `lower`, or `all_chars` covers them too.
75-
caps = _expand_with_variants(font, list('ABCDEFGHIJKLMNOPQRSTUVWXYZ') + upper_ligatures)
76-
lower = _expand_with_variants(font, list('abcdefghijklmnopqrstuvwxyz') + lower_ligatures)
77-
all_chars = caps + lower
73+
caps = list('ABCDEFGHIJKLMNOPQRSTUVWXYZ')
74+
lower = list('abcdefghijklmnopqrstuvwxyz')
75+
roman = caps + lower
7876

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

82-
def kern(sep, left, right, **kwargs):
80+
def kern(sep, left, right, damper=None, **kwargs):
8381
"""Wraps font.autoKern: expands accented variants and leading/trailing ligatures."""
8482
def expand(chars, left_side):
8583
expanded = _expand_with_variants(font, chars)
8684
seen = set(expanded)
8785
for glyph in font.glyphs():
8886
name = glyph.glyphname
89-
if '_' not in name:
87+
if name[0] == '_' or '_' not in name:
9088
continue
9189
parts = name.split('_')
9290
# Left side: ligature's right edge (last component) determines spacing.
@@ -96,26 +94,72 @@ def expand(chars, left_side):
9694
expanded.append(name)
9795
seen.add(name)
9896
return expanded
99-
font.autoKern('kern', sep, expand(left, left_side=True), expand(right, left_side=False), **kwargs)
97+
lefts = expand(left, left_side=True)
98+
rights = expand(right, left_side=False)
99+
font.autoKern('kern', sep, lefts, rights, **kwargs)
100+
if damper and damper != 1.0:
101+
for l in lefts:
102+
tuples = font[l].getPosSub('kern')
103+
new_table = []
104+
for tup in tuples:
105+
if tup[1] == 'Pair' and tup[2] in rights:
106+
font[l].addPosSub('kern', *(tup[2:5] + (int(tup[5] * damper),) + tup[6:]))
107+
108+
def getkern(left, right):
109+
c = font[left]
110+
tuples = c.getPosSub('kern')
111+
for tup in tuples:
112+
if tup[1] == 'Pair' and tup[2] == right:
113+
return tup[5]
114+
return None
100115

101116
a = font['_pad_space'].width
102-
a = max(a - 20, 0)
117+
a = a - 20
103118

119+
# The same combination will be overwritten, so the one written last will take effect.
104120
# autoKern looks at the outline, so even if you change the padding, it absorbs all of it.
105121
# Use `+a` when you want to link the spacing after kerning to the padding.
106122
kern(150, ['/', '\\'], ['/', '\\'])
107-
kern(60+a, ['s'], set(lower) - {'j', 'f'}, minKern=50)
108-
# x has diagonal strokes that leave visual space on its left side.
109-
kern(90+a, set(lower) - {'f'}, ['x'], minKern=40)
123+
# lowercase-lowercase
124+
kern(60+a, ['s'], set(lower) - {'i', 'j', 'f', 't', 'x'}, onlyCloser=True, damper=0.75) # loosen by damper
125+
# Overwrite sf and st. (From experience, it is often just right to adopt the larger of the two
126+
# separation required by the glyphs on the left and right.)
127+
# The horizontal bars of 'r' and 'f' get caught between the dot and the stem of 'i', causing
128+
# unexpected behavior, so 'i' and 'j' are excluded.
129+
kern(80+a, set(lower) - {'i', 'j'}, ['f', 't'], onlyCloser=True, damper=0.75)
130+
kern(90+a, set(lower) - {'i', 'j'}, ['x'], onlyCloser=True, damper=0.75) # kx is fine, fx is tight
131+
kern(80+a, ['x'], set(lower) - {'i', 'j'}, onlyCloser=True, damper=0.75)
132+
kern(100+a, ['f', 't'], set(lower) - {'i', 'j'}, onlyCloser=True, damper=0.75) # oveerwrite fx
133+
# For some reason, autoKern malfunctions on the left side of 'e', so the kerning value of 'o' is reused.
134+
kern(0, ['r'], ['e', 'o'])
135+
diff_ro_re = getkern('r', 'o') - getkern('r', 'e')
136+
kern(100+a, ['r'], set(lower) - {'i', 'j'}, onlyCloser=True, damper=0.75)
137+
kern(100+a + diff_ro_re, ['r'], ['e'], onlyCloser=True, damper=0.75)
138+
# including uppercase
139+
# Set *Y altogether first: CY, OY, etc. will have appropriate values set in the latter part.
140+
kern(105+a, roman, ['Y', 'T'], onlyCloser=True, damper=0.75)
141+
kern(100+a, caps, ['f'], onlyCloser=True, damper=0.75)
110142
# F/E are separated from T/J so they can use a tighter target gap.
111-
kern(130, ['F'], set(all_chars) - {'f', 'j'})
112-
kern(140, ['E'], ['V', 'W', 'Y'])
113-
kern(100, ['E'], set(all_chars) - {'f', 'j'})
114-
kern(120, ['T', 'J'], ['R'])
115-
kern(150, ['T', 'J'], set(all_chars) - {'f', 'j'})
116-
# C: loosen from the default (was too tight for Ct/Cf/Cj).
117-
kern(65, ['C'], set(all_chars) - {'f', 'j'})
118-
kern(60, ['O'], set(all_chars) - {'f', 'j'})
143+
kern(110+a, ['F'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep FO≈-60
144+
# Since F and z mesh together and the kerning becomes too large,
145+
# reuse the kerning value of one of the round letterforms.
146+
diff_Fo_Fz = getkern('F', 'o') - getkern('F', 'z')
147+
kern(110+a + int(diff_Fo_Fz / 0.75), ['F'], ['z'], onlyCloser=True, damper=0.75)
148+
kern(90+a, ['E'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep ES≈-30
149+
kern(45+a, ['E'], ['V'], onlyCloser=True, touch=True)
150+
kern(115+a, ['T', 'J'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep Tr≈-105
151+
kern(105+a, ['Y'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
152+
kern(85+a, ['V'], caps, onlyCloser=True, damper=0.75)
153+
# C: loosen from the default (was too tight for Cj).
154+
# Compared to E, the lower curve of C tends to come close to the next character,
155+
# but this is considered an intentional design.
156+
kern(60+a, ['C'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # keep CK≈-15
157+
kern(25+a, ['C'], ['V'], onlyCloser=True, touch=True)
158+
kern(60+a, ['O'], set(roman) - {'j'}, onlyCloser=True, damper=0.75) # loosen
159+
kern(100+a, ['P'], set(roman) - {'j'}, onlyCloser=True, damper=0.75)
160+
diff_Po_Pe = getkern('P', 'o') - getkern('P', 'e')
161+
kern(100+a + int(diff_Po_Pe / 0.75), ['P'], ['e'], onlyCloser=True, damper=0.75)
162+
kern(35+a, ['L'], set(roman) - {'j'}, onlyCloser=True, touch=True)
119163

120164

121165
autokern(font)
@@ -208,7 +252,7 @@ def expand(chars, left_side):
208252
# hinting, which alters the rendered pixel positions of Latin letters. Pin
209253
# all values here (derived from the Latin+diacritic glyph set) so the
210254
# hinting is stable regardless of how many non-Latin glyphs are added.
211-
font.private['BlueValues'] = (-20, 20, 411, 450, 573, 613)
255+
font.private['BlueValues'] = (-10, 20, 411, 441, 573, 603)
212256
font.private['OtherBlues'] = (-241, -190)
213257
font.private['BlueScale'] = 0.0208333
214258
font.private['BlueShift'] = 16
-96 Bytes
Loading
-1.99 KB
Loading
-351 Bytes
Loading

0 commit comments

Comments
 (0)