Files
assaultcube-/source/dev_tools/gen_unicode_fonts.py
T
2026-10-25 08:51:39 +00:00

158 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
Appends extra glyphs (Latin-1, Latin Extended-A, Greek, Cyrillic, some punctuation) to the three built-in
AssaultCube font atlases and writes config/font_unicode.cfg with the matching "fontcharu" definitions.
Run from the AssaultCube root folder:
python3 source/dev_tools/gen_unicode_fonts.py [--fonts-dir /usr/share/fonts/truetype/dejavu]
Requires Pillow and the DejaVu fonts (any TTF with the needed coverage works, see FONTS below).
The ASCII part of every atlas is never touched: the script cuts each png back to its original size first, so
running it repeatedly is safe. The new glyphs are appended below the old ones; the image gets wider, which is fine
because all fontchar coordinates are pixel based.
"""
import argparse, math, os, re, sys
from PIL import Image, ImageDraw, ImageFont
# glyph sets: (first, last) inclusive
RANGES = [
(0x00A1, 0x00AC), (0x00AE, 0x00FF), # Latin-1 supplement (no NBSP, no soft hyphen)
(0x0100, 0x017F), # Latin Extended-A (Polish, Czech, Hungarian, Turkish, Baltic, ...)
(0x0386, 0x0386), (0x0388, 0x038A), (0x038C, 0x038C), (0x038E, 0x03A1), (0x03A3, 0x03CE), # Greek
(0x0400, 0x045F), (0x0490, 0x0491), # Cyrillic: Russian, Ukrainian, Belarusian, Serbian, Macedonian, ...
(0x2013, 0x2014), (0x2018, 0x201A), (0x201C, 0x201E), (0x2022, 0x2022), (0x2026, 0x2026),
(0x20AC, 0x20AC), (0x2116, 0x2116), # dashes, quotes, bullet, ellipsis, euro, numero
]
# the serif atlas is the largest per glyph, so it gets a smaller set (keeps the texture at 1024x2048)
RANGES_SMALL = [(0x00A1, 0x00AC), (0x00AE, 0x00FF), (0x0400, 0x045F), (0x0490, 0x0491),
(0x2013, 0x2014), (0x2018, 0x201A), (0x201C, 0x201E), (0x2022, 0x2022), (0x2026, 0x2026), (0x20AC, 0x20AC), (0x2116, 0x2116)]
FONTS = {
# name: (png, cfg, ttf, outline px, original png size)
'default': dict(png='chars_default.png', cfg='font_default.cfg', ttf='DejaVuSans-Bold.ttf', stroke=4, orig=(512, 512)),
'mono': dict(png='chars_monospace.png', cfg='font_monospace.cfg', ttf='DejaVuSansMono-Bold.ttf', stroke=0, orig=(512, 512)),
'serif': dict(png='chars_serif.png', cfg='font_serif.cfg', ttf='DejaVuSerif-Bold.ttf', stroke=0, orig=(690, 690), ranges=RANGES_SMALL),
}
NEWW = 1024 # width of the extended atlases (power of two; old glyph pixel coordinates stay valid)
def parse_cfg(path):
hdr, chars = None, []
for line in open(path, encoding='utf-8'):
line = line.split('//')[0].strip()
if line.startswith('font '):
t = line.replace('"', ' ').split()
nums = [int(v) for v in t[3:]]
nums += [0] * (7 - len(nums))
hdr = dict(name=t[1], png=t[2], dw=nums[0], dh=nums[1], ox=nums[2], oy=nums[3], ow=nums[4], oh=nums[5])
elif line.startswith('fontchar '):
v = [int(x) for x in line.split()[1:]]
v += [0] * (4 - len(v))
chars.append(v)
return hdr, chars
def alpha_bbox(im, thr=100):
return im.getchannel('A').point(lambda v: 255 if v > thr else 0).getbbox()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--fonts-dir', default='/usr/share/fonts/truetype/dejavu')
ap.add_argument('--root', default='.')
a = ap.parse_args()
out_cfg = ['// Extra glyphs for the built-in fonts: Latin-1, Latin Extended-A, Greek, Cyrillic, some punctuation.',
'// Generated by source/dev_tools/gen_unicode_fonts.py - do not edit by hand.',
'// "fontselect" makes an existing font the target of the following "fontcharu" commands (cp x y w h).', '']
for fname, fc in FONTS.items():
cfgp = os.path.join(a.root, 'config', fc['cfg'])
pngp = os.path.join(a.root, 'packages', 'misc', fc['png'])
hdr, chars = parse_cfg(cfgp)
cps = [c for lo, hi in fc.get('ranges', RANGES) for c in range(lo, hi + 1)]
atlas = Image.open(pngp)
mode = atlas.mode
atlas = atlas.crop((0, 0) + fc['orig']).convert('RGBA') # cut back to original -> idempotent
# reference glyph 'H' defines size and baseline
hx, hy, hw, hh = chars[ord('H') - 33]
hw = hw or hdr['dw']; hh = hh or hdr['dh']
crop = atlas.crop((hx + hdr['ox'], hy + hdr['oy'], hx + hw + hdr['ow'], hy + hh + hdr['oh']))
l, t, r, b = alpha_bbox(crop)
ref_h, ref_w = b - t, r - l
crop_base = crop.size[1] # crop height of a normal glyph
maxcrop = crop_base + 4 # glyphs with descenders may be a bit taller (like 'g' in the original)
pad_total = hw - ref_w
stroke = fc['stroke']
ttf = os.path.join(a.fonts_dir, fc['ttf'])
# fit font size so that the rendered 'H' (incl. outline) has the reference height
def render(ch, size, origin=(150, 250)):
f = ImageFont.truetype(ttf, size)
im = Image.new('RGBA', (400, 400), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
d.text(origin, ch, font=f, fill=(255, 255, 255, 255), stroke_width=stroke, stroke_fill=(0, 0, 0, 255), anchor='ls')
return im, alpha_bbox(im, 8), origin
size = 20
for s in range(20, 120):
_, bb, _ = render('H', s)
if bb and bb[3] - bb[1] >= ref_h: size = s; break
base_y = b - stroke # row of the baseline inside the crop
mono = hdr['dw'] and fname == 'mono'
print('%s: ref H ink %dx%d, crop %dx%d, ttf size %d, baseline row %d' % (fname, ref_w, ref_h, crop.size[0], crop_base, size, base_y))
glyphs = [] # (cp, image (cell crop), w, crop_h)
for cp in cps:
ch = chr(cp)
im, bb, org = render(ch, size)
if not bb: continue
ext_above, ext_below = org[1] - bb[1], bb[3] - org[1]
sc = 1.0
if ext_above > base_y - 1: sc = min(sc, (base_y - 1) / ext_above)
if ext_below > maxcrop - base_y - 1: sc = min(sc, (maxcrop - base_y - 1) / ext_below)
if sc < 1.0:
im, bb, org = render(ch, max(8, int(size * sc)))
ext_above, ext_below = org[1] - bb[1], bb[3] - org[1]
ink = im.crop((bb[0], 0, bb[2], im.size[1]))
ink_w = bb[2] - bb[0]
cell_w = hdr['dw'] if mono else ink_w + pad_total
if mono and ink_w > cell_w: # keep monospace cells fixed width
k = (cell_w - 1) / ink_w
ink = ink.resize((cell_w - 1, ink.size[1])); ink_w = cell_w - 1
crop_h = max(crop_base, min(maxcrop, base_y + ext_below + 1))
cell = Image.new('RGBA', (cell_w, crop_h), (0, 0, 0, 0))
px = (cell_w - ink_w) // 2
py = base_y - org[1] # paste so that baseline lands on base_y
cell.alpha_composite(ink, (px, py)) if py >= 0 else cell.alpha_composite(ink.crop((0, -py, ink_w, ink.size[1])), (px, 0))
glyphs.append((cp, cell, cell_w, crop_h))
# pack into rows below the original atlas
pitch = maxcrop + 4
x, row, placed = 1, 0, []
for cp, cell, w, ch_ in glyphs:
if x + w + 1 > NEWW: x, row = 1, row + 1
placed.append((cp, cell, x, row, w, ch_)); x += w + 1
top0 = fc['orig'][1]
if fname == 'serif': top0 = max(top0, 700)
need = top0 + (row + 1) * pitch
newh = 1 << max(8, math.ceil(math.log2(need)))
big = Image.new('RGBA', (NEWW, newh), (0, 0, 0, 0))
big.paste(atlas, (0, 0))
out_cfg += ['fontselect %s' % hdr['name']]
for cp, cell, cx, crow, w, ch_ in placed:
ry = top0 + crow * pitch
big.paste(cell, (cx, ry))
cfg_y = ry - hdr['oy']
cfg_h = ch_ - (hdr['oh'] - hdr['oy'])
out_cfg.append('fontcharu %d %d %d %d %d\t// U+%04X %s' % (cp, cx - hdr['ox'], cfg_y, w, cfg_h, cp, chr(cp)))
out_cfg.append('')
if mode == 'LA':
big = Image.merge('LA', (big.convert('L'), big.getchannel('A')))
big.save(pngp, optimize=True)
print(' wrote %s %dx%d, %d glyphs' % (pngp, NEWW, newh, len(placed)))
open(os.path.join(a.root, 'config', 'font_unicode.cfg'), 'w', encoding='utf-8').write('\n'.join(out_cfg))
print('wrote config/font_unicode.cfg')
if __name__ == '__main__':
main()