Skip to content
Merged
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.
428 changes: 397 additions & 31 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.
Binary file added xkcd-script/generator/extras/eszett.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions xkcd-script/generator/pt5_additional_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""
Extract hand-drawn glyphs from extras/ and convert them to SVG.

Outputs go to ../generated/additional_chars/ and are consumed by pt6_derived_chars.py.
"""
import os
import subprocess
import tempfile
import numpy as np
from PIL import Image
import fontforge

OUT_DIR = '../generated/additional_chars'
os.makedirs(OUT_DIR, exist_ok=True)

UPSAMPLE = 12 # upscale factor before potrace; higher = more curve detail
THRESHOLD = 160 # pixel value below which a pixel is considered ink


def _clean_potrace_svg(raw_svg_path, clean_svg_path):
"""Remove potrace artefacts from raw_svg_path and write clean_svg_path.

Potrace always emits a background rectangle covering the full canvas, plus
occasional single-pixel noise specks. We load the SVG into FontForge,
drop those contours, and re-export so that pt6 receives a file containing
only the actual ink outlines.

Filtering rules (applied in order):
1. Background rectangle: <= 12 control points AND spans > 80% of the
full bounding box in both axes.
2. Noise specks: bbox smaller than 10% of the remaining ink extent in
both axes simultaneously.
"""
scratch = fontforge.font()
g = scratch.createChar(-1, 'tmp')
g.importOutlines(raw_svg_path)

# Pass 1: drop background rectangle
full_bb = g.boundingBox()
full_w = full_bb[2] - full_bb[0]
full_h = full_bb[3] - full_bb[1]
pass1 = fontforge.layer()
for c in g.foreground:
cb = c.boundingBox()
span_w = (cb[2] - cb[0]) / full_w if full_w else 0
span_h = (cb[3] - cb[1]) / full_h if full_h else 0
if len(list(c)) <= 12 and span_w > 0.8 and span_h > 0.8:
continue
pass1 += c
g.foreground = pass1

# Pass 2: drop noise specks relative to ink extent
ink_bb = g.boundingBox()
ink_w = ink_bb[2] - ink_bb[0]
ink_h = ink_bb[3] - ink_bb[1]
ink = fontforge.layer()
for c in pass1:
cb = c.boundingBox()
if (cb[2] - cb[0]) < ink_w * 0.10 and (cb[3] - cb[1]) < ink_h * 0.10:
continue
ink += c
g.foreground = ink

scratch.save(clean_svg_path + '.sfd') # FontForge can't export single-glyph SVG directly
# Export via generate — write to a temp SFD then export the glyph as SVG
g.export(clean_svg_path)
os.remove(clean_svg_path + '.sfd')


def extract_symbol(arr, r0, r1, c0, c1, name):
"""Crop glyph region, upsample, binarise, run potrace, clean, save SVG."""
crop = arr[r0:r1, c0:c1]
big = Image.fromarray(crop).resize(
(crop.shape[1] * UPSAMPLE, crop.shape[0] * UPSAMPLE),
Image.BILINEAR)
binary = (np.array(big) >= THRESHOLD).astype(np.uint8) * 255

with tempfile.TemporaryDirectory() as tmp:
png_path = os.path.join(tmp, f'{name}.png')
pbm_path = os.path.join(tmp, f'{name}.pbm')
raw_svg = os.path.join(tmp, f'{name}_raw.svg')
Image.fromarray(binary, mode='L').save(png_path)
subprocess.check_call(['convert', png_path, '-threshold', '50%', pbm_path])
subprocess.check_call(['potrace', '-s', pbm_path, '-o', raw_svg])
svg_path = os.path.join(OUT_DIR, f'{name}.svg')
_clean_potrace_svg(raw_svg, svg_path)

print(f' wrote {svg_path}')
return svg_path


# ---------------------------------------------------------------------------
# Hand-drawn extras (generator/extras/*.png)
# Each file is a full-glyph image (no cropping needed). RGBA images are
# composited onto white before thresholding so transparent areas read as white.
# A lower upsample factor is used since these images are already high-res.
# ---------------------------------------------------------------------------

EXTRAS_DIR = 'extras'

EXTRAS = [
'eszett', # ß U+00DF / ẞ U+1E9E source
]

print('Extracting hand-drawn extras...')
for name in EXTRAS:
src_path = os.path.join(EXTRAS_DIR, f'{name}.png')
arr_extra = np.array(Image.open(src_path).convert('L'))
h, w = arr_extra.shape
extract_symbol(arr_extra, 0, h, 0, w, name)

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

Reads the base SFD produced by pt4_svg_to_font.py, adds derived glyphs, saves.
"""
import os
import fontforge
import psMat

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


# ---------------------------------------------------------------------------
# Glyphs imported from xkcd comic images
# ---------------------------------------------------------------------------

_COMIC_CHARS_DIR = os.path.join(os.path.dirname(__file__), '../generated/additional_chars')


def _import_comic_glyph(font, name, svg_path, target_top, weight_delta=0, y_clip=None):
"""Import a pre-cleaned SVG (from pt5_additional_sources.py) and scale it
so the top of the ink reaches target_top in font units, preserving the
aspect ratio so any descender falls naturally below baseline.

weight_delta: passed to changeWeight() after scaling (positive = thicker).
y_clip: if given, clip the glyph at this y-coordinate using FontForge's
intersect() against a background rectangle, removing everything below y_clip.
Use y_clip=0 to clip at the baseline (removes descenders, seats the glyph
on the baseline with a natural edge rather than a raw image crop).
"""
g = font.createChar(-1, f'_comic_{name}')
g.clear()
g.importOutlines(svg_path)

# Scale uniformly so the top of the ink reaches target_top
bb = g.boundingBox()
scale = target_top / bb[3]
g.transform(psMat.scale(scale))

# Translate to add left margin; vertical position follows naturally from scale
bb = g.boundingBox()
g.transform(psMat.translate(-bb[0] + 20, 0))

if weight_delta:
g.removeOverlap()
g.changeWeight(weight_delta)

if y_clip is not None:
# Clip at y_clip: keep only ink above y_clip.
# Place a filled rectangle covering [y_clip, +∞] in the background layer,
# then intersect() keeps only the foreground that overlaps that rectangle.
# FontForge uses clockwise winding for filled (outer) contours.
g.removeOverlap()
g.correctDirection()
clip_rect = fontforge.contour()
clip_rect += fontforge.point(-10000, 10000, True) # top-left
clip_rect += fontforge.point(10000, 10000, True) # top-right
clip_rect += fontforge.point(10000, y_clip, True) # bottom-right
clip_rect += fontforge.point(-10000, y_clip, True) # bottom-left
clip_rect.closed = True
bg = fontforge.layer()
bg += clip_rect
g.background = bg
g.intersect()

g.correctDirection()
g.removeOverlap()
g.addExtrema()

bb = g.boundingBox()
g.width = int(round(bb[2] + 20))
return g


# ß/ẞ source: hand-drawn glyph from extras/eszett.png, vectorised by pt0.
_eszett_svg = os.path.join(_COMIC_CHARS_DIR, 'eszett.svg')
if os.path.exists(_eszett_svg):
# ß U+00DF Latin Small Letter Sharp S — scaled to ascender height (like 'b')
_eszett_glyph = _import_comic_glyph(
font, 'eszett', _eszett_svg,
target_top=font['b'].boundingBox()[3] * 0.59,
weight_delta=23)
# Snap bottom to baseline so ß sits like a/e
_bb = _eszett_glyph.boundingBox()
if _bb[1] < 0:
_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
_eszett = font.createMappedChar(0x00DF)
_eszett.clear()
for c in _eszett_glyph.foreground:
_eszett.foreground += c
_eszett.width = _eszett_glyph.width

# ẞ U+1E9E Latin Capital Letter Sharp S — scaled to capital height (like 'B')
_cap_eszett_glyph = _import_comic_glyph(
font, 'eszett_cap', _eszett_svg,
target_top=font['B'].boundingBox()[3] * 0.72,
weight_delta=19)
_bb = _cap_eszett_glyph.boundingBox()
if _bb[1] < 0:
_cap_eszett_glyph.transform(psMat.translate(0, -_bb[1]))
_cap_glyph = font.createMappedChar(0x1E9E)
_cap_glyph.clear()
for c in _cap_eszett_glyph.foreground:
_cap_glyph.foreground += c
_cap_glyph.width = _cap_eszett_glyph.width


# ---------------------------------------------------------------------------
# Save
# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""
Apply font-wide properties: kerning, spacing, and any other metric tweaks.

Reads the SFD produced by pt5_derived_chars.py (which has all glyphs),
Reads the SFD produced by pt6_derived_chars.py (which has all glyphs),
applies properties, saves.
"""
import fontforge
Expand Down
7 changes: 4 additions & 3 deletions xkcd-script/generator/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ $RUN_CTXT python3 pt1_character_extraction.py
$RUN_CTXT python3 pt2_character_classification.py
$RUN_CTXT python3 pt3_ppm_to_svg.py
$RUN_CTXT python3 pt4_svg_to_font.py
$RUN_CTXT python3 pt5_derived_chars.py
$RUN_CTXT python3 pt6_font_properties.py
$RUN_CTXT python3 pt7_gen_reprod_font.py
$RUN_CTXT python3 pt5_additional_sources.py
$RUN_CTXT python3 pt6_derived_chars.py
$RUN_CTXT python3 pt7_font_properties.py
$RUN_CTXT python3 pt8_gen_reprod_font.py
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_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.
Loading