PDFium をブラウザ拡張機能に埋め込む
PDF へのリンクが貼ってあるページで、そのリンクにマウスカーソルを合わせると PDF のプレビューが見られるようにしたかったのでブラウザ拡張機能を作ってみました。
成果物↓ fetchで PDF をダウンロードして WebAssembly で PNG に変換しています。デモページはこちらですが、 もちろんリンクが貼ってあるだけでプレビューは表示されません。

環境
Google Chrome 141.0.7390.126
PDFium 7243
pdfium-render 0.8.35
PDFium
PDFium は Chrome で使われている PDF レンダリングエンジンです。いろんなプラットフォーム向けにビルドしてくれている人がいるのでこちらのバイナリを使います。
pdfium-render
pdfium-render は PDFium の Rust バインディングです。pdfium-render は WebAssembly へのビルドにも対応しているので、PDFium を Web ページに埋め込んだり、今回のようにブラウザ拡張にすることも可能です。
PDF を PNG に変換するコード
まず pdfium-render を使って PDF を PNG に変換するコードを書きます。
[package]
name = "pdf-preview"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
image = { version = "0.25.8", default-features = false, features = ["png"] }
pdfium-render = "0.8.35"
wasm-bindgen = "0.2.105"
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"
use image::ImageFormat::Png;
use pdfium_render::prelude::*;
use std::cell::RefCell;
use std::io::Cursor;
use wasm_bindgen::prelude::*;
mod error;
use error::Error;
thread_local! {
// Pdfium を保持しておくためのグローバル変数
static PDFIUM: RefCell<Option<Pdfium>> = RefCell::new(None);
}
// PDF バイナリを受け取って、PNG を返す
fn preview_impl(pdf: &[u8]) -> Result<Vec<u8>, Error> {
PDFIUM.with(|p| -> Result<_, Error> {
let pdfium = p.borrow();
let pdfium = pdfium.as_ref().ok_or(Error::Nil)?;
let config = PdfRenderConfig::new()
.set_target_width(800)
.set_maximum_height(800);
let mut buf = vec![];
pdfium
.load_pdf_from_byte_slice(pdf, None)?
.pages()
.first()?
.render_with_config(&config)?
.as_image()
.write_to(Cursor::new(&mut buf), Png)?;
Ok(buf)
})
}
#[wasm_bindgen]
pub fn preview(pdf: &[u8]) -> Vec<u8> {
preview_impl(pdf).unwrap()
}
// Pdfium の初期化処理
fn init_impl() -> Result<(), Error> {
let pdfium = Pdfium::new(Pdfium::bind_to_system_library()?);
PDFIUM.set(Some(pdfium));
Ok(())
}
#[wasm_bindgen]
pub fn init() {
init_impl().unwrap();
}
この Rust コードをwasm32-unknown-unknownターゲットでビルドします。.cargo/config.tomlに下記を書き込んでおきます。
[build]
target = "wasm32-unknown-unknown"
cargo build --releaseでビルドするとtarget/wasm32-unknown-unknown/releaseディレクトリにpdf_preview.wasmが生成されているはずです。
wasm-bindgen
生成したpdf_preview.wasmを JavaScript から呼び出せるように wasm-bindgen-cli を導入します。下記コマンドでインストールするとwasm-bindgenなどのコマンドが実行できるようになります。
cargo install -f wasm-bindgen-cli
pdfium.wasmをバインドするには--target no-modulesオプションを指定する必要があるとのこどなので、wasm-bindgenは下記のように実行します。
wasm-bindgen --target no-modules --out-dir pkg/ target/wasm32-unknown-unknown/release/pdf_preview.wasm
すると、pkg/ディレクトリにpdf_preview.jsとpdf_preview_bg.wasmが生成されます。
wasm-opt
binaryen は WebAssembry 用のコンパイラ・ツールチェインを提供しています。その中のwasm-optを使ってpdf_preview_bg.wasmを最適化します。プリビルドバイナリが配布されているので適当に解凍してwasm-optにパスを通しておきます。そして下記のようにコマンドを実行してバイナリを最適化します。
wasm-opt pkg/pdf_preview_bg.wasm -O2 -o pkg/pdf_preview_bg.wasm
manifest.json
ここからブラウザ拡張機能の作成に取り掛かります。manifest.jsonは下記のようになります。ここからダウンロードしたpdfium.jsとpdfium.wasmをmanifest.jsonと同じディレクトリに保存してください。さらに上記で生成したpdf_preview.jsとpdf_preview_bg.wasmも同じディレクトリに保存しておきます。
{
"manifest_version": 3,
"name": "pdf-preview",
"version": "0.1.0",
"description": "generate pdf thumbnail using pdfium.wasm",
"content_scripts": [{
"matches": ["https://zxrs.github.io/pdf-preview/*"],
"js": [
"pdfium.js",
"pdf_preview.js",
"content.js"
],
"all_frames": true
}],
"web_accessible_resources": [
{
"resources": [
"pdf_preview_bg.wasm",
"pdfium.wasm"
],
"matches": ["<all_urls>"]
}
]
}
拡張機能用pdf-previewディレクトリ内の構成は下記のようになります。
pdf-preview/
├── content.js
├── manifest.json
├── pdfium.js
├── pdfium.wasm
├── pdf_preview_bg.wasm
└── pdf_preview.js
content.js
content.jsにはpdfium-renderがpdfium.wasmをバインディングするためのコードと、リンクにマウスオーバーしたときにプレビューをポップアップするコードを書いていきます。ますバインディングするところは下記のようになります。
const App = {};
let PdfiumModule;
let RustModule;
(async () => {
// pdfium.wasm の初期化
PdfiumModule = await PDFiumModule();
// initialize_pdfium_render は pdfium-render crate がエクスポートしている関数
// preview と init 関数は自分で書いた Rust コードのエクスポート
const {initialize_pdfium_render, preview, init} = wasm_bindgen;
// preview 関数をグローバル変数に保存しておく
App.preview = preview;
// Rust で生成した wasm の初期化
RustModule = await wasm_bindgen(chrome.runtime.getURL("pdf_preview_bg.wasm"));
// pdfium.wasm と Rust 製 wasm のバインディング処理
console.assert(initialize_pdfium_render(PdfiumModule, Module, false), "failed to initialize pdfium_render");
// pdf-preview の初期化
init();
})();
マウスオーバーでプレビュー表示するところは下記のようになります。
const onmouseover = async (e) => {
// 省略
// url は PDF ファイルの URL
const res = await fetch(url);
// Uint8Array にして preview 関数にわたす
const pdf = await res.arrayBuffer();
// PNG に変換
const png = App.preview(new Uint8Array(pdf));
const blob = new Blob([png], {type: "image/png"});
const data = URL.createObjectURL(blob);
// Image オブジェクトに読み込んでポップアップ表示
const img = new Image();
img.src = data;
img.onload = () => {
// 省略
document.body.appendChild(img);
};
};
content.js 全体はこちら
const App = {};
let PdfiumModule;
let RustModule;
const Cache = new Map();
const id = "pdf-preview";
const max_image_size = 640;
const show_preview = (e, data) => {
const img = new Image();
img.id = id;
img.src = data;
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > max_image_size) {
height = height * max_image_size / width;
width = max_image_size;
}
} else {
if (height > max_image_size) {
width = width * max_image_size / height;
height = max_image_size;
}
}
let css = [];
css.push("width:" + width + "px;");
css.push("height:" + height + "px;");
css.push("border:1px solid gray;");
css.push("box-shadow:0px 0px 8px 4px rgba(0,0,0,0.4);")
css.push("position:fixed;");
css.push("z-index:10001;");
let top = e.clientY + 12;
let left = e.clientX + 12;
if (window.innerHeight - e.clientY - 24 < height) {
top = window.innerHeight - height - 12;
}
css.push("top:" + top + "px;");
css.push("left:" + left + "px;");
img.style.cssText = css.join("");
document.body.appendChild(img);
};
};
const close_preview = () => {
while (image = document.getElementById(id)) {
document.body.removeChild(image);
}
};
const onmouseover = async (e) => {
close_preview();
if (e.target.tagName !== "A") {
return;
}
const url = e.target.href;
if (Cache.has(url)) {
show_preview(e, Cache.get(url));
return;
}
const res = await fetch(url);
const pdf = await res.arrayBuffer();
const png = App.preview(new Uint8Array(pdf));
const blob = new Blob([png], {type: "image/png"});
const data = URL.createObjectURL(blob);
Cache.set(url, data);
show_preview(e, data);
};
const onmouseout = () => {
close_preview();
};
(async () => {
PdfiumModule = await PDFiumModule();
const {initialize_pdfium_render, preview, init} = wasm_bindgen;
App.preview = preview;
Module = await wasm_bindgen(chrome.runtime.getURL("pdf_preview_bg.wasm"));
console.assert(initialize_pdfium_render(PdfiumModule, Module, false), "failed to initialize pdfium_render");
init();
})();
window.addEventListener("mouseover", onmouseover);
window.addEventListener("mouseout", onmouseout);
pdfium.js
ここでダウンロードしたpdfium.jsはそのままではブラウザ拡張機能内で動かないので一部改造します。findWasmBinaryの関数内を下記のように書き換えます。
- function findWasmBinary(){return locateFile("pdfium.wasm")}
+ function findWasmBinary(){return locateFile(chrome.runtime.getURL("pdfium.wasm"))}
以上。
Discussion