Skip to content

Commit e2f9ac2

Browse files
committed
Add a new processing stage to inject glyphs from extra sources. Add a basic Eszett/ß glyph, which I produced manually by using the image from https://xkcd.com/2586/, pulling out the beta symbol, and removing the tail
1 parent b0cbe5e commit e2f9ac2

12 files changed

Lines changed: 537 additions & 34 deletions

xkcd-script/font/xkcd-script.otf

3.82 KB
Binary file not shown.

xkcd-script/font/xkcd-script.sfd

Lines changed: 324 additions & 30 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

1.91 KB
Binary file not shown.

xkcd-script/font/xkcd-script.woff

2.35 KB
Binary file not shown.
10 KB
Loading
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Extract hand-drawn glyphs from extras/ and convert them to SVG.
4+
5+
Outputs go to ../generated/additional_chars/ and are consumed by pt6_derived_chars.py.
6+
"""
7+
import os
8+
import subprocess
9+
import tempfile
10+
import numpy as np
11+
from PIL import Image
12+
import fontforge
13+
14+
OUT_DIR = '../generated/additional_chars'
15+
os.makedirs(OUT_DIR, exist_ok=True)
16+
17+
UPSAMPLE = 12 # upscale factor before potrace; higher = more curve detail
18+
THRESHOLD = 160 # pixel value below which a pixel is considered ink
19+
20+
21+
def _clean_potrace_svg(raw_svg_path, clean_svg_path):
22+
"""Remove potrace artefacts from raw_svg_path and write clean_svg_path.
23+
24+
Potrace always emits a background rectangle covering the full canvas, plus
25+
occasional single-pixel noise specks. We load the SVG into FontForge,
26+
drop those contours, and re-export so that pt6 receives a file containing
27+
only the actual ink outlines.
28+
29+
Filtering rules (applied in order):
30+
1. Background rectangle: <= 12 control points AND spans > 80% of the
31+
full bounding box in both axes.
32+
2. Noise specks: bbox smaller than 10% of the remaining ink extent in
33+
both axes simultaneously.
34+
"""
35+
scratch = fontforge.font()
36+
g = scratch.createChar(-1, 'tmp')
37+
g.importOutlines(raw_svg_path)
38+
39+
# Pass 1: drop background rectangle
40+
full_bb = g.boundingBox()
41+
full_w = full_bb[2] - full_bb[0]
42+
full_h = full_bb[3] - full_bb[1]
43+
pass1 = fontforge.layer()
44+
for c in g.foreground:
45+
cb = c.boundingBox()
46+
span_w = (cb[2] - cb[0]) / full_w if full_w else 0
47+
span_h = (cb[3] - cb[1]) / full_h if full_h else 0
48+
if len(list(c)) <= 12 and span_w > 0.8 and span_h > 0.8:
49+
continue
50+
pass1 += c
51+
g.foreground = pass1
52+
53+
# Pass 2: drop noise specks relative to ink extent
54+
ink_bb = g.boundingBox()
55+
ink_w = ink_bb[2] - ink_bb[0]
56+
ink_h = ink_bb[3] - ink_bb[1]
57+
ink = fontforge.layer()
58+
for c in pass1:
59+
cb = c.boundingBox()
60+
if (cb[2] - cb[0]) < ink_w * 0.10 and (cb[3] - cb[1]) < ink_h * 0.10:
61+
continue
62+
ink += c
63+
g.foreground = ink
64+
65+
scratch.save(clean_svg_path + '.sfd') # FontForge can't export single-glyph SVG directly
66+
# Export via generate — write to a temp SFD then export the glyph as SVG
67+
g.export(clean_svg_path)
68+
os.remove(clean_svg_path + '.sfd')
69+
70+
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]
74+
big = Image.fromarray(crop).resize(
75+
(crop.shape[1] * UPSAMPLE, crop.shape[0] * UPSAMPLE),
76+
Image.BILINEAR)
77+
binary = (np.array(big) >= THRESHOLD).astype(np.uint8) * 255
78+
79+
with tempfile.TemporaryDirectory() as tmp:
80+
png_path = os.path.join(tmp, f'{name}.png')
81+
pbm_path = os.path.join(tmp, f'{name}.pbm')
82+
raw_svg = os.path.join(tmp, f'{name}_raw.svg')
83+
Image.fromarray(binary, mode='L').save(png_path)
84+
subprocess.check_call(['convert', png_path, '-threshold', '50%', pbm_path])
85+
subprocess.check_call(['potrace', '-s', pbm_path, '-o', raw_svg])
86+
svg_path = os.path.join(OUT_DIR, f'{name}.svg')
87+
_clean_potrace_svg(raw_svg, svg_path)
88+
89+
print(f' wrote {svg_path}')
90+
return svg_path
91+
92+
93+
# ---------------------------------------------------------------------------
94+
# Hand-drawn extras (generator/extras/*.png)
95+
# Each file is a full-glyph image (no cropping needed). RGBA images are
96+
# composited onto white before thresholding so transparent areas read as white.
97+
# A lower upsample factor is used since these images are already high-res.
98+
# ---------------------------------------------------------------------------
99+
100+
EXTRAS_DIR = 'extras'
101+
102+
EXTRAS = [
103+
'eszett', # ß U+00DF / ẞ U+1E9E source
104+
]
105+
106+
print('Extracting hand-drawn extras...')
107+
for name in EXTRAS:
108+
src_path = os.path.join(EXTRAS_DIR, f'{name}.png')
109+
arr_extra = np.array(Image.open(src_path).convert('L'))
110+
h, w = arr_extra.shape
111+
extract_symbol(arr_extra, 0, h, 0, w, name)
112+

xkcd-script/generator/pt5_derived_chars.py renamed to xkcd-script/generator/pt6_derived_chars.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
Reads the base SFD produced by pt4_svg_to_font.py, adds derived glyphs, saves.
77
"""
8+
import os
89
import fontforge
910
import psMat
1011

@@ -351,6 +352,101 @@ def _accented(cp, base_name, mark_name, gap=20, x_adj=0):
351352
_accented(cp, base, '_macron_mark')
352353

353354

355+
# ---------------------------------------------------------------------------
356+
# Glyphs imported from xkcd comic images
357+
# ---------------------------------------------------------------------------
358+
359+
_COMIC_CHARS_DIR = os.path.join(os.path.dirname(__file__), '../generated/additional_chars')
360+
361+
362+
def _import_comic_glyph(font, name, svg_path, target_top, weight_delta=0, y_clip=None):
363+
"""Import a pre-cleaned SVG (from pt5_additional_sources.py) and scale it
364+
so the top of the ink reaches target_top in font units, preserving the
365+
aspect ratio so any descender falls naturally below baseline.
366+
367+
weight_delta: passed to changeWeight() after scaling (positive = thicker).
368+
y_clip: if given, clip the glyph at this y-coordinate using FontForge's
369+
intersect() against a background rectangle, removing everything below y_clip.
370+
Use y_clip=0 to clip at the baseline (removes descenders, seats the glyph
371+
on the baseline with a natural edge rather than a raw image crop).
372+
"""
373+
g = font.createChar(-1, f'_comic_{name}')
374+
g.clear()
375+
g.importOutlines(svg_path)
376+
377+
# Scale uniformly so the top of the ink reaches target_top
378+
bb = g.boundingBox()
379+
scale = target_top / bb[3]
380+
g.transform(psMat.scale(scale))
381+
382+
# Translate to add left margin; vertical position follows naturally from scale
383+
bb = g.boundingBox()
384+
g.transform(psMat.translate(-bb[0] + 20, 0))
385+
386+
if weight_delta:
387+
g.removeOverlap()
388+
g.changeWeight(weight_delta)
389+
390+
if y_clip is not None:
391+
# Clip at y_clip: keep only ink above y_clip.
392+
# Place a filled rectangle covering [y_clip, +∞] in the background layer,
393+
# then intersect() keeps only the foreground that overlaps that rectangle.
394+
# FontForge uses clockwise winding for filled (outer) contours.
395+
g.removeOverlap()
396+
g.correctDirection()
397+
clip_rect = fontforge.contour()
398+
clip_rect += fontforge.point(-10000, 10000, True) # top-left
399+
clip_rect += fontforge.point(10000, 10000, True) # top-right
400+
clip_rect += fontforge.point(10000, y_clip, True) # bottom-right
401+
clip_rect += fontforge.point(-10000, y_clip, True) # bottom-left
402+
clip_rect.closed = True
403+
bg = fontforge.layer()
404+
bg += clip_rect
405+
g.background = bg
406+
g.intersect()
407+
408+
g.correctDirection()
409+
g.removeOverlap()
410+
g.addExtrema()
411+
412+
bb = g.boundingBox()
413+
g.width = int(round(bb[2] + 20))
414+
return g
415+
416+
417+
# ß/ẞ source: hand-drawn glyph from extras/eszett.png, vectorised by pt0.
418+
_eszett_svg = os.path.join(_COMIC_CHARS_DIR, 'eszett.svg')
419+
if os.path.exists(_eszett_svg):
420+
# ß U+00DF Latin Small Letter Sharp S — scaled to ascender height (like 'b')
421+
_eszett_glyph = _import_comic_glyph(
422+
font, 'eszett', _eszett_svg,
423+
target_top=font['b'].boundingBox()[3] * 0.65,
424+
weight_delta=15)
425+
# Snap bottom to baseline so ß sits like a/e
426+
_bb = _eszett_glyph.boundingBox()
427+
if _bb[1] < 0:
428+
_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
429+
_eszett = font.createMappedChar(0x00DF)
430+
_eszett.clear()
431+
for c in _eszett_glyph.foreground:
432+
_eszett.foreground += c
433+
_eszett.width = _eszett_glyph.width
434+
435+
# ẞ U+1E9E Latin Capital Letter Sharp S — scaled to capital height (like 'B')
436+
_cap_eszett_glyph = _import_comic_glyph(
437+
font, 'eszett_cap', _eszett_svg,
438+
target_top=font['B'].boundingBox()[3] * 0.75,
439+
weight_delta=15)
440+
_bb = _cap_eszett_glyph.boundingBox()
441+
if _bb[1] < 0:
442+
_cap_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
443+
_cap_glyph = font.createMappedChar(0x1E9E)
444+
_cap_glyph.clear()
445+
for c in _cap_eszett_glyph.foreground:
446+
_cap_glyph.foreground += c
447+
_cap_glyph.width = _cap_eszett_glyph.width
448+
449+
354450
# ---------------------------------------------------------------------------
355451
# Save
356452
# ---------------------------------------------------------------------------

xkcd-script/generator/pt6_font_properties.py renamed to xkcd-script/generator/pt7_font_properties.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"""
33
Apply font-wide properties: kerning, spacing, and any other metric tweaks.
44
5-
Reads the SFD produced by pt5_derived_chars.py (which has all glyphs),
5+
Reads the SFD produced by pt6_derived_chars.py (which has all glyphs),
66
applies properties, saves.
77
"""
88
import fontforge
File renamed without changes.

xkcd-script/generator/run.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ $RUN_CTXT python3 pt1_character_extraction.py
1212
$RUN_CTXT python3 pt2_character_classification.py
1313
$RUN_CTXT python3 pt3_ppm_to_svg.py
1414
$RUN_CTXT python3 pt4_svg_to_font.py
15-
$RUN_CTXT python3 pt5_derived_chars.py
16-
$RUN_CTXT python3 pt6_font_properties.py
17-
$RUN_CTXT python3 pt7_gen_reprod_font.py
15+
$RUN_CTXT python3 pt5_additional_sources.py
16+
$RUN_CTXT python3 pt6_derived_chars.py
17+
$RUN_CTXT python3 pt7_font_properties.py
18+
$RUN_CTXT python3 pt8_gen_reprod_font.py

0 commit comments

Comments
 (0)