はじめに
先日、開発のなかで少し長めの仕様書をVS CodeでMarkdown表示したところ、「Ctrl+Shift+V」で綺麗に表示されました。ただ、確認のために印刷しようとしたところ、プレビューの実体が「HTML」であるため印刷もPDF保存もできないことに気づき、ACRをVS Codeの機能拡張として対応させました(こちらの記事で解説しています)。
もともとACRはWindows/Mac/Linuxのマルチベンダー対応で、VS Code自体もElectronで作られています。そこで今回、Node.js対応の npm パッケージとしてもリリースすることにしました。
Node.jsやElectronアプリで帳票(請求書・納品書など)をPDF/PNGとして出力したいとき、よくある選択肢は次のどちらかだと思います。
- Puppeteer等でHeadless Chromiumを起動し、HTMLを印刷する
- 別の帳票生成ツールを子プロセスで呼び出す
どちらも動きはしますが、Chromiumプロセスの起動コストやメモリ使用量、子プロセスのエラーハンドリング・stdout/stderrのパースなど、本質的ではない複雑さを抱え込むことになります。
今回、ACR (Across Report) という帳票レンダリングエンジンのNode.js/Electron向けバインディング @acrossreport/acr-node を napi-rs で作り、npmに公開しました。この記事では、なぜこの構成にしたか、どう作ったかを書きます。
ACRとは
ACRは、JSON形式の帳票テンプレートをRust + Google Skiaで描画する、プリンタ非依存の帳票レンダリングエンジンです。
テンプレート(JSON) → レイアウトエンジン → 描画エンジン(Skia) → PDF / PNG
- 同じテンプレートがWindows/macOS/Linuxで同一のピクセル結果を生成する(WYSIWYG)
- プレビューと印刷結果が完全一致する(1ドットの狂いもない設計方針)
- 業務帳票で馴染みのある「セクション型」のレイアウトモデル(PageHeader/GroupHeader/Detail/GroupFooter/PageFooter)
これを使ったCLIツール(acr_cli)、VS Code拡張、デスクトップのデザイナーアプリなどは既に存在していましたが、「Node.js/Electronアプリに直接組み込みたい」というニーズに対する製品がありませんでした。
なぜnapi-rsか
検討したのは大きく2案です。
案A: CLIラッパー(子プロセスでacr_cliを叩く)
一番手軽ですが、以下のデメリットがあります。
- プロセス起動のオーバーヘッド(帳票を大量に生成するサーバー用途では無視できない)
- stdout/stderr経由でのエラーハンドリングが脆い
- Windows/macOS/Linuxでバイナリの配置・権限周りの考慮が増える
案B: napi-rsでネイティブアドオン化
- RustのACRコア(
acr_core/acr_renderer_skia)をそのままFFI越しに呼べる - プロセス境界がないため、オーバーヘッドが小さい
- napi-rsはNode.js/Electron両対応の実績が厚く、prebuilt binaryの配布の仕組みが整っている
既にRust実装のACRエンジンがある以上、わざわざプロセス境界を挟む理由がなく、案Bを採用しました。
実は社内にはVS Code拡張向けに同様のnapi-rsバインディング(acr_vscode_native)が既にあったため、そのパターンを踏襲する形で実装できました。
実装の方針
やったことはシンプルで、既存のRustクレート(acr_core / acr_renderer_skia)のロジックには一切手を入れず、#[napi] マクロで薄いラッパー関数を被せただけです。既存製品(CLI/VS Code拡張)と挙動が食い違うリスクを避けられますし、実装量も最小限で済みました。
いくつか気をつけた点だけ挙げておきます。
-
panicのハンドリング: napi-rs境界を越えてRust側がpanicすると、Node.jsプロセスごと落ちてしまいます。テンプレートJSONが壊れている等のユーザー起因のエラーでアプリ全体が落ちるのは避けたいので、公開関数は
catch_unwindで包んでいます。 - ライセンスの扱い: 無償利用はできますが、既存のCLIツールと同じ方針で、ライセンスキー未登録時は出力にウォーターマークが入ります。ライセンスのキャッシュファイルは他のACR製品(CLI/Designer/Viewer)と共通フォーマットなので、同じマシンで既にどれか1つアクティベート済みなら、acr-nodeは自動的にそのライセンスを使います。
クロスプラットフォームビルド
napi-rsは index.<platform-triple>.node という命名規則でプラットフォームごとのバイナリを配置し、読み込み時にOS/CPUを見て適切なものをrequireするローダー(index.js)を生成してくれます。
switch (platform) {
case 'win32':
switch (arch) {
case 'x64':
nativeBinding = require('./index.win32-x64-msvc.node');
break;
case 'arm64':
nativeBinding = require('./index.win32-arm64-msvc.node');
break;
}
break;
case 'linux':
// glibc/musl、x64/arm64 の判定...
case 'darwin':
// x64/arm64 の判定...
}
今回は以下の6プラットフォーム分を、それぞれ実機(Windows x64機、Windows ARM64機、Intel Mac、Apple Silicon Mac、Ubuntu x64、Ubuntu ARM64 on WSL2)でビルドして同梱しました。
| プラットフォーム | 状態 |
|---|---|
| Windows x64 | ✅ |
| Windows ARM64 | ✅ |
| Linux x64 (glibc) | ✅ |
| Linux ARM64 (glibc) | ✅ |
| macOS (Apple Silicon) | ✅ |
| macOS (Intel) | ✅ |
CI(GitHub Actions)でのクロスビルドは未整備で、今回は手元の実機を一台ずつ回って cargo build --release した結果を集めました。次の課題はここの自動化です。
使い方
npm install @acrossreport/acr-node
import { renderPdf, renderPreview, renderPngZip, listDataFields, activate, licenseStatus } from "@acrossreport/acr-node";
import { readFileSync } from "node:fs";
const templateJson = readFileSync("invoice.template.json", "utf8");
const dataJson = readFileSync("invoice.data.json", "utf8");
// PDF出力(72dpi)。戻り値はページ数。
const pageCount = renderPdf(templateJson, dataJson, "invoice.pdf");
// 画面プレビュー用PNG配列(96dpi)
const pages = renderPreview(templateJson, dataJson);
// pages[0].png は Buffer
// ライセンスキーのアクティベート(同期HTTPリクエスト)
await activate("you@example.com", "ACR-XXXX-XXXX-XXXX-XXXX");
Electronのメインプロセスでも、素のNode.jsサーバー(Express、Next.jsのAPI route等)でも、同じAPIで動きます。
まとめ
- Node.js/Electronでの帳票PDF/PNG生成を、Chromiumにも子プロセスにも頼らず、napi-rs経由でRust製の描画エンジンに直結した
- 既存のRustロジック(
acr_core/acr_renderer_skia)には手を入れず、薄いバインディング層だけを追加した - ライセンス周りも既存製品と共通化し、同一マシン上での重複アクティベートを避けられるようにした
- 6プラットフォーム分のプリビルドバイナリを同梱し、利用側には追加のビルド環境を要求しない
リポジトリ: https://github.com/acrossreport/acr-node
npm: https://www.npmjs.com/package/@acrossreport/acr-node