
Security News
Deno Goes the Way of the Dinosaur
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.
@reportwright/engine
Advanced tools
ReportWright report engine: JSON report definitions to pages, PDF (incl. PDF/A), Word, Excel, PowerPoint, HTML and CSV. Node and browsers. Fonts: @reportwright/fonts; CLI: @reportwright/cli.
The ReportWright report engine for Node and browsers: a JSON report definition and data in, pages out, then PDF (also PDF/A-2b), Word, Excel, PowerPoint (exportPptx), HTML or CSV. The command line is @reportwright/cli (pw).
npm i @reportwright/engine @reportwright/fonts
Save this as statement.pw.json (a minimal report; the repository's examples/ has more):
{
"$schema": "pagewright/report@1",
"name": "Statement",
"page": { "size": "A4", "margins": [36, 36, 36, 36] },
"parameters": [{ "name": "accountId", "type": "string" }],
"dataSources": [{ "name": "ledger", "type": "json", "data": { "rows": [{ "item": "Design", "amount": 1200 }, { "item": "Hosting", "amount": 99.5 }] } }],
"dataSets": [{ "name": "Lines", "source": "ledger", "path": "$.rows" }],
"sections": { "body": { "items": [
{ "type": "textbox", "name": "Title", "x": 0, "y": 0, "w": 400, "h": 24, "value": "Statement", "style": { "fontSize": 18, "fontWeight": "bold" } },
{ "type": "table", "name": "Table", "x": 0, "y": 40, "w": 400, "h": 60, "dataSet": "Lines",
"columns": [{ "width": 250 }, { "width": 150 }],
"header": [{ "height": 18, "cells": [{ "value": "Item" }, { "value": "Amount", "style": { "textAlign": "right" } }] }],
"detail": [{ "height": 18, "cells": [{ "value": "=Fields.item" }, { "value": "=Fields.amount", "style": { "format": "C2", "textAlign": "right" } }] }],
"footer": [{ "height": 18, "cells": [{ "value": "Total" }, { "value": "=Sum(Fields.amount)", "style": { "format": "C2", "textAlign": "right", "fontWeight": "bold" } }] }] }
] } }
}
import { render, defaultFontStore, exportPdf } from '@reportwright/engine';
import fs from 'node:fs';
// the fonts, the text shaper (Arabic, Hebrew, Indic, Thai…) and the PDF subsetter come from @reportwright/fonts;
// for your own fonts: new FontStore((key) => bytesOf(fontFile(key)))
const fonts = defaultFontStore();
const model = await render(JSON.parse(fs.readFileSync('statement.pw.json', 'utf8')), { fontStore: fonts, parameters: { accountId: 'LN-1' } });
// fonts are subset by default (only the glyphs the PDF uses); { subset: false } embeds every font in full
fs.writeFileSync('statement.pdf', await exportPdf(model, { fonts, title: 'Statement' }));
// a bundled browser app (Vite, webpack, Next client code): copy the fonts folder to your site and point at it, since the
// package's location is not known to the bundler. `cp -r node_modules/@reportwright/fonts/public/fonts public/pw/fonts`
const bundledFonts = defaultFontStore({ fontsUrl: '/pw/fonts/' }); // a missing font is an error; a URL or, in Node, a path
// the same options object everywhere: exportXlsx(model, { fonts }), exportDocx(model, { fonts }), exportPptx(model, { fonts })
// Excel, CSV, Word, interactive HTML and the accessible PDF rebuild tables from the table data, which render() keeps
// only when asked: render(def, { exportData: true }) (needsExportData(format) says which formats need it)
fields, they are detected from the rows, and a string that looks like an ISO
date (2024-01-31, 2024-01-31T10:00:00Z) becomes a date. Keep such strings as text with detectDates: false on
the data set, or declare the fields and give that one type: 'string'.colorByCategory: true on charts gives each category (pie,
donut and funnel slices; seriesGroup series) one colour in every chart of the report that sets it.rest, graphql, soap, odata, and csv or xml with a URL) and SQL sources without
sql make requests, through render(def, { fetch }). Without that option the engine uses globalThis.fetch behind
an SSRF guard: http(s) only; no loopback, private, link-local, CGNAT, multicast or reserved address in any spelling
(2130706433, 0x7f.1, [::ffff:127.0.0.1]), no localhost name; in Node (20.16+) the request goes through
node:http(s) with the checked address pinned (no DNS rebinding; a failed lookup refuses); every redirect hop checked
again (at most 5; never https → http; another origin gets none of the source's headers); a parameter fills a URL's path and query, never its scheme and host (unless the
whole URL is a parameter and its host is in allowHosts). allowHosts: ['10.0.0.5'] lets named hosts through;
unsafeFetch: true turns the guard off. Pass your own fetch to add auth, a proxy or an allow-list, or to forbid
requests altogether: render(def, { fetch: () => { throw new Error('no network') } }).fetchTimeoutMs (30 s, never past the render's
timeoutMs) and after maxFetchBytes (64 MB, counted decoded, so a gzip bomb stops too). Exporters take
timeoutMs as well; the PDF exporter checks every PNG before decoding it and leaves a damaged one out (onWarning).C2 with no currency on the report uses the locale's own (en-IN ₹, de-DE €, en-US $).exportPdfStream(definition, options, sink) writes the PDF as it is laid out: a flowing table is laid out a window of
rows at a time and each page leaves memory once it is written, so a million-row report's PDF needs about the memory of a
few pages (measured: peak RSS flat from 20,000 to 300,000 rows, about 250 MB for the whole Node process; most of it is
garbage V8 has not collected yet, not rows: it does not shrink with a smaller window, and
node --max-old-space-size=128 holds a 300,000-row export to about 180 MB). The file reads as
exportPdf's: the same pages, text and positions.
import fs from 'node:fs';
import { exportPdfStream } from '@reportwright/engine';
const out = fs.createWriteStream('ledger.pdf');
const r = await exportPdfStream(definition, { parameters: { year: 2026 }, title: 'Ledger' }, out);
out.end();
console.log(r.streamed ? `${r.pages} pages, streamed` : `written whole: ${r.why}`);
Bytes in memory instead of a file. exportPdf(model) takes a page model that render() has already built, so it
cannot stream: render + exportPdf holds every page (measured on a flowing 4-column table: 435 MB peak RSS at 100,000
rows, against 273 MB streamed with the rows themselves in the definition). For a large report that you want as bytes, give
exportPdfStream a sink that keeps the chunks; it renders the report whole itself when it cannot stream (r.why).
render() warns once per render when a data set has more rows than its largeReportRows option (default 100,000; 0
or false turns it off): Large report (N rows): … in model.warnings, and on the console once per process.
const parts = [];
const r = await exportPdfStream(definition, { title: 'Ledger' }, { write: (b) => { parts.push(b); } });
const bytes = Buffer.concat(parts); // the same pages as render + exportPdf
A failed export leaves a damaged file. The PDF is written as it goes, so an error, a timeout or an abort halfway leaves a partial file at the path. Write to a temporary name and rename it when the export has succeeded:
import fs from 'node:fs';
import { finished } from 'node:stream/promises';
const tmp = `${file}.part`;
const out = fs.createWriteStream(tmp);
try {
await exportPdfStream(def, opts, out);
await finished(out.end());
await fs.promises.rename(tmp, file);
} catch (e) {
out.destroy();
await fs.promises.rm(tmp, { force: true });
throw e;
}
Rows from a database (sqlRows). sqlRows(source, params, { signal }) returns an async (or sync) iterable of
arrays of rows, a batch at a time; a single row is an error. This one reads a PostgreSQL cursor with pg and
pg-cursor, releases the client when the export ends or stops, and checks signal between batches:
import pg from 'pg';
import Cursor from 'pg-cursor';
const pool = new pg.Pool();
const sqlRows = async function* (source, params, { signal }) {
const client = await pool.connect();
const cursor = client.query(new Cursor(source.query, Object.values(params)));
try {
for (;;) {
signal?.throwIfAborted(); // stop between batches when the export is cancelled or times out
const rows = await cursor.read(1000); // one batch of up to 1,000 rows
if (!rows.length) break;
yield rows;
}
} finally {
await cursor.close().catch(() => {});
client.release();
}
};
await exportPdfStream(def, { sqlRows, timeoutMs: 120_000 }, out);
Word and Excel, streamed. exportDocxStream(definition, options, sink) and exportXlsxStream(definition, options, sink) take the same sink, timeoutMs, signal, sqlRows, maxRows and spill: Word lays out a window of rows at a
time and writes each page's XML into the zip as it comes, Excel writes the sheet row by row, so neither holds the rows,
the pages or the XML (measured: live heap 17 MB after 20,000 and after 50,000 rows; a 50,000-row Word export runs in
node --max-old-space-size=48). A report that cannot stream is rendered whole and written in one piece (streamed: false, and why). exportDocx and exportXlsx still take a rendered model.
Options that are not options are reported: a misspelled key ({ windw: 500 }) is ignored with a warning in the
result's warnings (render(): in the model's; exportPdf: through onWarning) naming the nearest option, and a
misspelling of encrypt or prepare throws, so a file is never written without the password or signature you asked
for. encrypt, prepare, save and PDF/A without a profile need the whole model, sqlRows included: the rows are
gathered, and the result says streamed: false.
The sink is a Node Writable or anything with write(bytes) that may return a promise (back-pressure); it is not
closed for you. Options are render()'s and exportPdf's (fonts, parameters, title, pdfa, tagged, …), plus
window (rows laid out at once, 1,000), maxRows (rows read, 5,000,000), presorted, spill and parallel. Rows come
from the report's source a batch at a time: CSV and REST (JSON) sources are read as streams, JSON given as text is read
row by row, an array given in the definition (or in sources) is read where it is, never copied or sorted, and
sqlRows(source, params), an async iterable of row batches, stands in for a database cursor. An array you pass is yours
and stays in memory for the export: the export's own memory stays flat beside it (measured: the array plus about
150 MB), but V8 lets garbage grow with a large live heap, so pass a cursor (sqlRows) or JSON text when the rows are
many.
spill: { dir, maxMB, runMB, mergeMB }; 2 GB of disk at most; runMB (default 8) and mergeMB (default 4) set the memory), so memory stays flat: the sort holds about 8 MB of rows while reading
and 4 MB while merging, whatever the row count (past that many sorted runs, groups of them are merged on disk first).
spill: false, and in a browser, it is in memory (all the rows: measured about 1.3 KB a row, so keep browser exports
under about 100,000 sorted or grouped rows).ORDER BY region, type for groups by region then type), pass presorted: true: nothing is sorted or held, each row is checked
against the one before as it passes, and a row out of order stops the export with an error. It is the cheapest way to
a large grouped report: a tester's 1,000,000-row grouped ledger (0.13.0, Node 22, 2 vCPU Linux) peaked at 293 MB
presorted and 386 MB sorted by the engine; the engine's sort holds less since 0.13.1, but it still reads and writes
every row to disk once more.timeoutMs bounds the whole export, including waits for a sink that stops reading
(an HTTP client gone quiet, a Writable that never drains): past it the export rejects with status 504 and
code: 'ETIMEDOUT'. signal (an AbortSignal) stops it at its next wait (a batch of rows, the sink, a sort, a
page) and rejects with the signal's reason (an AbortError). sqlRows(source, params, { signal }) gets the signal
too, so a database cursor can cancel its query. Either way the rows' cursors are closed and a sort's temporary
files removed.tagged: { lang, maxElements } caps it (the export stops with an error).What streams: a body that is one table (with items above or below it), its rows' expressions row by row (fields,
parameters, functions of the row, RowNumber()), groups with headers that repeat, subtotals (Sum, Count, Avg,
Min, Max of a row expression, CountRows()), keep-together and page breaks, filters and sorts, page headers and
footers without aggregates ("Page N of M" included: the pages are counted first, so the source is read twice), PDF/A-2b,
and accessible PDFs (PDF/UA-1, PDF/A-2a; validated with veraPDF). A data set without declared fields streams too: its fields are
detected from its first 50 rows, as the whole render detects them (declare them when a column first appears later).
What is written whole (exportPdf; the result says why, in why and in its first warning, since its memory grows
with its pages): matrices, charts, lists, subreports, images, rich text,
lookups and aggregates outside footers, more than one data set, group sections (newSection), signed or encrypted PDFs.
Grouped and sorted tables sort through a disk spill in Node (above), in memory in a browser.
examples/stream-1m.mjs streams a generated million-row ledger to a file and prints its peak memory:
node node_modules/@reportwright/engine/examples/stream-1m.mjs 1000000 ledger.pdf (--grouped, --presorted,
--tagged, --plain, --array, --array=text; serial unless --parallel=N).
import { createPool } from '@reportwright/engine/pool';
const pool = createPool({ workers: 4 }); // default: CPU count - 1; each worker loads the engine and fonts once
const pdf = await pool.render(definition, { format: 'pdf', parameters: { id: 42 } }); // Uint8Array
await pool.close();
Measured on an 8-core laptop, 40 copies of a one-page chart report (node scripts/bench-pool.mjs): 1 worker 14.7/s,
4 workers 40.7/s.
The pw command line is in @reportwright/cli:
npx -p @reportwright/cli pw render statement.pw.json -p accountId=LN-1 -d loans=data.json -f pdf -o statement.pdf
npx -p @reportwright/cli pw test reports # snapshot tests for reports-as-code
MIT.
FAQs
ReportWright report engine: JSON report definitions to pages, PDF (incl. PDF/A), Word, Excel, PowerPoint, HTML and CSV. Node and browsers. Fonts: @reportwright/fonts; CLI: @reportwright/cli.
The npm package @reportwright/engine receives a total of 608 weekly downloads. As such, @reportwright/engine popularity was classified as not popular.
We found that @reportwright/engine demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.

Security News
On Front Lines’ Early Adopters: CISO podcast, Andrew Becherer shares lessons from Datadog, weighs the long-term costs of AI-built tools, and explains how founders can actually gain a CISO’s trust.