1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MuPDF WASMでVSCode拡張機能を自作・公開した話 — MuPDF Viewer

1
Last updated at Posted at 2026-04-21

はじめに

VSCodeでPDFを開こうとしたとき、「ビルトインのプレビューがない」「別アプリに切り替えるのが地味に面倒」と感じたことはないでしょうか。

既存の拡張機能もいくつか存在しますが、PDF.jsベースのものが多く、MuPDFのレンダリング品質を直接Webview上で使えるものは見当たりませんでした。そこで、MuPDFをWebAssemblyにコンパイルしたWASMバインディングをベースに、ゼロから作って公開しました。

なぜ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)。高いほど鮮明だがメモリ消費増

高解像度ディスプレイ環境では 144192 あたりを試してみてください。

インストール

VSCode Quick Open(Ctrl+P)を開き、以下を貼り付けてEnterを押してください。

ext install skrtk98.mupdf-viewer

またはMarketplaceから直接インストールすることもできます。

実装の解説

技術スタック

  • 言語: TypeScript 5.3
  • バンドラ: esbuild(esbuild.mjsでカスタム設定)
  • テスト: Vitest
  • PDFエンジン: mupdf npm 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)
  // ...

localResourceRootsmedia/ ディレクトリだけに絞っており、拡張機能のリソースに余計なアクセスが通らないようにしています。

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でお待ちしています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?