はじめに
VSCodeでPDFを開こうとしたとき、「ビルトインのプレビューがない」「別アプリに切り替えるのが地味に面倒」と感じたことはないでしょうか。
既存の拡張機能もいくつか存在しますが、PDF.jsベースのものが多く、MuPDFのレンダリング品質を直接Webview上で使えるものは見当たりませんでした。そこで、MuPDFをWebAssemblyにコンパイルしたWASMバインディングをベースに、ゼロから作って公開しました。
- Marketplace: https://marketplace.visualstudio.com/items?itemName=skrtk98.mupdf-viewer
- GitHub: https://github.com/skrtk98/vscode-pdf-viewer
なぜMuPDF WASMか
MuPDF はArtifex Softwareが開発するオープンソースのPDFレンダリングエンジンで、テキスト抽出精度・描画品質ともに高い評価を得ています。mupdf npmパッケージ(v1.27.0)としてWASMバイナリとJSバインディングが提供されており、Node.js/Browser両環境で動作します。
| アプローチ | 課題 |
|---|---|
| PDF.js | テキスト抽出精度がやや劣るケースがある |
| ネイティブバイナリ呼び出し | プラットフォーム依存、配布が複雑 |
| MuPDF WASM | Chromiumベースのwebviewで完全動作、純JS資産として配布可能 |
VSCode拡張機能のWebviewはChromiumベースなので、WASMをそのまま動かせます。MuPDFを持ち込む場所として、Webviewはかなり相性が良いです。
機能一覧
自動起動
.pdfファイルを開くとカスタムエディタとして自動起動します。設定は不要です。タブを切り替えてもスクロール位置・ズームレベルが保持されます(retainContextWhenHidden: true)。
表示モード
| モード | 動作 |
|---|---|
| Scrollモード(デフォルト) | 全ページを連続レンダリング |
| Single-pageモード | 1ページずつ表示 |
ズーム操作
| 操作 | 結果 |
|---|---|
Ctrl+Wheel |
マウス位置を軸にズームイン/アウト |
+ / =
|
ズームイン |
- |
ズームアウト |
| Fit Widthボタン | コンテナ幅に合わせてスケール |
| Fit Pageボタン | ページ全体が収まるようスケール |
| ズーム入力欄 |
150%または1.5のように直接入力 |
ナビゲーション
| 操作 | 結果 |
|---|---|
PageDown / →
|
次のページ |
PageUp / ←
|
前のページ |
| ページ番号入力欄 | 任意ページへジャンプ |
| アウトラインサイドバー | ブックマーク付きページへジャンプ(ツリー表示・折りたたみ対応) |
| サムネイル | 対応ページへジャンプ |
テキスト選択・コピー
- クリック&ドラッグでテキスト選択
- ダブルクリックで単語選択
-
Ctrl+Cでクリップボードにコピー
検索
検索ボックスにテキストを入力するとドキュメント全体を横断検索できます。Enter / Shift+Enterまたはナビゲーションボタンでヒット間を移動できます。
その他の機能
- 画像を右クリック → PNG形式でコピー
- 回転ボタンで現在ページを時計回りに90°回転
- パスワード保護PDFに対応(VSCodeのInputBox経由でパスワードを入力)
- ファイルがディスク上で変更されると自動リロード(LaTeX再コンパイル後などに便利)
- HiDPI / Retinaディスプレイ対応(デバイスピクセル比を動的に追跡)
- 内部リンク・外部リンクのナビゲーション
設定
{
"pdfPreviewer.defaultZoom": 1.0,
"pdfPreviewer.renderResolution": 96
}
| キー | 型 | デフォルト | 説明 |
|---|---|---|---|
pdfPreviewer.defaultZoom |
number | 1.0 |
初期ズームレベル(1.0 = 100%) |
pdfPreviewer.renderResolution |
number | 96 |
レンダリング解像度(DPI)。高いほど鮮明だがメモリ消費増 |
高解像度ディスプレイ環境では 144〜192 あたりを試してみてください。
インストール
VSCode Quick Open(Ctrl+P)を開き、以下を貼り付けてEnterを押してください。
ext install skrtk98.mupdf-viewer
またはMarketplaceから直接インストールすることもできます。
実装の解説
技術スタック
- 言語: TypeScript 5.3
-
バンドラ: esbuild(
esbuild.mjsでカスタム設定) - テスト: Vitest
-
PDFエンジン:
mupdfnpm package v1.27.0(WASM) -
対象VSCodeバージョン:
^1.85.0
アーキテクチャ概要
extension host (Node.js)
└── PdfEditorProvider
├── openCustomDocument() ← PDFをUint8Arrayとして読み込む
└── resolveCustomEditor() ← Webviewにアセットを注入・メッセージ処理
Webview (Chromium)
├── viewer.ts ← メインのUI・レンダリングロジック
└── Web Worker
└── worker.ts ← MuPDF WASMの初期化・サムネイル生成
PdfDocument と PdfEditorProvider
vscode.CustomReadonlyEditorProvider<PdfDocument> を実装しています。PdfDocument はPDFのrawバイト列(Uint8Array)を持つだけのシンプルなクラスです。
class PdfDocument implements vscode.CustomDocument {
readonly uri: vscode.Uri;
data: Uint8Array; // rawバイト列。ディスク変更時にin-placeで更新
constructor(uri: vscode.Uri, data: Uint8Array) {
this.uri = uri;
this.data = data;
}
dispose(): void {}
}
PDFバイト列はURIや一時ファイル経由ではなく、Uint8Arrayとして直接postMessageで渡すようにしました。ファイルパスのエスケープ処理を気にしなくて済むのが主な理由です。
extension.ts の登録
export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push(
vscode.window.registerCustomEditorProvider(
'pdfPreviewer.pdfEditor',
new PdfEditorProvider(context),
{
webviewOptions: { retainContextWhenHidden: true }, // タブ切替でも状態保持
supportsMultipleEditorsPerDocument: false,
}
)
);
}
retainContextWhenHidden: true がポイントです。これを外すとタブを切り替えるたびにWebviewが再初期化され、スクロール位置やズームがリセットされてしまいます。
CSPとアセット解決
WebviewのセキュリティにはCSP(Content-Security-Policy)のnonceを使っています。viewer.htmlはテンプレートとしてmedia/に置いておき、起動時にアセットのURIをプレースホルダーに埋め込む形にしました。
const nonce = crypto.randomBytes(16).toString('base64');
const wasmUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaPath, 'mupdf.wasm')).toString();
const mupdfJsUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaPath, 'mupdf.js')).toString();
// ...
const html = template
.replace(/\$\{nonce\}/g, nonce)
.replace(/\$\{cspSrc\}/g, cspSrc)
.replace(/\$\{wasmUri\}/g, wasmUri)
.replace(/\$\{mupdfJsUri\}/g, mupdfJsUri)
// ...
localResourceRoots は media/ ディレクトリだけに絞っており、拡張機能のリソースに余計なアクセスが通らないようにしています。
postMessage プロトコル
extension host ↔ Webview 間のやり取りは以下の5種類のメッセージで行います。
| 方向 | type | 内容 |
|---|---|---|
| Webview → Host | ready |
Webviewの初期化完了を通知 |
| Host → Webview | load |
PDFバイト列・設定値を送信 |
| Webview → Host | openExternal |
外部URLをデフォルトブラウザで開く |
| Webview → Host | requestPassword |
パスワード入力をInputBoxで促す |
| Webview → Host | error |
エラー通知をVSCode UIに表示 |
readyを受け取ってからloadを送るのは、Webview側でWASMの初期化が終わるのを待つためです。
case 'ready': {
const config = vscode.workspace.getConfiguration('pdfPreviewer');
await webview.postMessage({
type: 'load',
data: document.data, // Uint8Array
defaultZoom: config.get<number>('defaultZoom', 1.0),
renderResolution: config.get<number>('renderResolution', 96),
});
break;
}
ファイル監視による自動リロード
vscode.workspace.createFileSystemWatcherでPDFファイルを監視し、変更があればバイト列を読み直してloadメッセージを再送します。
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(
vscode.Uri.file(path.dirname(document.uri.fsPath)),
path.basename(document.uri.fsPath)
)
);
watcher.onDidChange(async () => {
const newData = await vscode.workspace.fs.readFile(document.uri);
document.data = newData;
await webview.postMessage({ type: 'load', data: newData });
});
pdflatex を実行するたびにVSCode上のプレビューが自動で更新されるので、LaTeX執筆中に重宝します。
Web Workerによるサムネイル生成(worker.ts)
サムネイルのレンダリングはWeb Workerに切り出しています。MuPDF WASMの初期化はメインスレッドをブロックしうるため、UIが固まらないようWorkerに任せています。
WASMのロード時、locateFileフックで.wasmバイナリのURLをvscode-resource:URIに差し替える必要があります。
(globalThis as Record<string, unknown>)['$libmupdf_wasm_Module'] = {
locateFile: (filename: string) =>
filename === 'mupdf-wasm.wasm' ? wasmUri : filename,
};
サムネイルはページを THUMB_SCALE = 0.2 でレンダリングし、OffscreenCanvasでJPEG(quality 0.75)に変換してdataURLとして返しています。
座標変換の落とし穴(coords.ts)
PDFとcanvasでは座標系が異なります。
| 空間 | 原点 | y軸の向き |
|---|---|---|
| PDF user space | 左下 | 上向き |
| Canvas space | 左上 | 下向き |
| MuPDF stext space | 左上 | 下向き(PDF user spaceとは異なる) |
テキスト選択の実装時、stext.highlight() / stext.copy()をそのまま使うとオフセットがずれるバグが出ました。MuPDFが返すstext座標とPDF user space座標がかみ合っていないのが原因でした。
stext.highlight()は使うのをやめて、文字単位で走査するbuildCharList()をcanvasデバイスピクセル空間で完結させる実装に切り替えました。座標変換ロジックはcoords.tsにまとめており、回転(0/90/180/270°)にも対応しています。
export function toCanvasCoord(
pdfX: number, pdfY: number,
pageWidth: number, pageHeight: number,
scale: number, dpr: number, rotation: number
): Point {
const s = scale * dpr;
let x = pdfX, y = pdfY, w = pageWidth, h = pageHeight;
// 回転を先に適用してからy軸反転
const r = ((rotation % 360) + 360) % 360;
if (r === 90) { [x, y] = [h - y, x]; [w, h] = [h, w]; }
else if (r === 180) { x = w - x; y = h - y; }
else if (r === 270) { [x, y] = [y, w - x]; [w, h] = [h, w]; }
return { x: x * s, y: (h - y) * s };
}
逆変換(toPdfCoord)も用意してあり、マウスイベントからPDF座標への変換(テキスト選択・画像ヒットテスト)で使っています。
おわりに
MuPDF WASMをVSCodeのWebview上で動かすことで、ネイティブアプリに近い描画品質のPDFビューアをTypeScript/JSだけで作れました。実装でハマったのは主に2点で、WASMバイナリのURLをvscode-resource:スキームに差し替えるlocateFileフックと、PDF / stext / canvas 三者の座標系の不一致によるテキスト選択のオフセットバグでした。
フィードバック・Issue・PRはGitHubでお待ちしています。