forked from Yonokid/YataiDONNijiiro
158 lines
7.8 KiB
Python
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()
|