👓

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 に変換するコードを書きます。

Cargo.toml
[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"
src/lib.rs
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に下記を書き込んでおきます。

.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.jspdf_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.jspdfium.wasmmanifest.jsonと同じディレクトリに保存してください。さらに上記で生成したpdf_preview.jspdf_preview_bg.wasmも同じディレクトリに保存しておきます。

manifest.json
{
	"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-renderpdfium.wasmをバインディングするためのコードと、リンクにマウスオーバーしたときにプレビューをポップアップするコードを書いていきます。ますバインディングするところは下記のようになります。

content.js
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();
})();

マウスオーバーでプレビュー表示するところは下記のようになります。

content.js
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 全体はこちら
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の関数内を下記のように書き換えます。

pdfium.js
- function findWasmBinary(){return locateFile("pdfium.wasm")}
+ function findWasmBinary(){return locateFile(chrome.runtime.getURL("pdfium.wasm"))}

以上。

Discussion