158 lines
8.2 KiB
Python
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()
|