zh is Simplified Chinese: convert the zh strings, translate the last Japanese-only labels #34
No files matched your search
|
After Width: | Height: | Size: 4.1 KiB |
@@ -128,5 +128,25 @@
|
||||
"rank_plate": {
|
||||
"x": 64,
|
||||
"y": 57
|
||||
},
|
||||
"unreached_ja": {
|
||||
"x": 1252,
|
||||
"y": 171
|
||||
},
|
||||
"unreached_en": {
|
||||
"x": 1252,
|
||||
"y": 171
|
||||
},
|
||||
"unreached_ko": {
|
||||
"x": 1252,
|
||||
"y": 171
|
||||
},
|
||||
"unreached_zh": {
|
||||
"x": 1252,
|
||||
"y": 171
|
||||
},
|
||||
"nosave": {
|
||||
"x": 1344,
|
||||
"y": 22
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,31 @@
|
||||
{
|
||||
"transition_hint_title": {
|
||||
"font_size": 32,
|
||||
"text": {
|
||||
"ja": "ゲームのヒント",
|
||||
"en": "Hints",
|
||||
"zh": "游戏提示",
|
||||
"ko": "게임의 힌트"
|
||||
}
|
||||
},
|
||||
"transition_hint_line1": {
|
||||
"font_size": 26,
|
||||
"text": {
|
||||
"ja": "太鼓の面とフチをたたいてリズムにのろう!",
|
||||
"en": "Hit the drum face and rim and ride the rhythm!",
|
||||
"zh": "敲击鼓面和鼓边,跟上节奏吧!",
|
||||
"ko": "북면과 테두리를 두드려 리듬을 타자!"
|
||||
}
|
||||
},
|
||||
"transition_hint_line2": {
|
||||
"font_size": 26,
|
||||
"text": {
|
||||
"ja": "黄色い連打音符はたたいた数だけ得点アップ!",
|
||||
"en": "Yellow drumroll notes: every hit adds to your score!",
|
||||
"zh": "黄色连打音符敲得越多得分越高!",
|
||||
"ko": "노란 연타 음표는 두드린 만큼 점수 UP!"
|
||||
}
|
||||
},
|
||||
"screen": {
|
||||
"parent": "PyTaikoGreen",
|
||||
"width": 1920,
|
||||
@@ -18,7 +45,8 @@
|
||||
"costume_preset_slots": true,
|
||||
"dan_title_horizontal": true,
|
||||
"combo_color_tiers": true,
|
||||
"gauge_cell_fade_in": true
|
||||
"gauge_cell_fade_in": true,
|
||||
"dan_exam_grid": true
|
||||
}
|
||||
},
|
||||
"score_counter_pos": {
|
||||
@@ -873,8 +901,8 @@
|
||||
"height": 132
|
||||
},
|
||||
"dan_exam_seg": {
|
||||
"x": 14,
|
||||
"y": 48,
|
||||
"x": 11,
|
||||
"y": 47,
|
||||
"width": 327
|
||||
},
|
||||
"dan_select_exam_border_text": {
|
||||
@@ -940,6 +968,17 @@
|
||||
"ko": "%s 미만"
|
||||
}
|
||||
},
|
||||
"dan_result_nosave": {
|
||||
"text": {
|
||||
"ja": "今回の挑戦は記録されません",
|
||||
"en": "This attempt will not be recorded",
|
||||
"zh": "本次挑戰不會被記錄",
|
||||
"ko": "이번 도전은 기록되지 않습니다"
|
||||
}
|
||||
},
|
||||
"dan_result_nosave_text": {
|
||||
"font_size": 24
|
||||
},
|
||||
"dan_exam_text_gauge": {
|
||||
"text": {
|
||||
"ja": "%s %以上",
|
||||
|
||||
@@ -153,6 +153,16 @@ function SongTransition.new(title, subtitle, is_second)
|
||||
self.chara_l = tex.load_texture("global/rainbow_transition/chara_left")
|
||||
self.chara_r = tex.load_texture("global/rainbow_transition/chara_right")
|
||||
self.hint = tex.load_texture("global/rainbow_transition/chara_center")
|
||||
-- The hint block is baked in Japanese. Any other interface language draws the
|
||||
-- three lines as text (skin_config transition_hint_title / _line1 / _line2) at the
|
||||
-- rows measured off the texture, on the same fade.
|
||||
local lang = tex.language and tex.language() or "ja"
|
||||
if lang ~= "ja" and text and text.create_text then
|
||||
local white, black = { 255, 255, 255, 255 }, { 0, 0, 0, 255 }
|
||||
self.hint_title = text.create_text("transition_hint_title", white, black, false, 4.0, 2.0)
|
||||
self.hint_line1 = text.create_text("transition_hint_line1", white, black, false, 3.0, 2.0)
|
||||
self.hint_line2 = text.create_text("transition_hint_line2", white, black, false, 3.0, 2.0)
|
||||
end
|
||||
-- 演奏スキップON badge (the reference build loading/loading_song.nulm, mc `enso_skip_on`)
|
||||
self.skip_badge = tex.load_texture("loading/skip_badge/badge")
|
||||
-- NOT latched here: GameScreen::on_screen_start builds the transition
|
||||
@@ -276,8 +286,19 @@ function SongTransition:draw_bg(total_offset, rainbow_up, mini_up, chara_down)
|
||||
put(self.atlas_b, ORIGIN, 960 - bw * 0.5, 400 - bh * 0.5, bw, bh, info, SRC_BAND)
|
||||
-- 6. the hint block (「ゲームのヒント」 + 3 lines) on the same curve: the
|
||||
-- arcade turns every text field on at `stop` and off at `out`.
|
||||
if self.hint_title then
|
||||
-- texture rows (alpha > 200, outline included): title 165..202, line 1 228..257,
|
||||
-- line 2 282..310, all centred on x = 960; the texture sits at y = 568.
|
||||
local function centred(t, y)
|
||||
if t then t:draw({ x = math.floor(960 - t.width / 2 + 0.5), y = y, fade = info }) end
|
||||
end
|
||||
centred(self.hint_title, 568 + 165)
|
||||
centred(self.hint_line1, 568 + 228)
|
||||
centred(self.hint_line2, 568 + 282)
|
||||
else
|
||||
put(self.hint, REST_HINT, 0, 568, 1920, 512, info)
|
||||
end
|
||||
end
|
||||
|
||||
-- 7. 演奏スキップON badge, top-left, on the same fade as the band. The arcade
|
||||
-- shows it ONLY on the song-loading screens (loading/loading_song,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# `Graphics/font.ttf` — how it is made, and how the cabinet actually uses its fonts
|
||||
|
||||
## What the cabinet ships
|
||||
|
||||
The cabinet has **no vector font**. `Data\x64\font\` holds pre-rasterised BC7 glyph atlases,
|
||||
one family per language and one atlas per size/weight bucket, each with an XML sidecar
|
||||
(`codepoint → rect`; no bearings, no kerning):
|
||||
|
||||
| language | buckets on disk | distinct codepoints | face (as drawn in the atlas) |
|
||||
|---|---|---|---|
|
||||
| `jp` | `30`, `32_B`, `32_DB`, `32_EB`, `64` | 7 532 | 30/32: rounded gothic in three weights; 64: brush/勘亭流-style display face |
|
||||
| `tw` | `30`, `32`, `64` | 17 678 | same split |
|
||||
| `cn` | `30`, `32`, `64` | 7 608 | same split (the 64 face is a heavier brush) |
|
||||
| `kr` | `30`, `32`, `64` | 8 407 | same split |
|
||||
| `en` | `30`, `32`, `64` | 299 | same split |
|
||||
|
||||
(The JP 39.06 dump has the same files minus `cn_*`; the CN 05 dump adds `cn_*`. The
|
||||
`jp/tw/kr/en` XMLs are byte-identical between the two.)
|
||||
|
||||
## How a text record picks its atlas
|
||||
|
||||
* **Language** comes from the cabinet language setting: wordlist rows are looked up in the
|
||||
language's column and rendered from that language's atlas family. Rows flagged
|
||||
`fontType 0` (untranslated) always render from `jp`.
|
||||
* **Size/weight bucket** is authored per text record in the `.nulm` (`DefineFont` entry
|
||||
symbol `"0".."4"` → `64`, `30`, `32_EB`, `32_B`, `32_DB`). It is not language dependent.
|
||||
For `en/tw/kr/cn` the three weighted `32_*` buckets are registered onto the plain `32`
|
||||
atlas, so a record that asks for `32_EB` on a `cn` cabinet is drawn from `cn_32`.
|
||||
* The only fallback is `jp_64` when the language itself is unknown; a codepoint missing from
|
||||
the chosen atlas is simply not drawn. Of the 692 text records in the shipped movies, 449
|
||||
use the 30/32 (rounded gothic) buckets and 168 use the 64 (brush) bucket.
|
||||
|
||||
YataiDON draws every string through a single `.ttf`, so one face has to serve all of it.
|
||||
This skin's `font.ttf` is built from the **`_64` (brush) atlases**, which is the face used for
|
||||
headings, song titles and most of the large text.
|
||||
|
||||
## Building `font.ttf`
|
||||
|
||||
`tools/font/build_font.py` merges the five `_64` atlases into one TrueType font:
|
||||
|
||||
1. Decode each `<lang>_64.nutexb` to a DDS (BC7, `nutexb_dump -o`) next to its XML.
|
||||
2. For each language in priority order `jp, tw, cn, kr, en`, every codepoint that does not
|
||||
yet have an inked glyph takes its bitmap from that atlas (blank placeholder cells are
|
||||
skipped so a later atlas can supply the character).
|
||||
3. Each bitmap is upscaled 4× (bicubic), thresholded at alpha ≥ 128, traced with potrace
|
||||
and converted to quadratic outlines (`cu2qu`).
|
||||
4. Metrics: `unitsPerEm 1024`, `scale = 1024 / fontPoint` of the atlas (16 units per pixel
|
||||
for `jp_64`), `advance = cell width × scale`, baseline = the atlas's median bottom ink row
|
||||
of its Han/Hangul glyphs, so the five families sit on one baseline with equal Han height.
|
||||
`ascender 880 / descender −144`, as the earlier build.
|
||||
|
||||
```
|
||||
pip install potracer fonttools pillow numpy
|
||||
python build_font.py <dir with *_64.dds + *_64.xml> font.ttf --order jp,tw,cn,kr,en
|
||||
```
|
||||
|
||||
Result (version 3.000): 22 382 glyphs — 7 499 from `jp`, 10 107 from `tw`, 1 956 from `cn`,
|
||||
2 737 from `kr`, 82 from `en`. Coverage of each atlas's repertoire: jp 7521/7532,
|
||||
tw 17651/17678, cn 7589/7608, kr 8070/8407, en 268/295 (the remainder are blank cells in
|
||||
the atlases themselves). Version 2.000 had no `cn` or `kr` glyphs, which is why Simplified
|
||||
Chinese and Korean text showed `?`.
|
||||
|
||||
## Per-language fonts (`Graphics/font_<family>.ttf`)
|
||||
|
||||
The engine loads `Graphics/font_<language code>.ttf` if present, else `Graphics/font_<cabinet family>.ttf`
|
||||
(zh → cn, ko → kr, ja → jp), else `font.ttf`. The settings screen reloads the skin, so switching
|
||||
language switches the font. This mirrors the cabinet, which draws everything from the language's own
|
||||
atlas family and only lacks what that family does not have.
|
||||
|
||||
| file | atlas priority | used for |
|
||||
|---|---|---|
|
||||
| `font.ttf` | `jp, tw, cn, kr, en` | Japanese, and any language without its own file (unchanged 3.000) |
|
||||
| `font_cn.ttf` | `cn, tw, jp, en, kr` | Simplified Chinese (`zh`) |
|
||||
| `font_tw.ttf` | `tw, jp, cn, en, kr` | Traditional Chinese (`zh_tw` / `tw`) |
|
||||
| `font_en.ttf` | `en, jp, tw, cn, kr` | English |
|
||||
| `font_kr.ttf` | `kr, jp, en, tw, cn` | Korean (`ko`) |
|
||||
|
||||
```
|
||||
python build_font.py <dir> font_cn.ttf --order cn,tw,jp,en,kr
|
||||
python build_font.py <dir> font_tw.ttf --order tw,jp,cn,en,kr
|
||||
python build_font.py <dir> font_en.ttf --order en,jp,tw,cn,kr
|
||||
python build_font.py <dir> font_kr.ttf --order kr,jp,en,tw,cn
|
||||
```
|
||||
@@ -0,0 +1,157 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""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.5)
|
||||
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('--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('--files', default='', help='lang=atlas stem overrides, e.g. jp=jp_32_EB,tw=tw_32')
|
||||
ap.add_argument('--embolden', default='', help='lang=px: dilate that atlas alpha by px before tracing')
|
||||
args = ap.parse_args()
|
||||
langs = args.order.split(',')
|
||||
stems = dict(kv.split('=') for kv in args.files.split(',') if kv)
|
||||
|
||||
taken = {} # cp -> (advance, contours or None, lang)
|
||||
for lang in langs:
|
||||
t0 = time.time()
|
||||
stem = stems.get(lang, f'{lang}_64')
|
||||
glyphs, attr = parse_xml(os.path.join(args.atlas_dir, f'{stem}.xml'))
|
||||
im = Image.open(os.path.join(args.atlas_dir, f'{stem}.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)]
|
||||
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: continue
|
||||
taken[cp] = (adv, contours, lang); got += 1
|
||||
os.remove(alpha_path)
|
||||
print(f'{stem}: 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=2.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': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas'), 'styleName': 'Regular',
|
||||
'uniqueFontIdentifier': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas') + ' 3.000', 'fullName': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas') + ' Regular',
|
||||
'version': 'Version 3.000', 'psName': os.environ.get('FONT_FAMILY', 'YataiDON HSS Atlas').replace(' ', '') + '-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()
|
||||