作ってみました。下記ページ開くと TIFF 画像が WebAssembly で PDF に変換されてダウンロードされます。
動機
わが社ではいにしえの図面は TIFF 画像でサーバに置かれています。一方で最近の図面は PDF なので、違うビューワが立ち上がってしまうので作業効率が悪いです。ので、TIFF 画像を自動的に PDF にへんかんする CLI ツールを作りました。このツールは Rust で書かれていて、FFI で libtiff の API を叩いています。WebAssembly に興味があったので 「wasm-pack を使えば、この CLI ツールも簡単に WebAssembly 化してブラウザ上で実行できるんだろう」と試してみたら、いくつか詰まったところがあったので記事にしておきます。
WebAssembly 化する方法
2023年現在 Rust を WebAssembly にするには以下の方法があるようです。
- wasm-pack/wasm32-unknown-unknown
- cargo-wasi/wasm32-wasi
- emsdk/wasm32-unknown-emscripten
wasm-pack/wasm32-unknown-unknown
2023年現在 wasm-pack では C への FFI を含む Rust を WebAssembly 化するのは不可能のようです。たぶんこれからも無理そうです。わたしも下記の記事同様 env の罠にかかって wasm-pack での WebAssembly 化はあきらめました。
cargo-wasi/wasm32-wasi
下記の記事を参考に llvm-ar と WASI で WebAssembly 化できます。node 上で wasm 化した tiff2pdf は実行できました。これを webpack しようとすると失敗するので、これを解決するのが最近のわたしの課題です。(追記:webpack できたので後述します。)
emsdk/wasm32-unknown-emscripten
emsdk を使用することでブラウザ上で tiff2pdf できる WebAssembly が生成できました。下記の記事を参考にしました。
リポジトリはこちら。JPEG とか ZIP のサポートは省いているので変換できない TIFF 画像はいっぱいあると思います。
libtiff
Read and write on memory
libtiff の tiff2pdf は fopen とか fclose を使ってファイルの読み書きをしていました。WebAssembly 化するにあたってメモリ上で IO する必要があると思ったので、ここを Rust で書きました。(emscripten にはメモリファイルシステムが用意されているとも知らずに…)
この stackoverflow の回答を参考にしています。
Read はこう。
struct Input<'a>(Cursor<&'a [u8]>);
// tiff 変数に &[u8] の TIFF 画像データが入っている
let input_memory = Input(Cursor::new(tiff));
// 名前は適当で良い
let name = CString::new("MemoryInput")?;
// Read only モードで開く
let mode = CString::new("rm")?;
let input_tiff = unsafe {
TIFFClientOpen(
name.as_ptr(),
mode.as_ptr(),
&input_memory as *const _ as _, // コールバック関数に渡すデータ
Some(input_read),
Some(input_write),
Some(input_seek),
Some(dummy_close), // close, size, map, unmap は使用しないので空の関数で良い
Some(dummy_size),
Some(dummy_map),
Some(dummy_unmap),
)
};
unsafe extern "C" fn input_read(handle: thandle_t, buf: tdata_t, size: tmsize_t) -> tmsize_t {
// TIFFClientOpen の第 3 引数に渡したデータ
let input_memory = &mut *(handle as *mut Input);
// libtiff が用意しているバッファを slice に変換
let buf = slice::from_raw_parts_mut(buf as *mut u8, size as _);
// その slice に TIFF データを読み込む
input_memory.0.read_exact(buf).expect("failed to read.");
// 読み込んだバイト数を返す
size
}
unsafe extern "C" fn input_write(_: thandle_t, _: tdata_t, _: tmsize_t) -> tmsize_t {
0
}
unsafe extern "C" fn input_seek(handle: thandle_t, offset: u64, whence: i32) -> u64 {
let input_memory = &mut *(handle as *mut Input);
let pos = match whence {
// TIFF データの先頭からのオフセット
0 => SeekFrom::Start(offset as _),
// TIFF データの Cursor の現在位置からのオフセット
1 => SeekFrom::Current(offset as _),
// TIFF データの末尾からのオフセット
2 => SeekFrom::End(offset as _),
_ => unimplemented!(),
};
// Cursor 位置を動かして、現在の位置を返す
input_memory.0.seek(pos).expect("failed to seek")
}
Rust には Vec、Cursor などがあるので C より簡単に書けますね。
続いて Write。
struct Output<'a>(Cursor<&'a mut Vec<u8>>);
// この Vec に PDF データが書き込まれる
let mut buf = vec![];
let mut output_memory = Output(Cursor::new(&mut buf));
let name = CString::new("MemoryOutput")?;
// Write モードで開く
let mode = CString::new("w")?;
let output_tiff = unsafe {
TIFFClientOpen(
name.as_ptr(),
mode.as_ptr(),
&output_memory as *const _ as _,
Some(output_read),
Some(output_write),
Some(output_seek),
Some(dummy_close),
Some(dummy_size),
Some(dummy_map),
Some(dummy_unmap),
)
};
unsafe extern "C" fn output_read(_: thandle_t, _: tdata_t, _: tmsize_t) -> tmsize_t {
0
}
unsafe extern "C" fn output_write(handle: thandle_t, data: tdata_t, size: tmsize_t) -> tmsize_t {
let output_memory = &mut *(handle as *mut Output);
// libtiff が生成した PDF データを slice にする
let data = slice::from_raw_parts(data as *const u8, size as _);
// そのデータを上で用意した Vec に書き込む
output_memory.0.write_all(data).expect("failed to write.");
size
}
unsafe extern "C" fn output_seek(_: thandle_t, offset: u64, _: i32) -> u64 {
offset
}
これでメモリ上で読み書きできるようになりました。
t2p_write_pdf
上記で準備したinput_tiff
とoutput_tiff
をt2p_write_pdf
関数に渡すだけです。第 1 引数はt2p_init
で取得した T2P 構造体へのポインタです。
let t2p = unsafe { t2p_init() };
let ret = unsafe { t2p_write_pdf(t2p, input_tiff, output_tiff) };
しかし、これではうまく行きません。TIFFClientOpen は書き込みモードで開くと最初の 4 バイトに TIFF ヘッダを書き込んで、Cursor のオフセットを 4 進めてしまうのです。ので、t2p_write_pdf
を呼ぶ前に Cursor を 0 に戻す必要があります。
let output_tiff = unsafe {
TIFFClientOpen(
// 省略
)
};
// Cursor 位置を 0 に戻す
output_memory.0.set_position(0);
let ret = unsafe { t2p_write_pdf(t2p, input_tiff, output_tiff) };
エラー処理、メモリの自動開放も追加して libtiff-sys の lib.rs はこうなりました。
Emscripten
上記の libtiff-sys のgenerate_pdf
は引数に slice を取って、戻り値は anyhow::Result に包まれた Vec となっていますが、これを Emscripten で扱うのは面倒なので C の関数っぽくラップします。MDN に WebP エンコーダを WebAssembly にする例があったので参考にしました。
// 生成した PDF データを保存しておくグローバル変数
static BUF: Global<ManuallyDrop<Vec<u8>>> = Global(RefCell::new(None));
struct Global<T>(RefCell<Option<T>>);
unsafe impl<T> Sync for Global<T> {}
// no_mangle 属性でマングリングされないようにする
// 第 1 引数は TIFF データへのポインタ
// 第 2 引数は TIFF データの長さ
// 返り値は PDF データへのポインタ
#[no_mangle]
pub fn generate_pdf(ptr: *const u8, len: usize) -> *const u8 {
// TIFF データを slice にする
let tiff = unsafe { slice::from_raw_parts(ptr, len) };
// PDF に変換
let pdf = libtiff_sys::generate_pdf(tiff).expect("failed to generate pdf.");
// 関数抜けたときに PDF データが開放されないように ManuallyDrop に包む(必要ないかも。明示的だからいいか)
let pdf = ManuallyDrop::new(pdf);
let ptr = pdf.as_ptr();
// グローバル変数に PDF データを保存
*BUF.0.borrow_mut() = Some(pdf);
ptr
}
そして、EMCC_CFLAGS
に-s EXPORTED_FUNCTIONS=['_generate_pdf']
を追加して cargo でビルドしてやると JavaScript からgenerate_pdf
関数を呼べるようになります。関数名の前に_
を追加するルールのようです。
export EMCC_CFLAGS="-O3 -o t2p.js -s EXPORTED_FUNCTIONS=['_generate_pdf','_buf_len','_free_buf','_malloc','_free'] -s ALLOW_MEMORY_GROWTH=1 -s EXPORTED_RUNTIME_METHODS=ccall"
cargo build --release --target wasm32-unknown-emscripten
続いて JavaScript 側の実装です。ccall
関数を使って WebAssembly の関数を呼びます。第 1 引数が関数名。第 2 引数が返り値の型。第 3 引数が引数の型。第 4 引数が関数に渡す引数となっています。
const result_ptr = ccall("generate_pdf", "number", ["number", "number"], [ptr, array.length]);
ccall
関数で使える型としてはstring
、number
、array
があるようです。当初array
を使って TIFF データを渡そうとしましたが、これはスタックオーバーフローしてうまくいきませんでした。ので、大きなデータを WebAssembly 側に渡す場合にはヒープを使う必要があります。
// TIFF データを取得
const response = await fetch("test.tif");
const data = await response.arrayBuffer();
const array = new Uint8Array(data);
// WebAssembly 側にヒープ領域を確保
const ptr = Module._malloc(array.length);
// TIFF データを WebAssembly 側のヒープにコピー
Module.HEAPU8.set(array, ptr);
これで TIFF データを WebAssembly に渡して PDF に変換できました。次に WebAssembly 側のヒープにある PDF データを JavaScript 側にもってくる必要があります。generate_pdf
関数は PDF データへのポインタを返すので、あとは PDF データの長さがあればどうにかなりそうです。ので、PDF データの長さを返す関数を Rust で書きます。
#[no_mangle]
pub fn buf_len() -> usize {
BUF.0.borrow().as_ref().expect("no buffer.").len()
}
buf_len
関数を JavaScript 側から呼び出すと PDF データのサイズを取得できます。前述の PDF データへのポインタとサイズでもって、WebAsssembly 側のヒープへの view を作り、データを JavaScript 側にコピーします。
// PDF データのサイズ取得
const len = ccall("buf_len", "number", [], []);
// WebAssembly 側のヒープへの view を作る
const view = new Uint8Array(Module.HEAPU8.buffer, result_ptr, len);
// JavaScript 側にデータをコピー
const result = new Uint8Array(view);
ついに、JavaScript 側に PDF データを持ってこれました!
あとは WebAssembly 側のヒープデータの後始末とか PDF のダウンロード処理を追加して index.html はこうなりました。
WASI
こちらが WASI 版のもの。
リポジトリはこちら。
@wasmer/wasi
WASI を Node やブラウザ上で実行するためのライブラリです。インメモリのファイルシステムがあるので TIFF 画像や、成果物の PDF はこのファイルシステムを使って Rust 側とやり取りしました。
// インメモリファイルを開いて
let file = wasi.fs.open("/input", {read: true, write: true, create: true});
// TIFF 画像を書き込む
file.write(tiff);
file.seek(0);
// Rust の main 関数が呼ばれる
wasi.start();
// Rust 側では "/output" に PDF データを書き込むので JavaScript 側に読み込む
const out = wasi.fs.open("/output", {read: true, write: false, create: false});
const pdf = out.read();
// PDF データの後始末
wasi.fs.removeFile("/output");
fn main() -> Result<()> {
// TIFF データの読み込み
let tiff = fs::read("/input")?;
// PDF へ変換
let pdf = generate_pdf(&tiff)?;
// インメモリファイルシステムに書き込む
fs::write("/output", &pdf)?;
Ok(())
}
webpack
Node で動くようになったので webpack でブラウザ用にまとめます。webpack.config.js はこうなりました。
const webpack = require("webpack");
module.exports = {
entry: ["./index.mjs"],
// Buffer がないって言われるので追加(あんまり意味わかってないので後で調べる)
plugins: [
new webpack.ProvidePlugin({
Buffer: ["buffer", "Buffer"]
})
],
resolve: {
fallback: {
"buffer": require.resolve("buffer")
}
},
// wasmer_wasi_js_bg.wasm がないって言われるので無視する
externals: {
"wasmer_wasi_js_bg.wasm": true
},
// index.mjs の generate_pdf を index.html から window.generate_pdf() で呼べるようにする
output: {
library: "generate_pdf",
libraryTarget: "window",
libraryExport: "default",
}
};
まとめ
stdio.h とかを使っている C モジュールを FFI で使う Rust を WebAssembly 化するのはつらい。けど、どうにかなった