ハングルの書き取り練習のサービスでは、「画のデータ」を 4 か所で使っています。
| 使う場所 | 使い方 |
|---|---|
| アプリ(推し韓マル) | 書き順の再生・1 画ずつの判定 |
| Web の判定サーバー(Cloudflare Workers) | 1 画ずつの判定(正解の座標はサーバーの外に出さない) |
| Web の書き順動画 | 文字ごとに mp4 であらかじめ描いておく |
| Web の文字ページ 2,351 枚 | 画数・組み立ての表示 |
元になるのは、stroke-approved.json というファイルひとつです。どこか 1 か所だけが変わり、ほかがそのままだと、「再生はこの順番なのに、判定は別の順番」という事故が起きます。そこで元をひとつに決め、残りはすべてビルドで作るものにしました。1 画ずつ判定する仕組みそのものは、前回にまとめています。
保存の形は単純です。マスの 1 辺を 1000 とした整数の座標を、画ごとに書く向きの順に並べます。
{ "chars": { "하": [[x0, y0, x1, y1, ...], [...], [...]] } }
整数にしておくと JSON が小さくなり、比較やハッシュが安定し、マスの大きさが違っても / 1000 するだけで済みます。
データを作る:自動生成+人の検収+確定版を優先
自動生成
- 順番・向き・画数 = オープンソースの hangul-writer(MIT・51 字母・初声 → 母音 → パッチム)。音節は Unicode の分解で字母を求めます
- 線の位置 = 画面に実際に見える見本フォント(Gowun Dodum)の輪郭を画ごとに分け、その画の取り分の真ん中を通る線
音節から字母への分解は、Unicode の計算 1 行で済みます。
export function jamoOf(ch) {
const c = ch.codePointAt(0) - 0xac00;
if (c < 0 || c > 11171) return null;
const jong = JONG[c % 28];
return [CHO[Math.floor(c / 588)], JUNG[Math.floor((c % 588) / 28)], ...(jong ? [jong] : [])];
}
検収済みの確定版を、計算より優先する
自動生成の規則は、どうしても何度も直すことになります。規則を直すたびに、すでに人が目で確かめた文字まで変わってしまうと、検収がいつまでも終わりません。そこで、目での検収が終わった文字は確定版に保存し、ビルドは確定版にある文字を計算せず、そのまま使うようにしました。確定版にない文字だけを計算し、点検します。
自動ではうまくいかない文字は、手動の規則ファイルで直します。輪郭をどの字母に割り当てるか、どの画にどの輪郭を使うか、案内の線を直接指定するかを決めます。
ビルドは厳しく失敗させる
アプリに出てくる文字がひとつでも抜けたり、点検に引っかかったりしたら、ビルドを失敗させる(抜けたまま・間違ったまま出さないため)。
点検する項目は次のとおりです。
- 取り分のない画・持ち主のいないインク(フォントの輪郭のうち、どの画にも割り当てられていない部分)
- 形の違う画・向きの間違い
- 案内の線の長さ・見本の中に収まっているか
実際のビルドのログは、次のような形です。これはアプリに実際に出てくる文字を対象にしたログで、Web 全体の 2,351 字とは別の数え方です。
[strokes] src/content/stroke-paths.json · 1084 / 1084 字 (100 %) · 524 KB · 手動規則 14 字 · 検収確定版を使用 1084 字
[strokes] 点検通過 (取り分のない画 0 · 形の違う画 0 · 向きの間違い 0 · 持ち主のいないインク 0 · 案内の線の長さ · 見本の中 · 向き 通過)
書き順の動画 2,351 本をビルドで作る
Web には画の座標を出さないことにしました(データの保護のため)。その代わり、アプリの再生画面と同じ絵を、文字ごとに mp4 としてあらかじめ作っておきます。
- 描画 =
@resvg/resvg-js(SVG → RGBA) - 圧縮 =
ffmpeg(H.264・どのブラウザでも再生・faststart) - 並列 =
worker_threads
フレームをファイルに書かず、ffmpeg の標準入力に流し込む
const ff = spawn('ffmpeg', [
'-y', '-loglevel', 'error',
'-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${SIZE}x${SIZE}`, '-r', String(FPS), '-i', '-',
'-an', '-c:v', 'libx264', '-preset', 'slow', '-crf', String(CRF),
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', tmp,
], { stdio: ['pipe', 'ignore', 'pipe'] });
for (let f = 0; f < frames; f += 1) {
const svg = frameSvg(base, arrows, play, (f * 1000) / FPS);
// システムのフォントを読まない(絵に文字がない・1 枚ごとに読むと 10 倍遅くなる)
const rgba = new Resvg(svg, { fitTo: { mode: 'original' }, font: { loadSystemFonts: false } }).render().pixels;
if (!ff.stdin.write(rgba)) await new Promise((r) => ff.stdin.once('drain', r)); // 背圧の処理
}
ff.stdin.end();
await done;
renameSync(tmp, job.out); // 一時ファイル → 完成後に名前を変える(途中で落ちても壊れた mp4 が残らない)
「変わった文字だけ」作り直すための指紋
2,351 本を毎回作り直すと、時間がかかりすぎます。結果に影響するすべての入力をハッシュにまとめて記録しておき、違うときだけ作り直します。
const fontSig = createHash('sha256').update(readFileSync(fontPath)).digest('hex').slice(0, 16);
const settings = JSON.stringify({ RENDER_VERSION, SIZE, FPS, CRF, STROKE_MS, GAP_MS, HOLD_MS, FADE_MS,
GUIDE_RATIO, GUIDE_BASELINE, play, fontSig });
for (const [ch, strokes] of Object.entries(chars)) {
const hex = ch.codePointAt(0).toString(16);
const hash = createHash('sha256').update(settings).update(JSON.stringify(strokes)).digest('hex').slice(0, 16);
if (manifest[hex] === hash && existsSync(out)) continue; // 同じ入力 = 飛ばす
jobs.push({ ch, hex, hash, out, strokes });
}
ポイントは次のとおりです。
- フォントのファイルそのもののハッシュも入れる。フォントを替えただけでも、すべて作り直される
- 描くコードを変えるときのために
RENDER_VERSIONを置く。数字をひとつ上げれば、すべて作り直す - 進み具合は 50 本ごとに保存する。途中で止まっても、次の実行が続きから作る
スポンサーリンク
スポンサーリンク
手書きを筆ペンの線のように整える(判定とは切り離す)
指を離すと、0.18 秒でガタガタの線が整った筆ペンの線に変わります。気持ちのよい結果を見せるためのもので、判定には書いたままの元の点を送ります。整えた結果で判定すると、間違った画も正しく見えてしまうからです。
手順は次のとおりです。
- 同じ間隔で打ち直す(指の速さによる点の密度の差をなくす)
- 震えをならす(隣どうしの平均を 4 回)
- 折れる角で分ける(約 57° 以上)
- ほぼまっすぐな区間は直線にする。水平・垂直から ±9° 以内なら、ぴったり水平・垂直にそろえる
- 曲線の区間はもう一度なめらかにする → つなぎ合わせる
- 太さ:書き始めを少し太く、終わりの 30 % で細く抜く
export const BEAUTIFY_STEP = 0.0094; // 打ち直す間隔(マスに対して)
export const BEAUTIFY_SMOOTH = 4; // 震えをならす回数
export const BEAUTIFY_CORNER_ANGLE = 1.0; // 角の判定(ラジアン)
export const BEAUTIFY_STRAIGHT = 0.07; // まっすぐな区間の判定
export const BEAUTIFY_SNAP_DEG = 9; // 水平・垂直にそろえる範囲
export const BRUSH_HEAD = 1.15; // 書き始めの太さの倍率
export const BRUSH_TAIL_FROM = 0.7; // 細くなり始める地点
export const BRUSH_TAIL = 0.45; // 終わりの太さの倍率
すべての長さをマスの大きさに対する比率にしておいたおかげで、アプリ(React Native)と Web(ブラウザの JS)に同じ値をそのまま移し、同じ書き味を出せています。
元のデータを変えるときの手順(文書で固定)
- 確定版を直す → コンテンツのビルド(アプリのデータを作り直し・点検)→ 判定のテスト
- 動画を作る(変わった文字だけ)
- 判定サーバーのローカルでのテスト → 配信(Web の配信スクリプトが判定サーバーの応答を確かめるので、サーバーが先)
- Web の配信 → 本番のサイトで変わった文字を確かめる
- コミット(元のデータ+作ったもの)
- アプリは次のビルドに入れる
判定サーバーの作り方は、後の回で書きます。
つまずいたこと
- 見本の文字を画の領域で切り出して見せる方式は捨てた。最初は、フォントの文字を画ごとに切り出し、1 画ずつ見せようとしました。「같・놓・있」のように画がくっついた文字で、境目の推測が外れました。今は「出発点+細い線+終わりの矢じり」の案内の線の方式です
- 二重母音・二重パッチムは、部分に分けなければならない。ㅘ のような大きな箱が初声の輪郭まで持っていってしまい、과・왜・의・쉽・뭘・뷁 で組み合わせに失敗しました。ㅘ = ㅗ + ㅏ、ㄳ = ㄱ + ㅅ のように分けると解決しました
- 動画の位置のずれ。書くマス(正方形の SVG)より、マスの外枠のほうが広くなる画面(高さの足りない PC のウィンドウ)で、動画は外枠の真ん中に描かれ、見本は左に寄っていました。再生するときだけ文字が横にずれて見えたのです。マスを
width: fit-content; max-width: 100%; margin-inline: auto;で SVG と同じ正方形にそろえて解決しました - H.264 を再生できないブラウザもある。オープンソースの Chromium のビルドなどです。再生の前に
video.canPlayType()で H.264 の mp4 を再生できるかを確かめ、できなければ再生ボタンを隠します
スポンサーリンク
スポンサーリンク
この記事について
自社のハングル書き取り練習アプリ「推し韓マル」と、Web「iffthen hangul」の書き順データを作ったときの記録です。コードとログは実際のソースとビルドから抜き出し、コメントを読みやすく縮めています。動作を変える修正はしていません。
- 書き順の動画と判定を見てみる(無料):iffthen hangul
- アプリ:推し韓マル(App Store・Google Play)


コメント