Post
JA EN

拡張機能から拡張機能へフォントを渡す——VS Code に公式機構がない場所で規約を作り、全角記号の幅を実測で詰め直す

拡張機能から拡張機能へフォントを渡す——VS Code に公式機構がない場所で規約を作り、全角記号の幅を実測で詰め直す
  • 想定読者: VS Code 拡張を書く開発者、フォントの改変・再配布のライセンス実務に関心がある人
  • 前提知識: VS Code の webview、CSS の @font-face、フォントの advance / ink の区別(本文で補足する)
  • 所要時間: 約20分

本記事の位置づけ: 筆者が開発・公開している VS Code 拡張「Tmux Opener」と、それにフォントを供給するフォント拡張5本の実装記録である。使い方や導入手順はこの記事では扱わない(それは姉妹記事「VS Code のサイドバーに tmux を常駐させる」の担当で、ターミナルの日本語表示を直したいだけならそちらを読んでほしい)。ここから先は規約と変換の設計の話になる。数値は原則としてこのリポジトリで実測したもので、外部の挙動(xterm.js のソース、Unicode の規定、ライセンス条項)は公開ソースを引用する。汎用のベストプラクティスではなく、公式機構が無い場所で規約を作るとどこが壊れるかの一例として読んでほしい。

概要

VS Code の webview で任意のフォントを使いたい。素直な方法は、フォントファイルを自分の拡張に同梱して @font-face を書くことだ。では、そのフォントを別の拡張から借りたい場合はどうするか。ライセンスの都合で書体ごとにパッケージを分けたい、あるいは数 MB の TTF を本体のリリースに含めたくない、という動機は普通にある。

ここで詰まる。拡張機能どうしでフォントを共有する公式の仕組みは VS Code に無い1。フォントファイルを参照する貢献点(contribution point)自体は存在するが(contributes.iconscontributes.productIconThemes)、どちらも VS Code 自身が消費する単色グリフのアイコン用で、他の拡張に本文用のフォントを供給する機構ではない。webview が触れるフォント関連の値も --vscode-editor-font-family のような CSS 変数、つまりエディタ設定の反映に限られる。

そこで、フォントを同梱するだけの拡張(UI もコマンドも持たない)に package.json の最上位で providedFont という自作フィールドを宣言させ、消費側の拡張が extensions.getExtension() からそれを読んで @font-face を組む、という非公式な規約を書いた。書いてみると、危ないのはフォントの読み込みそのものではなく、規約の両側が食い違ったときに何も起きないことだった。不一致が無症状な箇所は、機械検査を置かないと静かに壊れる。

もうひとつの問題は幅だ。xterm.js は「1セル幅」と数えた文字の輪郭がセル幅の1.5倍(切り上げ)を超えたとき、横方向だけを縮める2。日本語フォントの のような曖昧幅3の記号はこれに引っかかり、平たく潰れる。そこで元のフォントを変換した。輪郭の幅を閾値の下まで縮め、それで失った高さを縦方向だけ伸ばして取り戻し、升目の幅そのものは動かさないまま、対象グリフの送り幅(advance)を1セル分に揃える。伸ばしすぎの上限は好みで決めず、そのフォント自身の「漢」の輪郭の上下端から実測している。

この記事では、規約の設計で無症状になる箇所、静的なレジストリを維持した判断、幅の変換で何を測ったか、そして改変版フォントの命名がライセンスに縛られる話を扱う。

前提: 端末の升目、advance、ink

話を進める前に3つの言葉を揃える。

升目(セル) は端末が文字を置く格子で、幅は「A」の送り幅で決まる。advance(送り幅) はその文字を描いた後にカーソルが進む距離。ink は輪郭が実際に塗る範囲で、advance とは独立している。ink は advance より狭くも広くもなれる(以下、ink は「輪郭」と書く)。

ここで重要なのは、端末が列数を決めるときフォントの送り幅を見ていないことだ。何セル分かは Unicode の幅の性質から決まる。そしてそこに曖昧さがある。UAX #11 は各文字に6つの幅の値(Ambiguous / Fullwidth / Halfwidth / Narrow / Wide / Neutral)を割り当てるが、Ambiguous(曖昧幅)の文字は文脈によって全角にも半角にも解決される3。東アジアのレガシー文字集合では全角だった記号が、それ以外の文脈では半角になるという歴史的な事情で、コードポイントだけでは幅が決まらない。

はこの曖昧幅にあたる。端末(正確には xterm.js の Unicode 11 プロバイダ)はこれを1列と数える。一方、日本語フォントはこの記号を全角の送り幅で描く。Mgen+ 1mn の実測では unitsPerEm が 1024、セル幅(A の送り幅)が 512 で、 の送り幅は全角の 1024 だ。

つまり、フォントが2セル分の想定で描いた記号が、端末の1セルの枠に押し込まれる。輪郭も全角のまま入ってくるので、枠を大きく超える。これが以下の問題の土台になる。

問題A: 拡張から拡張へフォントを渡す規約

契約フィールド

供給側の拡張は package.json の最上位でこう宣言する。

1
2
3
4
5
6
7
8
9
10
"providedFont": {
  "family": "Mgen+ 1mn Term",
  "file": "fonts/MgenPlus1mnTerm-Regular.ttf",
  "weight": "400",
  "faces": [
    { "file": "fonts/MgenPlus1mnTerm-Regular.ttf", "weight": "400" },
    { "file": "fonts/MgenPlus1mnTerm-Bold.ttf", "weight": "700" }
  ],
  "license": "OFL-1.1"
}

faces の配列(複数フェイス)か、fileweight の平坦な対(単一フェイス)のどちらかで宣言する。faces があれば平坦な対は無視される。上の例が両方を持っているのは、古い読み手のために平坦な別名を残すというリポジトリ側の慣習で、規約の要求ではない。

消費側は次の流れでこれを使う。

flowchart TB
    A["フォント拡張<br>TTF を同梱するだけ"] --> B["package.json の<br>providedFont"]
    B --> C["消費側が<br>getExtension で読む"]
    C --> D["extensionUri から<br>webview URI を作る"]
    D --> E["webview に<br>@font-face を注入"]
    E --> F["未インストールなら<br>OS の monospace へ"]

license は SPDX 識別子を必須にしているが、コードは値を読まない。書体を借りるかどうか判断する人間に条件を示すための情報で、ライセンス本文自体はパッケージに同梱される。読まれないフィールドを必須にすることに意味があるのは、規約が人間どうしの合意でもあるからだ。

契約の family を CSS に注入してはいけない

ここが実装した中で一番反直感的だった点だ。@font-face はファミリ名を宣言するのであって、フォントファイルから読むわけではない。CSS のフォントマッチングはファイル内部の name テーブルを一切参照しない。だから注入に使う文字列は、消費側が自分で持っている定数でよく、実際そうすべきだ。

理由は2つある。

第一に、他の拡張の package.json から読んだ値は、その拡張が制御する文字列である。契約の妥当性規則(空でない文字列であること)は CSS のメタ文字を排除しない。つまり供給側が細工した family を消費側の <style> に補間すれば、CSS の注入経路になる。自分のレジストリが持つ定数は自分が制御する値なので、この経路が存在しない。

この観点はあとから効いた。利用者が設定に書いたフォント名は、xterm 側が文字列連結で組む <style> にそのまま入る経路を持っていて、渡す前にトークン単位で構造を検査するよう直した。設定値は利用者が書くものだから供給側の信頼境界とは別の話だが、「文字列がスタイルシートに入る経路はすべて検査する」という結論は同じだった。

第二に、注入で読まないからこそ、契約の family と消費側の定数の食い違いが無症状になる。解決も注入も描画も全部成功したまま、2つの宣言だけが違っている状態が成立する。症状が出ないものは人間が気づけないので、機械検査を置いた。テストは両方向を突き合わせる。宣言された providedFont.family がすべて消費側のレジストリに入っていること(包含)と、レジストリの全ファミリに宣言するパッケージが存在して文字列が一致すること(同値)の2つだ。

規約の両側が食い違って何も起きない箇所を数え上げて、そのすべてにテストを置く。これが自作規約でいちばん退屈で、いちばん必要な作業だった。

劣化は静かで、静かなのは片方だけ

供給する拡張が入っていなければ、消費側は @font-face を一切注入せず、指定は OS の monospace に落ちる。ここは静かに落ちるのではなく、利用者が同梱ファミリを選んでいれば「入れると使えます」という勧誘の通知が一度だけ出る(この節の後半に出てくる、レジストリを静的にしている理由のひとつだ)。

静かなのはもう一方、宣言したフェイスの一部が欠けている場合だ。たとえば bold を宣言しているのにパッケージに入っていないと、bold を宣言しなかったパッケージと見分けがつかない描画になる(bold のセルは合成される)。リポジトリ側の歯止めはテストで、宣言された全フェイスをソースツリーのディスク上と突き合わせる。ただし公開された VSIX の中身が宣言と一致しているかを検査する機械的な層は無く、そこはパッケージング時の人間の手順として残っている。この非対称は README に書いて開示してある。

localResourceRoots は webview の外を守らない

もうひとつ、実装で踏みかけた落とし穴を共有する。宣言されたフォントファイルが実在するかを確認するコードは、webview ではなく extension host で動く。ここで localResourceRoots は何の防御にもならない。あれは webview のリソース配信に対する アクセス制御で、extension host の fs 呼び出しはその外側にある。

そして Uri.joinPath.. を解決する。file の値を / で分割すれば絶対パスは無効化できるが、上位への移動は止まらない。宣言されたパスを stat するなら、解決後の絶対パスが供給側の extensionUri の内側にあることを確認してからにしないと、"file": "../../../../etc/shadow" のような契約が供給拡張の外にあるファイルの存在を問い合わせるオラクルに化ける。

正直に書くと、この拡張自身はまだ封じ込め検査を持っていない。供給側が固定の拡張 ID(消費側にハードコードされたレジストリの5つ)に限られるという信頼境界の議論で受け入れているが、規約の正本と照らせば届いていない。届いていない点を「実装済みのように見せない」ために、規約側の文書に該当箇所として明記した。設計文書に自分の未達を書いておくのは、後から読む人にとっても自分にとっても、あとで直せる形の記録になる。

静的なレジストリを維持した判断

消費側のフォント一覧はハードコードされている。vscode.extensions.all を走査して providedFont を宣言する拡張を見つければ動的にできるのに、なぜそうしないのか。

理由は3つあり、動的な発見で消えるのは1つだけだった。

設定の enum(フォント名の選択肢)は activate() の前に読まれる。実行時にそれを拡張する API は無いので、走査した結果を選択肢に反映できない。

もうひとつは、フォント拡張が入っていないときに「入れると使えます」と勧める機能だ。これには「このファミリ名を供給するのはこの拡張 ID」というマッピングが必要になる。未インストールの拡張は走査に現れないのだから、発見では原理的に供給できない。

残る1つ(インストール済みの拡張から契約を読む部分)だけは走査で置き換えられるが、それだけ動的にしても、フォントを追加するには結局ホスト側のリリースが必要になる。フォントが頻繁に増えるものではないと判断して、静的なレジストリと勧誘機能のほうを残した。

「動的にできる」と「動的にして何が減るか」は別の問いである。3つのうち1つだけが消える変更に、レジストリの単純さを引き換えに出す価値はなかった。

問題B: 全角記号が横に潰れる

何が起きているか

xterm.js の該当関数はこうなっている2

1
2
3
4
5
6
7
8
9
export function allowRescaling(codepoint: number | undefined, width: number, glyphSizeX: number, deviceCellWidth: number): boolean {
  return (
    width === 1 &&
    glyphSizeX > Math.ceil(deviceCellWidth * 1.5) &&
    codepoint !== undefined && codepoint > 0xFF &&
    !isEmoji(codepoint) &&
    !isPowerlineGlyph(codepoint) && !isNerdFontGlyph(codepoint)
  );
}

読み方はこうだ。1セル幅と数えられた文字で、輪郭の横幅がデバイスセル幅の1.5倍(切り上げ)を超えていて、ASCII より上のコードポイントで、絵文字・Powerline・Nerd Font のグリフでないとき、再スケールが許可される。そして再スケールは横方向だけに効く。縦はそのままなので、記号は平たく潰れた形で描かれる。

日本語フォントの はこの条件を満たす。端末は1セルと数えているのに、輪郭は全角のまま入ってくるからだ。

変換の方針

変換が触るのは3つ、送り幅と輪郭の幅と高さである。

送り幅は、対象に選んだグリフだけをセル幅に揃える。全角送りで描かれていた記号が1セル分になる(Mgen+ 1mn なら 1024 から 512 へ、ShirokumaGen Term なら 1080 から 540 へ。後者では Regular の 1656 グリフ、Bold の 1621 グリフが該当する)。セル幅そのもの(A の送り幅)は変換で動かさないので、端末の升目は何をしても動かない。動くのは対象グリフの側だけだ。

輪郭の幅は、セル幅の 1.15 倍を超えるグリフだけを、ちょうど 1.15 倍まで縮める。この比率の上限 1.5 は好みではなく機構上の壁で、超えると上のコードが横だけ縮める。ただし 1.5 ちょうどが安全という意味でもない。判定はラスタライズ後のデバイスピクセルで、アンチエイリアスのにじみを含むからだ。上限の 1.5 から一段下げた運用の目安として 1.3 程度までに置くのが妥当だと考えていて、実際の既定値はさらに保守的な 1.15 を採っている。変換ツールは 1.5 を超える指定と 0 以下の指定を、1バイトも書く前に拒否する。

1 を超えた分のはみ出しは中央寄せによって左右対称になる。ShirokumaGen Term の実測では、はみ出しは 540 単位のセルに対して最大 41 単位(約 7.6%)にとどまる。つまりこの比率はセルへの封じ込めではなく、はみ出しの上界である。記号が隣のセルにわずかに触ることはあるが、潰される閾値には届かない。

高さが次の問題になる。輪郭の幅を縮めると、同じ率で高さも失う。Mgen+ 1mn Regular(セル幅 512)の実測で、 は 928×928 から、512 の 1.15 倍にあたる 588×588 になり、同じ面の大文字 A の高さ(748)より低くなった。横はもう伸ばせない(すでにセル幅を超えている)ので、余地は縦だけだ。そこで縦の倍率を横の倍率の 1.3 倍にした。この 1.3 は輪郭幅の比率とは別の量で、測って導いた値ではなく選んだ値である。1.0 から上限までの5段階を焼いて、人が見て決めた。近くに測れる基準(大文字 A の 748 を縮んだ記号の 588 で割った 1.272)はあるが、そこから導出したわけではない。

上限はフォント自身から測る

縦に伸ばす操作には、行の上下へ食い込ませない上限が要る。ここで固定値を決めるのは危ない。書体によって記号の高さの分布が違う。

そこでそのフォント自身の「漢」(U+6F22)の輪郭の上端と下端を参照グリフとして測った。Mgen+ 1mn Regular では y = −78..843 で、フォントと面ごとに違う値になる。伸ばした後の上端・下端がこの内側にとどまるよう、グリフごとに伸長係数を clamp する(下限は 1.0 で、変換前より縮めることはしない)。この参照グリフを持たないフォントは上限を実測できないので、変換は 1バイトも書く前に拒否する。推測値で代替しない。

縛るのは輪郭の高さではなく行の上下への到達である。変換は縦の中心を保存するので、高さだけを上限に押し込む実装では、中心が「漢」とずれているグリフが伸長分だけ行の外に出てしまう。それを実測で確かめてから、係数の側を clamp する形に変えた(高さで抑える方式だと、clamp が発火しないまま下端が 32 単位深くなるグリフ、clamp が発火しても上端が 26 単位上がるグリフがあった)。

実測から言えることは条件つきの文である

ここが文章として一番難しかった。書きたくなるのは「この書体の記号は『漢』より外に出ない」だが、これは偽になる。上流の書体には元から「漢」より高く/深く届く記号がある。Mgen+ 1mn Regular では変換対象 1001 個のうち 141 個が、変換前の時点ですでに外にいた。

真になる言明は、条件つきの形にしかならない。

この変換はグリフを「漢」の y 範囲より外へ出さない。変換前からすでに外にいるグリフは 1 単位も動かない。

同じ理由で「変換すると記号が縦に伸びる」も全称では書けない。clamp の下限が 1.0 である帰結として、変換前から上限に届いているグリフは動かない。ShirokumaGen Term の Regular では、変換対象 1723 グリフのうち縦に伸びたのは 1128 個で、残りは余地が無く 1.0 のままだった。

実測値を持っていることと、実測値から言える文を書けることは別の能力だ。測った数字を無条件の全称文に言い換えた瞬間、それは測っていない主張になる。

対象にしない領域

ASCII と Latin-1 は対象外にした。端末はそれらを再スケールしないし、変換すれば本文の見た目を作り直すことになる。罫線素片、ブロック要素、そして BMP の私用領域(U+E000–U+F8FF)も外している。触るのは対象グリフの送り幅と輪郭の幅・高さだけで、それ以外は何も変えない。

再現できる形にする

変換の設定は1つの JSON に集約した。比率の意味と上限と既定値の正本をそこに置き、変換スクリプトも各パッケージの README もそこを参照して、意味を書き直さない。同じ値の説明が2箇所にあれば、片方が古くなるのは時間の問題だからだ。

上流のアーカイブは URL と sha256 で記録し、変換前に全フェイスを一括照合する。取り違えは 1バイトも書く前に落ちる。上流の来歴がそもそも記録されていないパッケージについては、記録が無いことを理由付きで書き、推測値で埋めない。

ライセンス: 改変版の名前は自由に決められない

フォントを改変して再配布するとき、名前はライセンスの問題になる。SIL Open Font License 1.1 の §3 はこう定めている4

No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.

つまり改変版は、そのフォントが予約している名前(Reserved Font Name)を名乗れない。制限がかかるのは利用者に示される主たるフォント名だが、まさにそこが命名の焦点になる。だから自分の名前を決める前に、どの名前が予約されているかを確定させる必要がある。上流が新しいリリースで予約名を増やすこともあるので、上流更新のたびにやり直す作業になる。

配布しているパッケージのうち、変換したものには ` Term` という接尾辞を付けた。これは端末の升目に対する2つの主張だ。幅1のコードポイントがちょうど1セル分進むこと、そしてその輪郭が規約の定める範囲(セルの 1.15 倍以内で中央寄せ)に収まっていること。2つ目は封じ込めではなく上界の主張である点に注意してほしい。変換していないパッケージはこの接尾辞を取らない。名前を見れば、そのパッケージが変換出力かどうかが分かる。

上流の名前に含まれる語を残すかどうかは、意味で決めた。HackGen ConsoleConsole は上流が端末向けの版に付けた印で、 Term が言うことと重なるので落とした(ShirokumaGen Term)。M PLUS 1 CodeCode は「どの書体か」を指す語(プロポーショナルな M PLUS 1 の等幅版)なので残した(M PLUS 1 Code Term)。Mgen+ 1mn1mn は上流の版の呼び名で、m が等幅の印だが、それは ` Term` が言う2つの事実のうち片方でしかないので残した。部分的に重なる語は落とさない。

PlemolJP Console HS は改変せず、上流のバイト列を上流の名前のまま同梱している。理由は幅の側にある。この書体はもともと対象の記号の送り幅がセルに一致していて、変換すべきものが無い。

予約名の話はここに別の形で効いた。この書体は "PlemolJP" を予約名として含んでいるので、もし変換の対象にしていたら、機械的に ` Term` を足す命名規則が予約名を含む名前を Marketplace に出すところだった。変換しない判断が先にあって危険が消えたので、順序としては運が良かっただけである。だから予約名の照合を、命名規則とは独立した検査として回している。

検査は「静かな OK」を出さない設計にする

予約名の照合は目視ではなくスクリプトでやる。ここで設計として効いたのは、終了コードで OK の種類を区別することだった。

  • 0 は「少なくとも1つの予約名と比較した上で、違反なし」
  • 3 は「決めた名前が予約名を含む」(§3 違反)
  • 4 は「比較対象がゼロだったので、OK が空虚に真」
  • 5 は「保留」(空白を除くと予約名と一致する場合など)
  • 1 は「検査が完了しなかった」

4 と 5 があるのは、誰も本物の合格と区別できない OK が、検出器が静かになる経路だからだ。0 を返して標準エラーが空という状態は、4つの名前と比較したのか1つも比較しなかったのか、呼び出し側から見て同一に見える。予約名を1つも見つけられなかった検査は、合格ではなく「測れなかった」である。空虚な OK に別のコードを与えて、それを明示的に受け入れるフラグを要求する。この形にしてから、検査の結果を README に転記する作業が意味を持つようになった。

まとめ

公式の仕組みが無い場所に規約を作ると、危ないのは機能が動かないことではない。規約の両側が食い違ったまま全部成功することだ。今回の設計で無症状だった箇所は3つあった。契約の family と消費側の定数の不一致、宣言したフェイスが VSIX に入っていないこと、そして予約名を1つも見つけられなかった検査の合格。どれも描画は成功し、テストも(置かなければ)通る。

幅の変換で学んだのは、実測値の扱い方だった。数字を持っていることと、その数字から言える文を書けることは違う。「漢より外に出ない」は偽で、「この変換は外に出さない、既に外のものは動かさない」が真になる。条件つきの文は読みにくいが、無条件の文が偽なら選択肢はない。

どちらも、自分ひとりが使うツールなら要らない作業に見える。ただ、規約は他人の拡張と自分の拡張のあいだにあり、フォントは他人が作った書体の改変版で、名前はライセンスに縛られている。ひとりで作っていても、規約と検査を必要とする相手はいる。

使い方から知りたい方へ: この記事はフォント側の実装を扱いました。拡張機能そのものの機能(VS Code のサイドバーに tmux を常駐させる、AI CLI の端末ベルをネイティブ通知に変える)とセットアップは、姉妹記事「VS Code のサイドバーに tmux を常駐させる——AI CLI の「終わった」を取りこぼさない作業環境を自作した」で紹介しています。

関連記事

このテーマに関連する他の記事もご覧ください:

参考資料

本文中の引用番号に対応する参考資料を番号順に記載しています。

その他参考資料(本文中で番号引用なし)

  • ShirokumaLibrary/tmux-opener - 本記事で扱う実装のソースリポジトリ。providedFont 契約の仕様は font-packages/README.md、変換の設定は scripts/font-fit/manifest.json【信頼性: 高(一次情報)】
  • HackGen(白源) - yuru7. ShirokumaGen Term の上流【信頼性: 高】
  • PlemolJP - yuru7. 非改変で同梱しているパッケージの上流【信頼性: 高】
  1. Contribution Points / Webview API - Visual Studio Code Extension API. 貢献点の一覧にフォントを他の拡張へ供給する項目はない(contributes.iconscontributes.productIconThemes はフォントファイルを参照するが、いずれも VS Code 自身が消費するアイコングリフ用)。webview が参照できるフォント関連の値はエディタ設定を反映した CSS 変数に限られる【信頼性: 高】 ↩︎

  2. xterm.js allowRescalingsrc/browser/renderer/shared/RendererUtils.ts - xterm.js. 本文中のコードは同ファイルからの引用(上流のインラインコメントは省略した)【信頼性: 高】 ↩︎ ↩︎2

  3. UAX #11: East Asian Width - Unicode Consortium. 6つの幅の値と、Ambiguous な文字が文脈依存で解決されること【信頼性: 高】 ↩︎ ↩︎2

  4. SIL Open Font License Version 1.1 - 26 February 2007 - SIL International. §3 の条項は同文書からの引用(2文とも全文)【信頼性: 高】 ↩︎

This post is licensed under CC BY 4.0 by the author.