この記事は Vim 駅伝の 2026/4/22 の記事です。前回は ultimatileさんによる「ブラウザで日本語入力中にIMEが勝手に切り替わると思ったらNeovimのLSPが原因だった」でした。
作ったもの
最初に作ったものを紹介します。
Markdown のファイルを開き、キーを叩くと、以下のように綺麗にレンダリングされたウィンドウで文書を読むことができます。
画像が表示されてますね!動画も!これは GUI ではなく、ターミナル上の Neovim ですよ!
全ての機能の実例は :MdRenderDemo コマンドで確認できます。
- Mermaid ダイアグラムは画像に変換して表示します。
- 横に広いテーブルはいい感じに折り畳んで表示します。クリックすると展開します。
- コードブロックも長過ぎる行は「…」で省略します。これもクリックすると全部表示します。
-
<details>や折り畳みコールアウト(> [!NOTE]-みたいなの)はクリックして開閉できます。 - 日本語テキストは禁則処理と BudouX で綺麗に折り返します。
表示方法はいくつか選べます。
Floating Window で表示
Markdown ファイルを開いて、:MdRender、あるいは <Plug>(md-render-preview) マッピングを叩くと Floating Window の中で表示します。
タブで表示
:MdRenderTab、あるいは <Plug>(md-render-preview-tab) マッピングだと新しいタブで表示します。
telescope.nvim / snacks.nvim 拡張機能
telescope.nvim / snacks.nvim 用の Previewer も用意しました。Markdown や画像、動画はこのプラグインで表示し、他のファイルは標準の Previewer にフォールバックします。
" md-render.nvim でプレビューする
:Telescope md_render find_files
:Telescope md_render live_grep cwd=~/notes
:Telescope md_render grep_string search=TODO
md_render extension を使うとビルトインの Picker をこのプラグインでプレビューします。他の extension のプレビューに使ったり、snacks.nvim での用例については README を見てください。
less の代わり
起動時に :MdRenderPager することで、less の代わりとして使えます。シェルの alias にしとくと便利だと思います。
nvim +MdRenderPager README.md
動作要件・インストール方法
このプラグインは Neovim >= 0.10 で動作します。また後述するように、画像・動画の表示には WezTerm / Kitty / Ghostty のいずれかが必要で、macOS / Linux にのみ対応しています。
インストール方法は lazy.nvim のパターンだけ載せておきます。他のプラグインマネージャーの場合など、詳しい使い方は README を参照してください。
{
"delphinus/md-render.nvim",
version = "*",
dependencies = {
{ "nvim-tree/nvim-web-devicons", version = "*" }, -- optional: file type icons in code blocks
{ "delphinus/budoux.lua", version = "*" }, -- optional: CJK phrase-level line breaking
},
keys = {
{ "<leader>mp", "<Plug>(md-render-preview)", desc = "Markdown preview (toggle)" },
{ "<leader>mt", "<Plug>(md-render-preview-tab)", desc = "Markdown preview in tab (toggle)" },
{ "<leader>md", "<Plug>(md-render-demo)", desc = "Markdown render demo" },
},
}
なぜ作った?
既存のソリューション
Neovim で Markdown ファイルを開くと、プラグイン無しでもある程度綺麗にスタイルが描画されます。
しかしこれには限界があります。
- 画像も動画も表示されません。Mermaid ダイアグラムもダメです。
- テーブルも崩れています。
- GFM(GitHub Flavored Markdown)や Obsidian 記法、また、Qiita 記法に対応していません。上記のスクリーンショットでもコールアウトの描画がイマイチです。
プラグインを使う方法もあります。例えば render-markdown.nvim とかです。
いい感じです。しかし、このまま文書を編集するのはちょっと難しいです。コマンドで ON/OFF を切り替えながら編集することになります。
どうせ切り替えるなら、外部ツールを起動する方法もありますね。mcat を使えば綺麗に Markdown が描画できます。画像もテーブルも OK です。
本題とは違うんですが mcat はすごいツールです。-I オプションを使うと Markdown をレンダリングして画像に書き出し、それを拡縮・スクロールして見せてくれます。ターミナルの中でですよ!
実現したいこと
今までこれらを使ってきましたが、以下のような不満を感じていました。
- コードブロックのハイライトは Tree-sitter で描画したい。
- mcat だと表現できる言語がかなり限られます。
- Obsidian 記法や Qiita 記法に対応して欲しい。
-
<details>や、Obsidian の折り畳みコールアウトはちゃんと折り畳んで表示して欲しい。そしてクリックしたら展開して欲しい。 - 地の文は折り返して表示して欲しいけど、コードブロックやテーブルでは折り返さないで欲しい。
- 切り詰めて表示して、クリックすると全部表示して欲しい。
- 日本語の描画が気にくわない。ちゃんと禁則処理とかして欲しい。
だいぶ欲張りですね。こんなのできるでしょうか? できたんですよ。これが。全部。
という訳で再掲。みなさんも使いましょう!
技術の詳細とこだわりポイント
紹介は以上です。以下、各要素を実現するに当たって使った技術を見ていきましょう。
Extended Marks
全てはこれです。Neovim には Extended Marks(「extmarks」と略されることが多い)という素晴らしい機能があります。
Extended marks (extmarks) によりバッファー内のテキストに注釈を付けることができます。付けた注釈はテキストが変更されてもちゃんと追随します。これによりカーソルとか、折り畳みとか、綴りを間違えた単語とか、バッファー内の論理的な位置に追随して管理したいものを何でも定義できます。
:h extmarks よりの抜粋です。ここには書いてませんが、ハイライト、文字列の省略(:h conceal)や追加(:h virtual-text)、クリックした時の動作(OSC 8)も実現できます。ターミナルでの文字列表現は全てこれで可能です。
しかし、使い方は結構難しいです。nvim_buf_set_extmark 関数はかなり低レベルな API として定義されています。バッファー内の行、桁を指定して全ての項目を一つずつ設定する必要があるのです。
いや、やろうと思えばできますよ?でも各要素が被った時の処理とか、改行してずれた行・桁を再計算して合わせるとか、かなり地道な実装とテストが必要です。今回は AI にやってもらいましたが、今まではこれを手でやってたのかと思うと過去の作成者には頭が上がりません。
コードブロックの描画
これも結局最後は上記の nvim_buf_set_extmark を使うのですが、どの位置にどの色・書式を設定するかの情報は Treesitter で得ます。Treesitter はテキストを構文木として再構築し、それぞれのキャラクタがどの構成要素に当たるのか、Neovim のハイライトグループとマッピングしてくれます。
……とまあ、この説明じゃ難しいと思うので、Treesitter については他の記事を当たってください。
-- 文字列を解析して構文木を得る
local parser = vim.treesitter.get_string_parser(code_text)
local trees = parser:parse()
-- 言語(今回は Python)毎に用意されているクエリを使い
-- それぞれのノードを分類する
local query = vim.treesitter.query.get("python", code_text)
for id, node in query:iter_captures(trees[1]:root(), code_text) do
-- "function" のような文字列
local name = query.captures[id]
-- スタイルを適用するべき範囲
local start_row, start_col, end_row, end_col = node:range()
-- 様々なスタイルを設定する(今回はハイライト)
vim.api.nvim_buf_set_extmark(buf, ns, start_row, start_col, {
end_row = end_row,
end_col = end_col,
hl_group = "@" .. name .. ".python",
})
end
こんな感じです。最後の hl_group がハイライトグループで、具体的には @keyword.python, @function.lua のような文字列になっています。ハイライトグループはカラースキームで定義できますが、言語毎に詳しいハイライトを設定している例は少ないでしょう。大抵は伝統的な Keyword, Function といったハイライトグループにフォールバックします。
これだけでも十分難しいのですが、コードブロックの文書中の位置に合わせたり、長過ぎるものは「…」で省略表示し、テーブルやコールアウトの折り畳みでも描画が崩れないように……とやっていくととんでもなく複雑になります。手で書くのマジで無理……AI ホントありがとう。
画像!動画!
ターミナルで画像・動画を表示するのは古来から人類の夢でした。これについてはすでにいくつかのソリューションが可能にしています。
画像を表示するプロトコル
- SIXEL Graphics
- 開発されたのが 1984 年と言いますから、もう 40 年前のソリューションです。表示色が少なかったり描画処理が重かったり、他と比べると使いにくいです。
- iTerm2 Inline Images Protocol
- macOS ではメジャーな端末アプリ、iTerm2 で実装しているプロトコルです。機能的には十分なのですが後述の理由からサポートは見送りました。
- Kitty Graphics Protocol
- Kitty のために開発されたプロトコルです。最近のターミナルの多くがサポートしています。
今回のプラグインでは Kitty Graphics Protocol をサポートし、WezTerm, Kitty, Ghostty という、人気のターミナル御三家で動作を確認しています。
Kitty Graphics Protocol を採用した訳
それは vim.ui.img の存在です。
feat(ui): implement vim.ui.img api by chipsenkbeil · Pull Request #37914 · neovim/neovim
Neovim で画像を取り扱う API を提供する PR です。vim.ui.imgは自体は高レベルな API で、各プロトコルの実装は vim.ui.img.provider に委ねられています。ユーザーの環境に合わせて画像表示プロトコルを選ばせる方式です。
この API は Neovim 0.13 での実装を目指しています。現時点(2026/4)ではまだ利用できません。将来的にはユーザーが好きなプロトコルを差し替えて使うでしょう。0.13 のデフォルト設定では Kitty Graphics Protocol のみに対応する予定になっています。これを見越して、今回のプラグインでも Kitty Graphics Protocol を採用したという訳です。
実際に表示してみる
簡単なスクリプトを書きました。このプラグインのコードを使って、ターミナルで画像を描画できます。
nvim -u NONE +'set rtp+=~/.local/share/nvim/lazy/md-render.nvim' -l /tmp/demo_kitty.lua /path/to/image_file
rtp=…… となっている部分のパスは、実際にこのプラグインの場所に置き換えてください。こんな感じで動作します。
スクリプトでは以下のことを行っています。
- 端末が Kitty Graphics Protocol に対応しているか確認。
- 画像のファイル名を端末に転送。
- 指定した大きさ(行×列)で画像を表示。
- 3 秒経ったら削除。
簡単ですね!……けど、簡単なのはプラグインのコードを使っているからです。プラグインで隠蔽している処理は色々ありまして……
- 画像のバイナリを解析して解像度(縦×横)を得る。
- 端末の一文字が何ピクセルに当たるのか、TIOCGWINSZ を叩いて知る(FFI を使います)。
最低でもこれらが必要です。Markdown で画像を表示するには以下も必要でしょう。
- 画像の表示サイズに合わせて空行を表示する。
- 画面上で一部だけ表示される場合はその分だけクロップして端末に送る。
- スクロールしたら位置を変え、表示部分が増減すればその分クロップし直して再表示。
- テーブル内の画像のように、同じ行に複数の画像があれば↑これらの計算も更に大変になる。
アニメーション GIF や動画だともっと複雑に!
- 予めフレーム毎に分割(ffmpeg を使います)してキャッシュに保存する。
- タイマーを使って画像を次々に切り替える。画面上の表示部分に合わせて都度クロップもする。
うわぁ、ってなりますね。vim.ui.img が来れば少しは楽になるのかな……。
Neovim 上で画像を扱うためのライブラリとして image.nvim などの既存のプラグインを使うという方法もありました。しかし……
- 僕自身ターミナル上の画像表示について詳しく知りたかった。
- 動画やウェブからダウンロードしたコンテンツの表示はタイミングが非常にシビアで細かく制御する必要があった。
というような理由から採用しませんでした。
日本語特有のアレコレ
既存のソリューションに満足できなかったのはこれも大きな理由です。端末上で文書を読む際、less などのレンダリングは全て欧文が基準です。これらは文章の折り返しや禁則処理を考慮してくれません。
今回のプラグインではこれを 2 つの技術で改善しています。
正確な禁則処理
禁則処理とは、句読点や括弧など、周りの文字と離れると読みづらくなってしまう文字を考慮し、表示幅に合わせて適切に文章を折り返す処理です。
--画面幅--
長い文が続く
時、句読点が続
いていないと
ちゃんと読めな
いですよね。
(括弧も絡むと
更に困りま
す。)
だいぶ恣意的な例ですが、「時、」「ちゃ」「(括」「す。)」が途切れないように折り返しています。
元々は紙の本のための技術ですが、今回はこの仕様を元に実装しました。
紙の本ではこれに加えて、表示幅に合わせて字間を調整し句読点や括弧をいい感じに収めることもできます。JISX4051 でいう「アキ調整」です。これを端末でも再現できたらいいんですが、端末では「文字の大きさと字間が固定」という困った制限がありますので利用できません。無念。
文節単位での折り返し
これに加え、BudouX による文節単位の折り返しを導入しています。
意味のある塊で折り返すことができ、読んでいる時の違和感を減らすことができます。オリジナルの BudouX は Python / JavaScript で書かれていますが、割合単純なロジックなのでこれを Lua に移植しました。
delphinus/budoux.lua: A Lua port of BudouX — ML-powered CJK line break organizer
Lua による実装には既存のものもあるのですが、これは Neovim の機能に依存しているようです。どうせなら、ということで今回は Pure Lua に直しています。
README にも書いていますが、md-render.nvim とは別プラグインにしているので、これも一緒にインストールしてください(上述の設定例では lazy.nvim の dependencies に定義しています)。
BudouX も完全ではなく、長い文節や複合カタカナ語が文末にあると間延びした感じになります。また、そもそもテーブルのセル内では使わない方が読み易かったりします。その辺は実際に試してみて適宜調整しています。
終わりに
以上です。細かいこだわりポイントはまだまだあるのですが今日はこの辺にします。md-render.nvim は僕の「こうだったらいいな」を、思いつく限り全て実装したプラグインです。僕のこだわりが詰まった、それゆえカスタマイズできる所もほとんど無いものになっています。使ってみて「ここを調整できるようにして欲しい」など、ご意見をいただけたら嬉しいです。