ターミナル(tmux, Vim)でズレないフォントを作る
背景
Unicodeには,East Asian Width(以下EAWと略)という概念があって,表示幅がNarrowかWideかが定義されていない(Ambiguousな)文字が存在します.Narrowというのは所謂「半角」のことで「half width」とか「1セル」などとも呼ばれます.Wideというのは所謂「全角」のことで「full width」とか「2セル」などとも呼ばれます.たとえば「→」(U+2192), 「①」(U+2460), 「λ」(U+03BB)などがEAWの文字です.なお,この記事でEAWの文字と書いているのはEAWのAmbigiousカテゴリの文字のことを指します.
EAWの文字はフォントによってNarrowかWideかが異なるため,CLI(ターミナル, tmux, Vim)で表示がズレてしまう問題をよく引き起こします.
上図はmac標準ターミナルで「Migu 1M」フォントを使ってEAWの文字を含む文字列を表示した例です.フォントはWideにも関わらず表示領域がNarrowになっており,次の文字と重なってしまいます.
ターミナルやエディタによってはEAWの文字に対する設定が存在し,例えばiTerm2では「Ambiguous characters are double-width」,Vimでは「ambiwidth」という設定で,EAWの文字をWideとして強制表示することができます.(Vimの場合はさらに「setcellwidths」関数で特定の文字のセル数を設定することもできます.)しかし,EAWの文字がNarrowで表示されることを前提にしているアプリケーションもあり,そのアプリケーションではWideに強制することで逆に表示が崩れてしまいます.
上図はiTerm2で「Ambiguous characters are double-width」をONにして「Migu 1M」フォントを使ってEAW文字を含む文字列を表示した例です.単純な表示は問題なさそうですが,EAWの文字である罫線を出力するアプリケーション(sqlite)が,罫線がNarrowで表示されることを前提としているのでズレてしまいます.(ただし,罫線に関しては後述するBox Drawingにより実際には問題が発生しない可能性が高いです.)
アプリケーションによってはこの問題を解決するオプションを用意してくれていて,例えばfzfであれば,--no-unicodeというオプションを使うことで,EAWの文字である罫線に代えてASCII文字を使った罫線を出力してくれます.ただし,現在・将来に使うアプリケーションのすべてがこのようなオプションを提供してくれてるとは限らないでしょう.また,私の環境では上記のように強制的にWideにした場合,文字数(幅)のカウントがおかしくなるのか,カーソルの位置がズレたり,文字を編集した際に消したはずの文字が残像として残ってしまうこともありました.
このEAWの文字の表示に関する問題は多くの方が経験しているようで,検索するとたくさんの事例がヒットします.
- 端末の文字幅問題の傾向と対策 | IIJ Engineers Blog
- Ambiguous width character in CJK environment · Issue #370 · microsoft/terminal
- tmux 2.5 以降において East Asian Ambiguous Character を全角文字の幅で表示する
- mlterm + tmux + Neovim で East Asian Ambiguous Width 問題をなんとかする on NixOS - カラクリスタ
特にターミナル,tmux(などのマルチプレクサ),Vim(などのエディタ)が多重に動いてる環境では,すべてのアプリケーションが一貫してEAWの文字をハンドリングできなければ表示やカーソルがズレてしまうので,色々と設定を変えたりアプリケーションにパッチを当てたりして試行錯誤している例をよく見かけました.
方針
この問題を解決するにあたり,ターミナルもマルチプレクサもエディタも,今後ずっと同じものを使い続けるかわからないですしアプリケーションによってサポートの有無が異なる中で,EAWの文字に翻弄されてアレコレやるのは辛いと感じました.そこで,EAWの文字がNarrowであることを前提とするアプリケーションがメジャーである(東アジアの事情を考慮してくれるアプリケーションはグローバルでは少ない)と考え,EAWの文字をNarrowに固定する方針としました.つまり,普段使用しているフォントをベースに,EAWの文字のグリフをNarrowに変換する方法を考えました.
この考えの起点になったのは,以下のブログとフォントです.
- Macのターミナルで全角記号を綺麗に表示したかった #Terminal - Qiita
- tomonic-x/Illusion: Programming font for JIS X 0208 with Unicode.
環境
- OS: mac 15.7.1 (Sequoia)
- ターミナル: iTerm2 3.6.6
- ターミナルマルチプレクサ: tmux 3.6a (HomeBrewでインストール)
- エディタ: vim 9.2 (HomeBrewでインストール)
- フォント編集ツール: FontForge 20251009 (HomeBrewでインストール)
- ベースとなるフォント: Migu 1M
フォントの変換
ターミナルで使っているフォントをFontForgeを使って編集し,EAWの文字のグリフをNarrow化します.私の場合は「Migu 1M」を使っているのでそれをベースとしました.フォントはmonospaceであることを前提とします.
以下のような変換を行います.
- 対象はフォントに含まれているEAWの文字とします.
- EAWの文字のグリフは(左右マージンも考慮した上で)フォントのem/2の範囲内に収まるように縮小します.
- 罫線の文字は変換しません.
- iTerm2を含め多くのターミナルにはBox Drawingという機能があり,罫線はフォントのグリフが利用されないためです.
- X方向だけ縮小してもグリフの太さが変わってしまうし,普通はBox DrawingをONにする(iTerm2の場合はデフォルトON)ので,下手にいじらないほうがよいと考えました.
- iTerm2の場合は,Advancedメニューにある「Use your typefaces’ box-drawing characters instead of iTerm2’s custom drawing code.」というオプションがそれに当たります.
ここでは,フォントファミリの名前を「Migu 1M Narrow」としました.
import fontforge
import unicodedata
# read base font
font = fontforge.open("migu-1m-regular.ttf")
x_margin = 0.05 # 5%
target_width = font.em // 2 # Narrow
target_width_with_margin = target_width * (1 - x_margin)
left_edge_with_margin = target_width * x_margin // 2
for code in range(0x110000):
ch = chr(code)
if (
code in font \
and unicodedata.east_asian_width(ch) == 'A' # only EAW characters
and not (0x2500 <= code <= 0x257F) # exclude border characters
):
g = font[code]
if font.em <= g.width: # only wide characters
xmin, ymin, xmax, ymax = g.boundingBox()
b_width, b_height = xmax - xmin, ymax - ymin
scale = min(target_width_with_margin / b_width, 1)
print(f'U+{code:04x}', chr(code), (xmin, ymin, xmax, ymax), (b_width, b_height), f'{scale:0.2f}')
# scale glyph to target_width_with_margin
g.transform((scale, 0, 0, scale, 0, 0))
# adjust x-offset
if scale < 1:
# move xmin to left edge
xmin_scaled = g.boundingBox()[0]
offset_x = left_edge_with_margin - xmin_scaled
else:
# match x-center with original x-center position ratio
center_x = xmin + b_width / 2
offset_x = (center_x / font.em * target_width_with_margin + left_edge_with_margin) - center_x
g.transform((1, 0, 0, 1, offset_x, 0))
# set new glyph width
g.width = target_width
# write converted font
font.fontname = "migu-1m-narrow-regular"
font.familyname = "Migu 1M Narrow"
font.fullname = "Migu 1M Narrow Regular"
font.generate("migu-1m-narrow-regular.ttf")FontForgeをimportできるようにするため,コマンドは以下のように実行します.
fontforge -script index.py比較
ベースの「Migu 1M」と変換した「Migu 1M Narrow」をターミナル上で比較すると,以下のようにEAWの文字がNarrowになっていることが確認できます.ズレがわかるように文字の前後に_を入れています.
| Migu 1M 20px | Migu 1M 40px | Migu 1M Narrow 20px | Migu 1M Narrow 40px |
|---|---|---|---|
|
|
|
|
はじめの例を「Migu 1M Narrow」で表示した例が以下です.
変換後のフォントはグリフが縮小されるので,フォントサイズが小さいと潰れてしまって見にくくなる文字もありますが,ズレることはなくなりました.ベースのフォントによって見え方は異なるでしょうし,上記のpythonスクリプトは単純なルールで変換しているだけなので,調整されたフォントと比べると不都合が出る可能性はあると思います.ただし,ズレるよりは256倍良いので,とりあえずはこのように一律なルールでスケーリングしたフォントで様子を見ようと思います.