概要
個人用のElectronアプリ(Vite + React + TypeScript構成)でターミナルの設定画面にフォント選択UIが必要になった。静的な6フォントのselectボックスでは足りなくて、システムにインストールされた全フォントを列挙し、等幅フォントだけをフィルタリングして表示したいという要件。調べてみるとqueryLocalFonts()というChromium組み込みのWeb APIで、ネイティブモジュールなしに実現できた。そのまとめ。
既存Electronアプリの調査
まず、有名なオープンソースのElectronアプリがフォント選択をどう実装しているか調べた。
| アプリ | フォントピッカー | 方法 |
|---|---|---|
| VS Code | なし | テキスト入力のみ |
| Hyper | なし | 設定ファイル直接編集 |
| Joplin | なし | テキスト入力のみ |
| Tabby | あり | fontmanager-redux(C++ネイティブ) |
| Mark Text | あり | fontmanager-redux(同上) |
ほとんどのアプリにフォントピッカーが存在しない。VS Codeですらeditor.fontFamilyはテキスト入力で、フォント名を直接タイプする方式になっている。
フォントピッカーを持っているのはTabbyとMark Textの2つだけで、どちらもfontmanager-reduxというC++ネイティブアドオンを使っている。ネイティブモジュールはElectronのバージョンに合わせてリビルドが必要になるので、できれば避けたい。
queryLocalFonts()の発見
Obsidianのコミュニティプラグインであるobsidian-font-galleryがqueryLocalFonts() APIを使っていることを見つけた。これはChromium組み込みのLocal Font Access APIで、ブラウザからシステムフォントの情報にアクセスできる。
通常のWebブラウザだとユーザーに権限プロンプトが表示されるが、Electronではrendererプロセスで直接呼び出せて、権限プロンプトも出ない。ネイティブモジュールのビルドやメンテナンスも不要。ということでこれを採用した。
フォントの列挙
queryLocalFonts()はシステムにインストールされた全フォントの情報を返す。ただし返ってくるのはフォントフェイスの一覧なので、Regular、Bold、Italicなどが個別のエントリとして含まれている。フォントファミリー単位でユニークにする必要がある。
async function getSystemFonts(): Promise<FontInfo[]> {
const fonts = await window.queryLocalFonts();
const families = [...new Set(fonts.map((f) => f.family))].sort((a, b) =>
a.localeCompare(b),
);
// ...
}Setでユニーク化してlocaleCompareでソートするだけ。
等幅フォント検出: Canvas計測
ターミナル用のフォントピッカーなので、等幅(monospace)フォントだけをフィルタリングしたい。フォントのメタデータには等幅かどうかの情報がないので、実際に文字を描画して幅を比較する方法を使った。
function detectMonospace(
family: string,
ctx: CanvasRenderingContext2D,
): boolean {
ctx.font = `16px "${family}", sans-serif`;
const wideChar = ctx.measureText("W").width;
const narrowChar = ctx.measureText("i").width;
return Math.abs(wideChar - narrowChar) < 0.5;
}原理は単純で、等幅フォントでは「W」と「i」が同じ幅になる。プロポーショナルフォントでは「W」のほうが広い。measureText()で両方の幅を測って、差が0.5px未満なら等幅と判定している。
フォールバックフォント指定の注意点
最初の実装ではフォールバックにmonospaceを指定していた。
// これだと正しく判定できない
ctx.font = `16px "${family}", monospace`;この書き方だと、システムに存在しないフォント名を指定した場合にフォールバックのmonospaceで描画される。monospaceフォントなので「W」と「i」が同じ幅になり、等幅と誤判定される。
フォールバックをsans-serifに変更することで解決した。
// sans-serifなら誤判定しない
ctx.font = `16px "${family}", sans-serif`;存在しないフォントがフォールバックされた場合、sans-serifはプロポーショナルフォントなので「W」と「i」の幅に差が出る。正しく非等幅と判定される。
パフォーマンス: Canvasコンテキストの共有
システムにインストールされたフォントは500以上になることがある。最初の実装ではフォントごとにCanvas要素をdocument.createElement('canvas')で新規作成していた。
500回以上Canvas要素を作成・破棄するのは無駄なので、1つのCanvasコンテキストを全フォントで共有するように修正した。ctx.fontを変更するだけでフォントは切り替わるので、Canvas自体を再生成する必要がない。
セッションキャッシュとエラー時の扱い
フォントの列挙と等幅判定はそれなりに時間がかかるので、結果をセッション中キャッシュする。ただしエラー時のキャッシュには注意が必要。
エラーが発生したときに空配列をキャッシュしてしまうと、次回の呼び出しでもキャッシュから空配列が返り、永久にリトライできなくなる。エラー時はキャッシュに何も入れず、次回呼び出し時に再取得させる。
UIの選択: タイプアヘッド
TabbyとMark Textの両方がタイプアヘッド(テキスト入力でフィルタリングする)UIを採用している。500以上のフォントリストをドロップダウンで表示しても選びにくいので、テキスト入力でインクリメンタルに絞り込む方式が適切ということだろう。
<FontPicker
value={values.fontFamily}
onChange={(fontFamily) => onChange({ fontFamily })}
monoOnly
/>monoOnlyプロパティで等幅フォントのみの表示に切り替えられるようにした。ターミナル設定ではmonoOnlyをオンにして使う。
まとめ
queryLocalFonts()とCanvasのmeasureText()を組み合わせることで、ネイティブモジュールなしにシステムフォントピッカーを実装できた。Electronだと権限プロンプトなしで動作するので、ユーザー体験としてもスムーズになる。
等幅判定のCanvasフォールバックフォント指定(monospaceではなくsans-serifを使う)は地味だが、ここを間違えると全フォントが等幅と判定されてしまうので見落としやすい。