Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b0be3b3

Browse files
authored
Merge pull request #65 from pelson/feature/greek
2 parents 1f910a8 + 3d54993 commit b0be3b3

20 files changed

Lines changed: 7305 additions & 1376 deletions

xkcd-script/font/xkcd-script.otf

218 KB
Binary file not shown.

xkcd-script/font/xkcd-script.sfd

Lines changed: 6791 additions & 1272 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

36.2 KB
Binary file not shown.

xkcd-script/font/xkcd-script.woff

68.6 KB
Binary file not shown.
171 KB
Loading
10.1 KB
Loading
9.94 KB
Loading
9.46 KB
Loading

xkcd-script/generator/pt5_additional_sources.py renamed to xkcd-script/generator/pt4_additional_sources.py

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"""
33
Extract hand-drawn glyphs from extras/ and convert them to SVG.
44
5-
Outputs go to ../generated/additional_chars/ and are consumed by pt6_derived_chars.py.
5+
Outputs go to ../generated/additional_chars/ and are consumed by pt5_svg_to_font.py.
66
"""
77
import os
88
import subprocess
@@ -68,9 +68,17 @@ def _clean_potrace_svg(raw_svg_path, clean_svg_path):
6868
os.remove(clean_svg_path + '.sfd')
6969

7070

71-
def extract_symbol(arr, r0, r1, c0, c1, name):
72-
"""Crop glyph region, upsample, binarise, run potrace, clean, save SVG."""
73-
crop = arr[r0:r1, c0:c1]
71+
def extract_symbol(arr, y0, y1, x0, x1, name, exclude=None):
72+
"""Crop glyph region, upsample, binarise, run potrace, clean, save SVG.
73+
74+
exclude: optional list of (y0, y1, x0, x1) regions in full-image coordinates
75+
to blank out (set to background) before potrace, for removing
76+
artefacts that cannot be separated by tightening the main crop.
77+
"""
78+
crop = arr[y0:y1, x0:x1].copy()
79+
if exclude:
80+
for ey0, ey1, ex0, ex1 in exclude:
81+
crop[ey0 - y0:ey1 - y0, ex0 - x0:ex1 - x0] = 255
7482
big = Image.fromarray(crop).resize(
7583
(crop.shape[1] * UPSAMPLE, crop.shape[0] * UPSAMPLE),
7684
Image.BILINEAR)
@@ -90,6 +98,51 @@ def extract_symbol(arr, r0, r1, c0, c1, name):
9098
return svg_path
9199

92100

101+
# ---------------------------------------------------------------------------
102+
# xkcd 2586 "Greek Letters" — 2x image (889 × 1699 px)
103+
# Symbol column: cols 80–136; coordinates are 2× the 1x bounds.
104+
# ---------------------------------------------------------------------------
105+
106+
GREEK_LETTERS_2586 = {
107+
# name y0 y1 x0 x1
108+
'pi': ( 144, 172, 80, 136), # π U+03C0
109+
'Delta': ( 190, 228, 80, 136), # Δ U+0394
110+
'delta': ( 256, 304, 80, 136), # δ U+03B4
111+
'theta': ( 334, 372, 80, 136), # θ U+03B8
112+
'phi': ( 370, 440, 80, 136), # φ U+03C6 (y1 extended: clips on bottom)
113+
'epsilon': ( 450, 476, 80, 136), # ε U+03B5
114+
'upsilon': ( 500, 534, 35, 86), # υ U+03C5 (left half of ν/υ pair row; x1 tightened: comma at col 87)
115+
'nu': ( 500, 534, 88, 120), # ν U+03BD (right half of ν/υ pair row)
116+
'mu': ( 562, 608, 65, 136), # μ U+03BC (x0 extended: clips on left)
117+
'Sigma': ( 636, 678, 65, 136), # Σ U+03A3 (x0 extended: clips on left)
118+
'Pi': ( 688, 732, 65, 136), # Π U+03A0 (x0 extended: clips on left)
119+
'zeta': ( 740, 795, 80, 136), # ζ U+03B6 (y0 compromise: clears Pi, keeps zeta top)
120+
'beta': ( 800, 844, 80, 136), # β U+03B2
121+
'alpha': ( 872, 902, 80, 115), # α U+03B1 (x1 tightened: bit on right)
122+
'Omega': ( 958, 1002, 70, 130), # Ω U+03A9 (x1 loosened from 115: clips on right)
123+
'omega': (1050, 1080, 65, 136), # ω U+03C9 (x0 extended: clips on left)
124+
'sigma': (1140, 1168, 80, 136), # σ U+03C3
125+
'xi': (1220, 1266, 80, 136), # ξ U+03BE
126+
'gamma': (1290, 1336, 80, 136), # γ U+03B3
127+
'rho': (1362, 1422, 80, 136), # ρ U+03C1 (y1 extended: clips on bottom)
128+
'Xi': (1472, 1520, 80, 136), # Ξ U+039E
129+
'psi': (1574, 1630, 65, 136), # ψ U+03C8 (x0 extended: clips on left)
130+
}
131+
132+
# Regions (in full-image coords) to blank out before potrace, keyed by glyph name.
133+
# Used when an artefact cannot be excluded by tightening the main crop alone.
134+
GREEK_EXCLUDE_2586 = {
135+
'upsilon': [(522, 534, 80, 100)], # comma tail curves into the crop at rows 522-533
136+
}
137+
138+
_greek_image = os.path.join(os.path.dirname(__file__), '2586_greek_letters_2x.png')
139+
print(f'Extracting Greek letters from {_greek_image}...')
140+
arr_greek = np.array(Image.open(_greek_image).convert('L'))
141+
for name, (y0, y1, x0, x1) in GREEK_LETTERS_2586.items():
142+
extract_symbol(arr_greek, y0, y1, x0, x1, name,
143+
exclude=GREEK_EXCLUDE_2586.get(name))
144+
145+
93146
# ---------------------------------------------------------------------------
94147
# Hand-drawn extras (generator/extras/*.png)
95148
# Each file is a full-glyph image (no cropping needed). RGBA images are
@@ -99,14 +152,17 @@ def extract_symbol(arr, r0, r1, c0, c1, name):
99152

100153
EXTRAS_DIR = 'extras'
101154

155+
# Each entry is (output_name, source_filename_stem).
102156
EXTRAS = [
103-
'eszett', # ß U+00DF / ẞ U+1E9E source
157+
('eszett', 'eszett'), # ß U+00DF / ẞ U+1E9E source
158+
('lambda', '1145_sky_color_2x__lambda'), # λ U+03BB source
159+
('tau', '2520_symbols_2x__tau'), # τ U+03C4 source
160+
('varsigma', '2586_greek_letters_2x__sigma'), # ς U+03C2 source
104161
]
105162

106163
print('Extracting hand-drawn extras...')
107-
for name in EXTRAS:
108-
src_path = os.path.join(EXTRAS_DIR, f'{name}.png')
164+
for name, filename in EXTRAS:
165+
src_path = os.path.join(EXTRAS_DIR, f'{filename}.png')
109166
arr_extra = np.array(Image.open(src_path).convert('L'))
110167
h, w = arr_extra.shape
111168
extract_symbol(arr_extra, 0, h, 0, w, name)
112-
Lines changed: 240 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
"""
33
Convert hand-drawn SVG glyphs into the base xkcd-script SFD font file.
44
5-
Reads per-character SVG files produced by pt3_ppm_to_svg.py, scales and
6-
positions each glyph to fit the EM, applies per-line scale corrections, and
7-
saves the result as xkcd-script.sfd.
5+
Reads per-character SVG files produced by pt3_ppm_to_svg.py and additional
6+
comic-sourced SVGs produced by pt4_additional_sources.py, scales and positions
7+
each glyph to fit the EM, applies per-line scale corrections, and saves the
8+
result as xkcd-script.sfd.
89
9-
Derived characters (diacriticals, aliases) are added in pt5_derived_chars.py.
10-
Font-wide properties (kerning) are applied in pt6_font_properties.py.
10+
Derived characters (diacriticals, aliases) are added in pt6_derived_chars.py.
11+
Font-wide properties (kerning) are applied in pt7_font_properties.py.
1112
"""
1213
from __future__ import division
1314
import base64
@@ -317,6 +318,240 @@ def charname(char):
317318
c.width = 256
318319

319320

321+
# ---------------------------------------------------------------------------
322+
# Glyphs imported from xkcd comic images
323+
# ---------------------------------------------------------------------------
324+
325+
_COMIC_CHARS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../generated/additional_chars')
326+
327+
328+
def _scan_stroke_width(g, y_lo, y_hi, n=8):
329+
"""Rough stroke-width estimate via horizontal line scanning.
330+
331+
Samples n evenly-spaced y values in [y_lo, y_hi]. At each y, finds all
332+
x-crossings of the contour edges (polyline approximation between consecutive
333+
points) and records the minimum gap between consecutive crossings. Returns
334+
the median of those per-y minimums — approximating the typical thinnest
335+
visible stroke in the scan band.
336+
"""
337+
results = []
338+
for k in range(n):
339+
y = y_lo + (y_hi - y_lo) * (k + 0.5) / n
340+
xs = []
341+
for contour in g.foreground:
342+
pts = list(contour)
343+
for i in range(len(pts)):
344+
p1, p2 = pts[i], pts[(i + 1) % len(pts)]
345+
if (p1.y - y) * (p2.y - y) < 0:
346+
t = (y - p1.y) / (p2.y - p1.y)
347+
xs.append(p1.x + t * (p2.x - p1.x))
348+
xs.sort()
349+
gaps = [xs[j + 1] - xs[j] for j in range(len(xs) - 1)]
350+
if gaps:
351+
results.append(min(gaps))
352+
if not results:
353+
return None
354+
results.sort()
355+
return results[len(results) // 2]
356+
357+
358+
def _import_comic_glyph(font, name, svg_path, target_top, weight_delta=0):
359+
"""Import a pre-cleaned SVG (from pt4_additional_sources.py) and scale it
360+
so the top of the ink reaches target_top in font units, preserving the
361+
aspect ratio so any descender falls naturally below baseline.
362+
363+
weight_delta: if non-zero, apply changeWeight() after scaling.
364+
"""
365+
g = font.createChar(-1, f'_comic_{name}')
366+
g.clear()
367+
g.importOutlines(svg_path)
368+
369+
bb = g.boundingBox()
370+
g.transform(psMat.scale(target_top / bb[3]))
371+
372+
bb = g.boundingBox()
373+
g.transform(psMat.translate(-bb[0] + 20, 0))
374+
375+
if weight_delta:
376+
g.removeOverlap()
377+
g.changeWeight(weight_delta)
378+
379+
g.correctDirection()
380+
# round() before removeOverlap() avoids a FontForge "endpoint intersection"
381+
# bug that fires when two spline curves meet exactly at a fractional-
382+
# coordinate point on the sweep line.
383+
g.round()
384+
g.removeOverlap()
385+
g.addExtrema()
386+
387+
bb = g.boundingBox()
388+
g.width = int(round(bb[2] + 20))
389+
return g
390+
391+
392+
# Greek letters vectorised by pt4 from xkcd comic images.
393+
# Each entry: (svg_name, unicode_cp, ref_char_for_height, baseline_snap)
394+
# baseline_snap=True → translate so bb[1]=0 (letters that sit on the baseline).
395+
# baseline_snap=False → leave at natural import coords; descender positioning
396+
# is handled separately via _GREEK_DESCENDER_FRAC.
397+
_GREEK = [
398+
('pi', 0x03C0, 'a', True),
399+
('Delta', 0x0394, 'A', True),
400+
('delta', 0x03B4, 'b', True),
401+
('theta', 0x03B8, 'b', True),
402+
('phi', 0x03C6, 'a', True),
403+
('epsilon', 0x03B5, 'a', True),
404+
('upsilon', 0x03C5, 'a', True),
405+
('nu', 0x03BD, 'a', True),
406+
('mu', 0x03BC, 'a', False),
407+
('Sigma', 0x03A3, 'A', True),
408+
('Pi', 0x03A0, 'A', True),
409+
('zeta', 0x03B6, 'b', False),
410+
('beta', 0x03B2, 'b', False),
411+
('alpha', 0x03B1, 'a', True),
412+
('Omega', 0x03A9, 'A', True),
413+
('omega', 0x03C9, 'a', True),
414+
('sigma', 0x03C3, 'a', True),
415+
('xi', 0x03BE, 'b', False),
416+
('gamma', 0x03B3, 'a', False),
417+
('rho', 0x03C1, 'a', False),
418+
('Xi', 0x039E, 'A', True),
419+
('psi', 0x03C8, 'a', False),
420+
('lambda', 0x03BB, 'b', True),
421+
('tau', 0x03C4, 'a', True),
422+
('varsigma', 0x03C2, 'a', True),
423+
]
424+
425+
# Letters with genuine descenders: fraction of the crop height that is the
426+
# body (above baseline). Remaining fraction becomes the descender below y=0.
427+
# Estimated from pixel proportions in the 2× source image crop.
428+
_GREEK_DESCENDER_FRAC = {
429+
'mu': 0.61,
430+
'beta': 0.75,
431+
'rho': 0.67,
432+
}
433+
434+
# Measure target stroke width from 'l' — a clean vertical stroke with no
435+
# ambiguity from curves or bowls.
436+
_l_bb = font['l'].boundingBox()
437+
_target_stroke = _scan_stroke_width(
438+
font['l'],
439+
_l_bb[1] + (_l_bb[3] - _l_bb[1]) * 0.2,
440+
_l_bb[1] + (_l_bb[3] - _l_bb[1]) * 0.8,
441+
)
442+
443+
# Glyphs dominated by horizontal strokes: the horizontal scan in
444+
# _scan_stroke_width measures bar *lengths* rather than stroke thickness,
445+
# producing a grossly over-estimated value and a large negative delta that
446+
# massively thins the letter. Skip stroke normalisation for these.
447+
_GREEK_NO_STROKE_NORM = {'epsilon', 'Xi', 'Sigma'}
448+
449+
# Per-letter changeWeight nudge applied after all positioning and stroke
450+
# normalisation. Positive = thicker, negative = thinner.
451+
_GREEK_WEIGHT_NUDGE = {
452+
'Sigma': 15,
453+
'mu': 15,
454+
'epsilon': 15,
455+
'psi': -15,
456+
'lambda': 10,
457+
}
458+
459+
for _name, _cp, _ref, _snap in _GREEK:
460+
_svg = os.path.join(_COMIC_CHARS_DIR, f'{_name}.svg')
461+
_target_top = font[_ref].boundingBox()[3]
462+
_g = _import_comic_glyph(font, _name, _svg, target_top=_target_top)
463+
_desc_frac = _GREEK_DESCENDER_FRAC.get(_name)
464+
if _desc_frac is not None:
465+
# Seat the body in [0, target_top] and let the descender fall below y=0.
466+
# baseline_y is the font-unit y that corresponds to the baseline inside
467+
# the imported glyph.
468+
_bb = _g.boundingBox()
469+
_baseline_y = _bb[3] - _desc_frac * (_bb[3] - _bb[1])
470+
_g.transform(psMat.translate(0, -_baseline_y))
471+
_bb = _g.boundingBox()
472+
if _bb[3] > 0:
473+
_g.transform(psMat.scale(_target_top / _bb[3]))
474+
_g.width = int(round(_g.boundingBox()[2] + 20))
475+
elif _snap:
476+
# Seat the glyph on the baseline: translate so bb[1]=0 then re-scale to
477+
# restore target_top. Handles both positive bb[1] (sub-baseline
478+
# whitespace in the source crop) and negative bb[1] (ink that slightly
479+
# undercuts the baseline).
480+
_bb = _g.boundingBox()
481+
if _bb[1] != 0:
482+
_g.transform(psMat.translate(0, -_bb[1]))
483+
_bb = _g.boundingBox()
484+
if _bb[3] > 0:
485+
_g.transform(psMat.scale(_target_top / _bb[3]))
486+
_g.width = int(round(_g.boundingBox()[2] + 20))
487+
# Normalise stroke width AFTER all positioning so that snap/descender
488+
# re-scales do not alter the final stroke width.
489+
if _target_stroke is not None and _name not in _GREEK_NO_STROKE_NORM:
490+
_measured = _scan_stroke_width(_g, _target_top * 0.15, _target_top * 0.85)
491+
if _measured and _measured > 0:
492+
_delta = int(round(_target_stroke - _measured))
493+
if abs(_delta) > 3:
494+
_g.correctDirection()
495+
_g.addExtrema()
496+
_g.removeOverlap()
497+
_g.changeWeight(_delta)
498+
_bb2 = _g.boundingBox()
499+
if _bb2[3] > 0:
500+
_g.transform(psMat.scale(_target_top / _bb2[3]))
501+
_bb2 = _g.boundingBox()
502+
_g.transform(psMat.translate(-_bb2[0] + 20, 0))
503+
_g.width = int(round(_g.boundingBox()[2] + 20))
504+
# Per-letter weight nudge (applied last so it overrides normalisation).
505+
_nudge = _GREEK_WEIGHT_NUDGE.get(_name)
506+
if _nudge:
507+
_g.correctDirection()
508+
_g.addExtrema()
509+
_g.removeOverlap()
510+
_g.changeWeight(_nudge)
511+
_bb2 = _g.boundingBox()
512+
if _bb2[3] > 0:
513+
_g.transform(psMat.scale(_target_top / _bb2[3]))
514+
_bb2 = _g.boundingBox()
515+
_g.transform(psMat.translate(-_bb2[0] + 20, 0))
516+
_g.width = int(round(_g.boundingBox()[2] + 20))
517+
_ch = font.createMappedChar(_cp)
518+
_ch.clear()
519+
for c in _g.foreground:
520+
_ch.foreground += c
521+
_ch.width = _g.width
522+
523+
524+
# ß (U+00DF) and ẞ (U+1E9E) — hand-drawn source from extras/eszett.png.
525+
# The same SVG is imported twice at different scales for the two case forms.
526+
_eszett_svg = os.path.join(_COMIC_CHARS_DIR, 'eszett.svg')
527+
528+
_eszett_glyph = _import_comic_glyph(
529+
font, 'eszett', _eszett_svg,
530+
target_top=font['b'].boundingBox()[3] * 0.59,
531+
weight_delta=23)
532+
_bb = _eszett_glyph.boundingBox()
533+
if _bb[1] < 0:
534+
_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
535+
_ch = font.createMappedChar(0x00DF)
536+
_ch.clear()
537+
for c in _eszett_glyph.foreground:
538+
_ch.foreground += c
539+
_ch.width = _eszett_glyph.width
540+
541+
_cap_eszett_glyph = _import_comic_glyph(
542+
font, 'eszett_cap', _eszett_svg,
543+
target_top=font['B'].boundingBox()[3] * 0.72,
544+
weight_delta=19)
545+
_bb = _cap_eszett_glyph.boundingBox()
546+
if _bb[1] < 0:
547+
_cap_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
548+
_ch = font.createMappedChar(0x1E9E)
549+
_ch.clear()
550+
for c in _cap_eszett_glyph.foreground:
551+
_ch.foreground += c
552+
_ch.width = _cap_eszett_glyph.width
553+
554+
320555
# ---------------------------------------------------------------------------
321556
# Save
322557
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)