1. はじめに
Edge PDF
PDFを圧縮するためのWEBサービスを作りました。完全無料で利用可能です。
Rust/WebAssemblyで圧縮部分のロジックを書いているので高速で動作します。とくに、スキャンして作成されたPDFに対して優れた性能が出ると自負しています。
なぜEdge PDFを作ったか
pdfツールといえばAbobe, smallPdf, iLovePDFなど、オンライン・オフライン含めて優れたツールが揃っており、自分で作る必要性などないと思っていました。
ですが、PDFの編集に関してはいずれも優れているものの、こと圧縮となると話は別でした。
無料では1日に圧縮できる枚数に制限があったり、圧縮にかかる時間が長かったり、サーバーにアップロードしないといけなかったり、圧縮されたPDFの品質が悪かったり......。
私はよくPDFを自炊するのですが、スキャンしたPDFはサイズが大きくなりがちです(数百MBとかザラ)。
なので、これらの問題を解決するツールを作りました。
2. 技術スタック
• Rust/WebAssembly(メインのPDFを圧縮する部分): WebAssemblyを使用すれば、データをサーバーに送信することなく動作するため、アップロード・ダウンロード時間がかからず、また機密性の高い情報でも扱うことができます。またJSより高速に動作することはみなさんご存知の通りです。
では、なぜRustからWebAssemblyを使うのか。ほぼロマンです。Rustで何かを作ってみたい、その気持ちが一番大事です。結果的に高速で動作するサービスになったので満足です。
• Next.js: フロントエンド開発に使用。単純に使い慣れていることと、Cloudflare pagesで簡単にデプロイ可能なため採用。
• Cloudflare pages(ホスティング): Cloudflare pagesだけであれば、どれだけアクセスが増えても、通信量が増加しても無料です。Cloudflareはpagesでホスティングさせて、workersやR2、D1で課金させる方針のようです。
WebAssemblyを用いればバックエンドでの計算は不要であり、workersなどの利用は必要ないため、この構成ならアクセスがどれだけ集中しても完全にノーコストでサーバーを起動し続けることができます。
このCloudflare pages + WebAssemblyを用いることにより完全無料でサービスを構築できる構成は、今後も利用していきたいと思います。
3. RustによるPDF処理の実装
使用したライブラリ
-
lopdf: PDFの構造解析 -
image: 画像の再圧縮
処理の流れ
- PDFをバイトデータとして読み込み、内部の画像を抽出
- ユーザー指定の品質(JPEG)で再エンコード
- PDFのオブジェクトを差し替えて再構築
今回自分が対象としたのはスキャンされたPDF = 画像がメインのPDFです。テキストデータ主体のPDF(wordから変換したものなど)はもともとファイルサイズも小さく、圧縮するメリットが少ないです。
肝心の圧縮部分は以下のコードを使いました。
img.write_to(&mut Cursor::new(&mut *buffer),
ImageOutputFormat::Jpeg(quality)) //qualityはユーザーが選択した品質に合わせて変更
Wasmのためのメモリ最適化(Work Buffer)
WebAssembly環境では、メモリの確保と解放がオーバーヘッドになり得ます。 今回の実装では、PdfCompressor構造体に作業用バッファ(work_buffer)を持たせ、これを再利用(clear()して再利用)することで、画像処理ごとのメモリ確保を最小限に抑えています。
pub struct PdfCompressor {
// ...
#[wasm_bindgen(skip)]
work_buffer: Vec<u8>, // 100MBなどの初期容量を確保して使い回す
}
Vec::with_capacityであらかじめ大きな領域を確保しておくことで、数百MBのPDFを扱っても安定して動作するようになりました。
「圧縮したのに逆に増える」を防ぐ
画像処理はCPU負荷が高いため、無駄な処理は一切省きたいところです。そこで、実際に圧縮を行う前にBits Per Pixelを計算し、すでに十分に圧縮されている画像はスキップするようにしています。
let pixels = (width * height) as usize;
let bpp = current_size as f32 / pixels as f32; //Bits Per Pixel
if bpp < 0.05 { //通常JPEGのbppは1前後
return CompressionResult::Skipped("Already highly compressed");
}
また、「せっかく計算したのに元のサイズより増えてしまった」という悲劇を防ぐため、圧縮後のサイズを元のサイズと比較し、小さくなった場合のみPDFのオブジェクトを差し替える処理にしました。
4. Next.jsへの組み込みでの工夫
データの流れ
Web workerの実装
ここまで散々高速を謳ってきましたが、数百MBとかのPDFを圧縮するにはさすがに10秒程度はかかります。
メインスレッドでWebAssemblyを動かすと、圧縮中にブラウザが固まってしまうため、web workerを実装してフリーズしてしまうのを防ぎました。
バッチ処理による進捗の可視化
単にworkerに投げっぱなしにするのではなく、「オブジェクトを50個処理するごとに制御を返す」というバッチ処理の仕組みを実装しました。
// 50個ずつ処理を回し、その都度進捗をメインスレッドへ通知
let finished = false;
while (!finished) {
finished = compressor.processBatch(quality, 50);
self.postMessage({ type: "progress", percent: compressor.getProgress() });
}
これにより、「今何パーセント終わったか」をリアルタイムでユーザーにフィードバックできるようになっています。
Transferable Objects によるメモリ転送の最適化
大きなファイルを扱う際、Worker間でのデータの受け渡しがボトルネックになります。通常、postMessageでデータを送るとデータコピーが発生し、メモリ消費が一時的に2倍になってしまいます。
これを防ぐため、Transferable Objectsを利用しました。
// successMsgを送る際、メモリの「所有権」を譲渡する(コピーを発生させない)
self.postMessage(successMsg, { transfer: [outputBytes.buffer] });
この一行を加えると、数百MBのPDFを扱っても挙動が安定するようになりました。
5. まとめ: Rust × WebAssembly の可能性
Rust × WebAssembly は「サーバーコストを抑えつつ高度な処理をする」ための大きな選択肢となりえます。
半分以上ロマンで選んだRust/WebAssemblyでしたが、実際に形にしてみると、自己満足以上の大きなメリットがあることに気づきました。
セキュリティの向上
「ファイルをサーバーにアップロードしない」というプライバシー上の強みは、PDFという機密性の高いデータを扱うツールにおいて、既存のWebサービスとの大きな差別化要因になりえます。
結果的に、Edge PDFのUI上では、セキュアであることを全面に押し出す感じになりました。
圧倒的なコストパフォーマンス
本来ならサーバーサイドでCPUリソースを消費する重い画像処理を、すべてクライアントサイドに肩代わりしてもらうことで、Cloudflare Pagesの無料枠だけで完全に運用できる仕組みを構築できました。
6. 最後に
皆さんはWasmをどんな用途に使っていますか?コメントで教えてください!!
