Most Node.js apps that need to generate PDF reports — invoices, receipts, delivery
slips — end up picking one of two approaches:
- Launch headless Chromium (Puppeteer) and print an HTML page to PDF.
- Shell out to a separate CLI tool and parse its output.
Both work. Both also come with overhead that has nothing to do with your actual
report: Chromium's startup cost and memory footprint, or a child process's spawn
latency and fragile stdout/stderr error handling.
We just released @acrossreport/acr-node,
a native Node.js/Electron binding that renders PDF/PNG reports in-process, with no
Chromium and no subprocess involved.
What it's built on
ACR (Across Report) is a JSON-template-based report
rendering engine written in Rust on top of Google Skia. It already powered a CLI tool,
a VS Code extension, and a desktop designer app — the same renderer, same pixel output,
across all of them. acr-node is a thin napi-rs binding that exposes
that same Rust core directly to Node.js as a native addon.
Template (JSON) → layout engine → Skia renderer → PDF / PNG
- Same template produces byte-identical pixels on Windows/macOS/Linux (true WYSIWYG).
- Preview and print output match exactly — no Chromium print-to-PDF layout drift.
- A section-based layout model (PageHeader/GroupHeader/Detail/GroupFooter/PageFooter) familiar to anyone who's used traditional report designers (Crystal Reports, ActiveReports, etc.).
Why napi-rs over a CLI wrapper
We considered two designs:
A — shell out to the existing CLI tool. Simplest to build, but:
- Process-spawn overhead adds up fast on a server generating many reports.
- Error handling over stdout/stderr is brittle.
- Binary placement and permissions differ per OS.
B — native addon via napi-rs. Calls the existing Rust core (acr_core /
acr_renderer_skia) directly through FFI, no process boundary, and napi-rs has solid
prior art for both Node.js and Electron with a mature prebuilt-binary distribution
story. Since the Rust engine already existed, there was no good reason to introduce a
process boundary — we went with B.
We'd already built a similar napi-rs binding for a VS Code extension
(acr_vscode_native), so this followed an established pattern rather than starting
from zero.
Implementation notes
The binding itself doesn't touch the existing Rust rendering logic at all — it's a thin
#[napi]-annotated wrapper around functions that already existed. Two details worth
calling out:
-
Panic handling. If Rust code panics across the napi-rs boundary, it takes the
whole Node.js process down with it. A malformed template JSON is a user error, not a
reason to crash the host app, so every exported function is wrapped in
catch_unwind. -
Licensing. Unlicensed rendering adds a watermark to the output (same policy as
the CLI/Designer/Viewer tools). The activation cache file uses the same format across
all ACR products, so if a license is already activated on the machine by any of them,
acr-nodepicks it up automatically — no duplicate activation needed.
Cross-platform builds
napi-rs follows an index.<platform-triple>.node naming convention and generates a
loader (index.js) that requires the right binary for the current OS/arch:
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 ...
}
We built and bundled binaries for six platform/arch combinations on real hardware
(Windows x64, Windows ARM64, Intel Mac, Apple Silicon Mac, Ubuntu x64, Ubuntu ARM64 on
WSL2):
| Platform | Status |
|---|---|
| Windows x64 | Available |
| Windows ARM64 | Available |
| Linux x64 (glibc) | Available |
| Linux ARM64 (glibc) | Available |
| macOS (Apple Silicon) | Available |
| macOS (Intel) | Available |
Cross-building in CI isn't set up yet — this round was built machine-by-machine with
cargo build --release. Automating that is next on the list.
Usage
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 output (72dpi). Returns the page count.
const pageCount = renderPdf(templateJson, dataJson, "invoice.pdf");
// PNG-per-page array for on-screen preview (96dpi)
const pages = renderPreview(templateJson, dataJson);
// pages[0].png is a Buffer
// Activate a license key (blocking HTTP request — call from a worker in Electron)
await activate("you@example.com", "ACR-XXXX-XXXX-XXXX-XXXX");
Same API whether you're calling it from a bare Node server (Express, Next.js API
routes) or an Electron main process.
Summary
- PDF/PNG report rendering in Node.js/Electron, wired directly into a Rust/Skia engine via napi-rs — no Chromium, no subprocess.
- The existing Rust rendering logic (
acr_core/acr_renderer_skia) is untouched; only a thin binding layer was added. - Licensing is shared with the existing CLI/Designer/Viewer tools, so one activation per machine covers all of them.
- Prebuilt binaries for six platforms ship in the package — no build toolchain required on the consumer side.
Repo: https://github.com/acrossreport/acr-node
npm: https://www.npmjs.com/package/@acrossreport/acr-node
Happy to answer questions about the napi-rs setup, the panic-safety boundary, or the
Skia rendering pipeline in the comments.
Top comments (0)