DEV Community

Hisa Araki
Hisa Araki

Posted on

Rendering PDF/PNG reports in Node.js without Chromium or a subprocess (napi-rs)

Most Node.js apps that need to generate PDF reports — invoices, receipts, delivery
slips — end up picking one of two approaches:

  1. Launch headless Chromium (Puppeteer) and print an HTML page to PDF.
  2. 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
Enter fullscreen mode Exit fullscreen mode
  • 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-node picks 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 ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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");
Enter fullscreen mode Exit fullscreen mode

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)