Files
YataiDONNijiiro/tools/font/build_font.py
T

158 lines
7.8 KiB
Python

"""Build a TrueType font from the cabinet's bitmap glyph atlases (font/<lang>_64.{xml,nutexb}).
The cabinet ships no vector font: each language has its own BC7 atlas + XML (codepoint ->
rect, no bearings, no kerning), and RenderText picks the atlas family by the cabinet
language (fontType 0 rows -> jp) and the size/weight bucket per text record. YataiDON
draws everything through one .ttf, so this merges the five 64pt atlases into one font:
every codepoint takes its glyph from the first atlas (in --order) that has ink for it.
Per atlas: scale = upm / fontPoint (jp_64: 16 units per pixel)
advance = glyph width * scale (A: 51 px -> 816, same as the earlier build)
baseline row = median bottom ink row of the Han/Hangul glyphs (+1), so the
different atlases sit on one baseline; Latin-only atlases use 'A'.
Tracing: alpha >= 128 at 4x bicubic upscale -> potrace -> cubic -> quadratic (cu2qu).
usage: python build_font.py <atlas_dir with *_64.dds/*_64.xml> <out.ttf> [--order jp,tw,cn,kr,en] [--jobs N]
"""
import sys, os, argparse, statistics, time
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image
import potrace
from multiprocessing import Pool
UPM = 1024
UPSCALE = 4
ASCENT, DESCENT = 880, -144
def parse_xml(path):
r = ET.parse(path).getroot().find('font')
glyphs = {int(x.get('index')): (int(x.get('offsetU')), int(x.get('offsetV')),
int(x.get('width')), int(x.get('height'))) for x in r.findall('glyph')}
return glyphs, {k: int(v) for k, v in r.attrib.items()}
_A = None
def _init(alpha_path):
global _A
_A = np.load(alpha_path, mmap_mode='r')
def _trace(job):
cp, u, v, w, h, scale, baseline_px = job
a = np.asarray(_A[v:v + h, u:u + w])
rows = np.where(a.max(axis=1) >= 128)[0]
if rows.size == 0:
return cp, int(round(w * scale)), None # blank: advance only
big = np.array(Image.fromarray(a).resize((w * UPSCALE, h * UPSCALE), Image.BICUBIC)) < 128 # potracer: True = white
path = potrace.Bitmap(big).trace(turdsize=4,
alphamax=1.0, opticurve=True, opttolerance=0.2)
k = scale / UPSCALE
def P(p): return (p.x * k, (baseline_px * UPSCALE - p.y) * k)
contours = []
for curve in path.curves:
segs = [('M', P(curve.start_point))]
for s in curve.segments:
if s.is_corner:
segs.append(('L', P(s.c))); segs.append(('L', P(s.end_point)))
else:
segs.append(('C', P(s.c1), P(s.c2), P(s.end_point)))
contours.append(segs)
return cp, int(round(w * scale)), contours
def main():
ap = argparse.ArgumentParser()
ap.add_argument('atlas_dir'); ap.add_argument('out')
ap.add_argument('--order', default='jp,tw,cn,kr,en')
ap.add_argument('--han-order', default='', help='separate priority for CJK ideographs (U+2E80-9FFF, F900-FAFF, 20000+); --order then applies to everything else')
ap.add_argument('--jobs', type=int, default=max(1, os.cpu_count() - 4))
ap.add_argument('--limit', type=int, default=0, help='debug: glyphs per atlas')
ap.add_argument('--embolden', default='', help='lang=px: dilate that atlas alpha by px before tracing')
args = ap.parse_args()
langs = args.order.split(',')
han_langs = args.han_order.split(',') if args.han_order else langs
def is_han(cp): return 0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or cp >= 0x20000
def rank(lang, cp): return (han_langs if is_han(cp) else langs).index(lang) if lang in (han_langs if is_han(cp) else langs) else 99
taken = {} # cp -> (advance, contours or None, lang)
all_langs = list(dict.fromkeys(langs + han_langs))
for lang in all_langs:
t0 = time.time()
glyphs, attr = parse_xml(os.path.join(args.atlas_dir, f'{lang}_64.xml'))
im = Image.open(os.path.join(args.atlas_dir, f'{lang}_64.dds'))
A = np.array(im)[:, :, 3]
emb = dict(kv.split('=') for kv in args.embolden.split(',') if kv)
if lang in emb and int(emb[lang]) > 0:
from scipy.ndimage import grey_dilation
k = 2 * int(emb[lang]) + 1; A = grey_dilation(A, size=(k, k))
scale = UPM / attr['fontPoint']
# baseline: where the Han / Hangul glyphs sit (Latin-only atlas: 'A')
bots = []
for cp, (u, v, w, h) in glyphs.items():
if 0x4E00 <= cp <= 0x9FFF or 0xAC00 <= cp <= 0xD7A3:
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
if r.size: bots.append(int(r.max()))
if bots:
baseline_px = statistics.median(bots) + 1
else:
u, v, w, h = glyphs[0x41]
r = np.where(A[v:v + h, u:u + w].max(axis=1) >= 128)[0]
baseline_px = int(r.max()) + 1 + 2 * 64 / attr['fontPoint'] # 'A' sits ~2 jp-px above the Han bottom
alpha_path = os.path.join(args.atlas_dir, f'_{lang}_alpha.npy')
np.save(alpha_path, A)
jobs = [(cp, u, v, w, h, scale, baseline_px) for cp, (u, v, w, h) in sorted(glyphs.items())
if (cp not in taken or taken[cp][1] is None and cp not in (0x20, 0x3000, 0xA0) or rank(lang, cp) < rank(taken[cp][2], cp))]
if args.limit: jobs = jobs[:args.limit]
got = 0
with Pool(args.jobs, initializer=_init, initargs=(alpha_path,)) as pool:
for cp, adv, contours in pool.imap_unordered(_trace, jobs, chunksize=64):
if contours is None:
if cp in (0x20, 0x3000, 0xA0) and cp not in taken:
taken[cp] = (adv, None, lang)
continue
if cp in taken and taken[cp][1] is not None and rank(lang, cp) >= rank(taken[cp][2], cp): continue
taken[cp] = (adv, contours, lang); got += 1
os.remove(alpha_path)
print(f'{lang}_64: fontPoint {attr["fontPoint"]} scale {scale:.2f} baseline {baseline_px:.1f}px '
f'-> {got} new glyphs ({time.time() - t0:.0f}s)', flush=True)
# ---- assemble
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.pens.cu2quPen import Cu2QuPen
order = ['.notdef'] + [(f'u{cp:05X}' if cp > 0xFFFF else f'uni{cp:04X}') for cp in sorted(taken)]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(order)
cmap, glyf, hmtx = {}, {}, {}
pen = TTGlyphPen(None); glyf['.notdef'] = pen.glyph(); hmtx['.notdef'] = (512, 0)
for name, cp in zip(order[1:], sorted(taken)):
adv, contours, _ = taken[cp]
pen = TTGlyphPen(None)
if contours:
qpen = Cu2QuPen(pen, max_err=1.0, reverse_direction=False)
for segs in contours:
for s in segs:
if s[0] == 'M': qpen.moveTo(s[1])
elif s[0] == 'L': qpen.lineTo(s[1])
else: qpen.curveTo(s[1], s[2], s[3])
qpen.closePath()
g = pen.glyph()
glyf[name] = g
g.recalcBounds(glyf) if contours else None
hmtx[name] = (adv, getattr(g, 'xMin', 0) if contours else 0)
cmap[cp] = name
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyf)
fb.setupHorizontalMetrics(hmtx)
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupNameTable({'familyName': 'YataiDON HSS Atlas', 'styleName': 'Regular',
'uniqueFontIdentifier': 'YataiDON HSS Atlas 3.000', 'fullName': 'YataiDON HSS Atlas Regular',
'version': 'Version 3.000', 'psName': 'YataiDONHSSAtlas-Regular'})
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT, sTypoLineGap=0,
usWinAscent=ASCENT, usWinDescent=-DESCENT, fsType=0)
fb.setupPost()
fb.save(args.out)
from collections import Counter
print('saved', args.out, len(order), 'glyphs;', dict(Counter(v[2] for v in taken.values())))
if __name__ == '__main__':
main()