pivotkit-report 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/package.json +74 -4
- package/src/default-template.js +33 -0
- package/src/html-table.js +104 -0
- package/src/index.js +24 -0
- package/src/render.js +278 -0
- package/src/report-css.js +61 -0
- package/src/svg.js +170 -0
- package/src/template.js +324 -0
- package/src/texts.js +63 -0
- package/src/tokens.js +180 -0
- package/types/default-template.d.ts +2 -0
- package/types/html-table.d.ts +36 -0
- package/types/index.d.ts +13 -0
- package/types/render.d.ts +176 -0
- package/types/report-css.d.ts +14 -0
- package/types/svg.d.ts +39 -0
- package/types/template.d.ts +37 -0
- package/types/texts.d.ts +44 -0
- package/types/tokens.d.ts +114 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `pivotkit-report` are documented in this file. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-13
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `SIZE_TOKENS`: the HTML report's font sizes as tokens, `--pk-font-size` (14 px), `--pk-small`
|
|
16
|
+
(the details, at least 14 px, were 12 px), `--pk-report-title-size` (20 px at 14 px),
|
|
17
|
+
`--pk-print-size` (10 pt) and `--pk-print-small` (7 pt); a host's `tokens` change them, and the
|
|
18
|
+
chart's text with `--pk-chart-text-size` in px (default 14 px, was ECharts' 12 px). A token's value
|
|
19
|
+
may hold a `calc()`'s `*`, never a comment ([#220](https://github.com/eizedev/pivotkit/issues/220), [#221](https://github.com/eizedev/pivotkit/issues/221)).
|
|
20
|
+
- `SHAPE_TOKENS`: the corner of the HTML report's chart card, `--pk-radius-card` (8 px, pivotkit-react's
|
|
21
|
+
value); a host's `tokens` change it, as the chart's title size (`--pk-chart-title-size`) and its marks'
|
|
22
|
+
corners (`--pk-radius-chart`) in px ([#216](https://github.com/eizedev/pivotkit/issues/216)).
|
|
23
|
+
- A colour scale or data bars without colours of the view's own take the host's `tokens` in the HTML
|
|
24
|
+
report's table and in the workbook (`--pk-scale-low`, `--pk-scale-mid`, `--pk-scale-high`,
|
|
25
|
+
`--pk-chart-1`), as the report on screen does; by default the light theme's heat colours, light
|
|
26
|
+
for low and strong for high ([#212](https://github.com/eizedev/pivotkit/issues/212)).
|
|
27
|
+
- `WEIGHT_TOKENS`: pivotkit-react's font weight scale (`--pk-weight-normal`, `-medium`, `-strong`,
|
|
28
|
+
`-bold`); the report's stylesheet and its chart take their weights from these tokens, so a host's
|
|
29
|
+
`tokens` change them too ([#201](https://github.com/eizedev/pivotkit/issues/201)).
|
|
30
|
+
- `render(view, data, options)`: a view over rows, columnar or encoded data as JSON, CSV, XLSX, SVG or
|
|
31
|
+
a printable HTML report, with the view's issues, `ok` and a summary; a bad view never throws, a
|
|
32
|
+
limit or a refused template throws `PivotkitError` ([#165](https://github.com/eizedev/pivotkit/issues/165)).
|
|
33
|
+
- The HTML report: the table as the screen shows it (merged headers, totals and subtotals, colour
|
|
34
|
+
scales and data bars, collapsed groups), the chart as an image, the details (source, rows, filters,
|
|
35
|
+
generated), print rules (the header on every page, rows not split, landscape for a wide table) and
|
|
36
|
+
a Content-Security-Policy that loads and runs nothing.
|
|
37
|
+
- The print template (`pivotkit-report/template`): `{{placeholders}}` from a fixed table, allowed in
|
|
38
|
+
text and a few attributes only, checked before anything is rendered (`INVALID_TEMPLATE`); the
|
|
39
|
+
`content` of a `<meta>` takes one only for `name="description"` without `http-equiv`.
|
|
40
|
+
- A host's `tokens` are left out when a value holds a line break or `url(` (also in a quoted string,
|
|
41
|
+
which a line break would end), a comment or a function that is not a colour, `calc()`, `min()`,
|
|
42
|
+
`max()`, `clamp()` or `var()`. The HTML report and the SVG leave out bidi controls, as the CSV does (in the SVG also as numeric references).
|
|
43
|
+
- The chart as SVG through ECharts' server-side renderer, checked and cleaned (`cleanSvg`): no
|
|
44
|
+
script, event attribute, link or style attribute; attribute names plain names, namespaces only
|
|
45
|
+
SVG's and xlink's.
|
|
46
|
+
- Options are checked: an unknown one is refused with the closest name, an option of a few values
|
|
47
|
+
(`chart`, `chartEmbed`, `bom`, `strict`, `trusted`) takes only those, and `chart: true` without
|
|
48
|
+
`echarts` is an error. `bom: false` writes CSV without a byte order mark.
|
|
49
|
+
|
|
50
|
+
[Unreleased]: https://github.com/eizedev/pivotkit/compare/pivotkit-report@0.1.0...HEAD
|
|
51
|
+
[0.1.0]: https://github.com/eizedev/pivotkit/releases/tag/pivotkit-report@0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 eizedev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pivotkit-report
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render [pivotkit](https://pivotkit.eize.dev/) views without a browser: a view over rows as an HTML
|
|
4
|
+
report to print or save as PDF, CSV, JSON, an Excel workbook (.xlsx) or the chart as SVG. The same
|
|
5
|
+
table and chart as the report on the screen, from pivotkit-core's engine and exporters; the print
|
|
6
|
+
template is HTML with `{{placeholders}}`, and everything filled into it is escaped. Runs in Node and
|
|
7
|
+
in the browser.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install pivotkit-report echarts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
ES modules for Node 22 or later and browsers from Chrome 111, Firefox 113 and Safari 16.4. It depends
|
|
16
|
+
on pivotkit-core (a caret range on the minor version it was released with, see its `package.json`). ECharts (`>=6.1.0 <7`) is an optional peer dependency: only
|
|
17
|
+
the SVG and the chart of an HTML report need it, passed in as `options.echarts`. TypeScript
|
|
18
|
+
declarations are included.
|
|
19
|
+
|
|
20
|
+
## Example
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { writeFile } from "node:fs/promises";
|
|
24
|
+
import * as echarts from "echarts";
|
|
25
|
+
import { render } from "pivotkit-report";
|
|
26
|
+
|
|
27
|
+
const view = { rows: ["Region"], values: [{ field: "Units", agg: "sum" }], display: "chart" };
|
|
28
|
+
const result = await render(view, rows, { format: "html", echarts, now: "2026-11-13" });
|
|
29
|
+
if (!result.ok) console.warn(result.issues); // a bad view never throws: its issues come back
|
|
30
|
+
await writeFile("report.html", result.body); // Uint8Array; result.text for the text formats
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Where in the docs
|
|
34
|
+
|
|
35
|
+
| Topic | Page |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `render()`, its options, formats, limits and errors | [pivotkit-report reference](https://pivotkit.eize.dev/reference/report/) |
|
|
38
|
+
| The print template and its placeholders | [Print template](https://pivotkit.eize.dev/reference/print-template/) |
|
|
39
|
+
| The command line on top of it | [pivotkit-cli](https://pivotkit.eize.dev/reference/cli/) |
|
|
40
|
+
| Changes | [CHANGELOG](https://github.com/eizedev/pivotkit/blob/main/packages/pivotkit-report/CHANGELOG.md) |
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
[MIT](https://github.com/eizedev/pivotkit/blob/main/LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pivotkit-report",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render pivotkit views without a browser: JSON, CSV, XLSX, SVG charts and a printable HTML report from a template, in Node and in the browser.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "eizedev <oss@eize.dev>",
|
|
7
|
+
"homepage": "https://pivotkit.eize.dev/reference/report/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/eizedev/pivotkit.git",
|
|
11
|
+
"directory": "packages/pivotkit-report"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/eizedev/pivotkit/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pivotkit",
|
|
16
|
+
"pivot-table",
|
|
17
|
+
"reporting",
|
|
18
|
+
"report",
|
|
19
|
+
"print",
|
|
20
|
+
"svg",
|
|
21
|
+
"echarts",
|
|
22
|
+
"excel-export",
|
|
23
|
+
"pivot-grid",
|
|
24
|
+
"pivottable",
|
|
25
|
+
"excel"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./types/index.d.ts",
|
|
32
|
+
"default": "./src/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./template": {
|
|
35
|
+
"types": "./types/template.d.ts",
|
|
36
|
+
"default": "./src/template.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"src",
|
|
42
|
+
"types",
|
|
43
|
+
"README.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test \"test/*.test.js\"",
|
|
52
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" \"test/*.test.js\"",
|
|
53
|
+
"types": "tsc -p .",
|
|
54
|
+
"test:types": "tsc -p test/types",
|
|
55
|
+
"prepack": "npm run types && node -e \"require('node:fs').copyFileSync('../../LICENSE', 'LICENSE')\""
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"echarts": ">=6.1.0 <7"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"echarts": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"pivotkit-core": "^0.5.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"echarts": "6.1.0",
|
|
70
|
+
"pivotkit-xlsx": "0.2.0"
|
|
71
|
+
},
|
|
72
|
+
"types": "./types/index.d.ts",
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The default print template: a document without script, its Content-Security-Policy first in <head>
|
|
3
|
+
* (nothing it shows can load or run anything: styles inline, images as data: addresses only), the
|
|
4
|
+
* title, the chart (an image), the table and the details below it. A host or `--template` replaces it
|
|
5
|
+
* with its own; docs/reference/print-template.md lists the placeholders.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const REPORT_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TEMPLATE = `<!doctype html>
|
|
11
|
+
<html lang="{{lang}}" dir="{{dir}}">
|
|
12
|
+
<head>
|
|
13
|
+
<meta http-equiv="Content-Security-Policy" content="${REPORT_CSP}">
|
|
14
|
+
<meta charset="utf-8">
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
16
|
+
<meta name="generator" content="pivotkit">
|
|
17
|
+
<meta name="description" content="{{summary}}">
|
|
18
|
+
<title>{{title}}</title>
|
|
19
|
+
{{style}}
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<main class="pk-report">
|
|
23
|
+
<header>
|
|
24
|
+
<h1>{{title}}</h1>
|
|
25
|
+
<p class="pk-report-description">{{description}}</p>
|
|
26
|
+
</header>
|
|
27
|
+
{{chart}}
|
|
28
|
+
{{table}}
|
|
29
|
+
{{details}}
|
|
30
|
+
</main>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
33
|
+
`;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The pivot table as HTML, written from pivotkit-core's grid (toGrid): what the Excel export keeps,
|
|
3
|
+
* as the table shows it. Merged headers and row labels as colspan and rowspan, totals and subtotals
|
|
4
|
+
* as classes (bold, shaded), colour scales and data bars as classes with their colours in a rule of
|
|
5
|
+
* their own (no `style` attribute, so a page with a strict Content-Security-Policy can show it), a
|
|
6
|
+
* collapsed group marked. Every text is escaped; every class comes from the fixed names below; a
|
|
7
|
+
* colour is written only when it is a hex colour.
|
|
8
|
+
*
|
|
9
|
+
* Left out on purpose: the header's sort buttons, the toggles and drill-down (nothing runs in a
|
|
10
|
+
* report). A table over `maxCells` cells is cut after the last whole row that fits: `cut` says how
|
|
11
|
+
* many body rows it shows of how many.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_THEME } from "pivotkit-core";
|
|
15
|
+
import { scaleColor } from "pivotkit-core/export";
|
|
16
|
+
import { escapeHtml } from "./template.js";
|
|
17
|
+
|
|
18
|
+
const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
19
|
+
|
|
20
|
+
/** The class of a total: subtotal or grand total. */
|
|
21
|
+
const totalClass = (total) => (total === 2 ? " pk-grand" : total === 1 ? " pk-sub" : "");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} HtmlTable
|
|
25
|
+
* @property {string} html the <table>
|
|
26
|
+
* @property {string} css the rules of the conditional colours, per cell class
|
|
27
|
+
* @property {{ rows: number, of: number } | null} cut body rows shown of all, when the table was cut
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import("pivotkit-core").Grid} grid
|
|
32
|
+
* @param {{ maxCells: number, caption?: string, colorScale?: string[], dataBar?: string }} options
|
|
33
|
+
* colorScale, dataBar: the colours of a scale or a bar whose view names none (the host's tokens,
|
|
34
|
+
* tokens.js conditionalColors; else pivotkit-core's DEFAULT_THEME)
|
|
35
|
+
* @returns {HtmlTable}
|
|
36
|
+
*/
|
|
37
|
+
export function htmlTable(grid, { maxCells, caption, colorScale = [...DEFAULT_THEME.colorScale], dataBar = DEFAULT_THEME.dataBar }) {
|
|
38
|
+
const width = Math.max(1, grid.columnCount);
|
|
39
|
+
const bodyCount = grid.rows.length - grid.headerRows;
|
|
40
|
+
// the header always; then whole body rows while the cells fit
|
|
41
|
+
const fit = Math.max(0, Math.min(bodyCount, Math.floor(maxCells / width) - grid.headerRows));
|
|
42
|
+
const rules = [];
|
|
43
|
+
let ruleId = 0;
|
|
44
|
+
const colorRule = (style) => {
|
|
45
|
+
if (!style) {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
if (style.bar !== undefined) {
|
|
49
|
+
const color = style.color && HEX.test(style.color) ? style.color : dataBar;
|
|
50
|
+
const bar = Math.round(Math.max(0, Math.min(1, style.bar)) * 1000) / 10;
|
|
51
|
+
const name = `pk-c${ruleId++}`;
|
|
52
|
+
rules.push(`.${name}{--pk-bar:${bar}%;--pk-bar-color:${HEX.test(color) ? color : DEFAULT_THEME.dataBar}}`);
|
|
53
|
+
return ` pk-has-bar ${name}`;
|
|
54
|
+
}
|
|
55
|
+
if (style.scale !== undefined) {
|
|
56
|
+
const t = Math.max(0, Math.min(1, style.scale));
|
|
57
|
+
const color = style.color && HEX.test(style.color) ? style.color : scaleColor(colorScale.filter((c) => HEX.test(c)).length >= 2 ? colorScale.filter((c) => HEX.test(c)) : [...DEFAULT_THEME.colorScale], t);
|
|
58
|
+
const name = `pk-c${ruleId++}`;
|
|
59
|
+
rules.push(`.${name}{--pk-cell-bg:${color}}`);
|
|
60
|
+
return ` pk-has-scale ${name}`;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
};
|
|
64
|
+
const span = (cell) => `${(cell.colSpan ?? 1) > 1 ? ` colspan="${cell.colSpan}"` : ""}${(cell.rowSpan ?? 1) > 1 ? ` rowspan="${cell.rowSpan}"` : ""}`;
|
|
65
|
+
const head = [];
|
|
66
|
+
for (let r = 0; r < grid.headerRows; r++) {
|
|
67
|
+
const cells = grid.rows[r]
|
|
68
|
+
.map((cell) => {
|
|
69
|
+
if (!cell) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
const kind = cell.kind === "corner" ? " pk-corner" : "";
|
|
73
|
+
return `<th scope="col" class="pk-th${kind}${totalClass(cell.total)}"${span(cell)}>${escapeHtml(cell.text)}</th>`;
|
|
74
|
+
})
|
|
75
|
+
.join("");
|
|
76
|
+
head.push(`<tr>${cells}</tr>`);
|
|
77
|
+
}
|
|
78
|
+
const body = [];
|
|
79
|
+
for (let r = grid.headerRows; r < grid.headerRows + fit; r++) {
|
|
80
|
+
const cells = grid.rows[r]
|
|
81
|
+
.map((cell) => {
|
|
82
|
+
if (!cell) {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
// a row label merged past the rows the cut keeps ends there
|
|
86
|
+
const rowSpan = Math.min(cell.rowSpan ?? 1, grid.headerRows + fit - r);
|
|
87
|
+
const spans = span({ ...cell, rowSpan });
|
|
88
|
+
if (cell.kind === "rowHeader") {
|
|
89
|
+
const collapsed = cell.toggle?.collapsed ? " pk-collapsed" : "";
|
|
90
|
+
return `<th scope="row" class="pk-th pk-row${totalClass(cell.total)}${collapsed}"${spans}>${escapeHtml(cell.text)}</th>`;
|
|
91
|
+
}
|
|
92
|
+
const number = cell.valueType === "text" ? " pk-text" : " pk-num";
|
|
93
|
+
return `<td class="pk-td${number}${totalClass(cell.total)}${colorRule(cell.style)}"${spans}>${escapeHtml(cell.text)}</td>`;
|
|
94
|
+
})
|
|
95
|
+
.join("");
|
|
96
|
+
body.push(`<tr>${cells}</tr>`);
|
|
97
|
+
}
|
|
98
|
+
const captionHtml = caption ? `<caption>${escapeHtml(caption)}</caption>` : "";
|
|
99
|
+
return {
|
|
100
|
+
html: `<table class="pk-table">${captionHtml}<thead>${head.join("")}</thead><tbody>${body.join("")}</tbody></table>`,
|
|
101
|
+
css: rules.join("\n"),
|
|
102
|
+
cut: fit < bodyCount ? { rows: fit, of: bodyCount } : null
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* pivotkit-report: a view over rows as a file without a browser - JSON, CSV, XLSX, SVG and a printable
|
|
3
|
+
* HTML report from a template with placeholders - on pivotkit-core, in Node and in the browser alike.
|
|
4
|
+
*
|
|
5
|
+
* import * as echarts from "echarts";
|
|
6
|
+
* const { body, issues, ok } = await render(view, rows, { format: "html", echarts, now: "2026-11-13" });
|
|
7
|
+
* // import { parseTemplate, fillTemplate, PLACEHOLDERS } from "pivotkit-report/template"
|
|
8
|
+
*
|
|
9
|
+
* Conventions as in pivotkit-core: required inputs first, everything optional in one options object;
|
|
10
|
+
* a bad view never throws (issues), a programmer error, a limit or a refused template throws
|
|
11
|
+
* PivotkitError.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {import("./render.js").RenderOptions} RenderOptions
|
|
16
|
+
* @typedef {import("./render.js").RenderResult} RenderResult
|
|
17
|
+
* @typedef {import("./texts.js").ReportTexts} ReportTexts
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { render, RENDER_FORMATS, REPORT_LIMITS, REPORT_ISSUE_CODES, CHART_SIZE } from "./render.js";
|
|
21
|
+
export { cleanSvg } from "./svg.js";
|
|
22
|
+
export { DEFAULT_TEMPLATE, REPORT_CSP } from "./default-template.js";
|
|
23
|
+
export { REPORT_TEXTS } from "./texts.js";
|
|
24
|
+
export { LIGHT_TOKENS, DARK_TOKENS, WEIGHT_TOKENS, SHAPE_TOKENS, SIZE_TOKENS } from "./tokens.js";
|
package/src/render.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* render(): a view over data as a file - JSON, CSV, XLSX, SVG or a printable HTML report. Every step
|
|
3
|
+
* is pivotkit-core's (fromViewFile, normalize, computePivot, toGrid and its exporters, chartPlan and
|
|
4
|
+
* the chart's marks); this module only joins them. A bad view never throws: its issues come back with
|
|
5
|
+
* the result (`ok` is false with an error among them); a programmer error, a limit and a refused
|
|
6
|
+
* template throw PivotkitError.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { PivotkitError, closest, computePivot, describeView, encode, fromViewFile, normalize, outsideLimits, toGrid } from "pivotkit-core";
|
|
10
|
+
import { toCsvBytes, toJson, toXlsx } from "pivotkit-core/export";
|
|
11
|
+
import { DEFAULT_TEMPLATE } from "./default-template.js";
|
|
12
|
+
import { htmlTable } from "./html-table.js";
|
|
13
|
+
import { reportCss } from "./report-css.js";
|
|
14
|
+
import { chartSvg } from "./svg.js";
|
|
15
|
+
import { TEMPLATE_LIMIT, escapeHtml, fillTemplate, parseTemplate } from "./template.js";
|
|
16
|
+
import { fill, textsFor } from "./texts.js";
|
|
17
|
+
import { cleanTokens, conditionalColors, tokenStyle } from "./tokens.js";
|
|
18
|
+
|
|
19
|
+
const XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
20
|
+
|
|
21
|
+
/** The formats: media type, file extension, and whether the body is text (`text` is set). */
|
|
22
|
+
export const RENDER_FORMATS = Object.freeze({
|
|
23
|
+
json: Object.freeze({ mediaType: "application/json", extension: "json", text: true }),
|
|
24
|
+
csv: Object.freeze({ mediaType: "text/csv;charset=utf-8", extension: "csv", text: true }),
|
|
25
|
+
xlsx: Object.freeze({ mediaType: XLSX_TYPE, extension: "xlsx", text: false }),
|
|
26
|
+
svg: Object.freeze({ mediaType: "image/svg+xml", extension: "svg", text: true }),
|
|
27
|
+
html: Object.freeze({ mediaType: "text/html;charset=utf-8", extension: "html", text: true })
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a report allows at most, besides pivotkit-core's LIMITS: the characters of a template, the size
|
|
32
|
+
* of a chart in px, the cells of a report's table (a larger table is cut after the last whole row, and
|
|
33
|
+
* says so), the categories of a chart (the first ones, as on the screen).
|
|
34
|
+
*/
|
|
35
|
+
export const REPORT_LIMITS = Object.freeze({ template: TEMPLATE_LIMIT, chartWidth: 8_000, chartHeight: 8_000, tableCells: 200_000, categories: 50 });
|
|
36
|
+
|
|
37
|
+
/** The default size of a chart in px. */
|
|
38
|
+
export const CHART_SIZE = Object.freeze({ width: 800, height: 480 });
|
|
39
|
+
|
|
40
|
+
/** Issues of a report itself (warnings), besides a view's (VIEW_ISSUES). */
|
|
41
|
+
export const REPORT_ISSUE_CODES = Object.freeze(["chartUnavailable", "tableCut", "chartCut", "viewFile"]);
|
|
42
|
+
|
|
43
|
+
/** Languages written from right to left. */
|
|
44
|
+
const RTL = new Set(["ar", "he", "fa", "ur", "ps", "sd", "ug", "yi", "dv", "ckb"]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} RenderOptions
|
|
48
|
+
* @property {"json" | "csv" | "xlsx" | "svg" | "html"} [format] default "html"
|
|
49
|
+
* @property {string} [locale] used when the view names none; default "en-US"
|
|
50
|
+
* @property {string} [timeZone] of rows that are not encoded yet (encode()), and of the "generated" time; default "UTC"
|
|
51
|
+
* @property {Date | number | string} [now] "today" of relative date filters and the "generated" time; fix it for output that is the same on every run
|
|
52
|
+
* @property {Partial<import("pivotkit-core").Limits>} [limits] pivotkit-core's limits
|
|
53
|
+
* @property {boolean} [trusted] the view is the host's own: the host's limits instead of outsideLimits() (default false)
|
|
54
|
+
* @property {boolean} [strict] no body when the view has an error (default false: the normalized view is rendered)
|
|
55
|
+
* @property {Partial<import("pivotkit-core").Labels>} [labels] pivotkit-core's texts (totals, value names)
|
|
56
|
+
* @property {Partial<import("./texts.js").ReportTexts>} [texts] the report's words around the table
|
|
57
|
+
* @property {Record<string, string>} [tokens] --pk-* colours over the light ones (the chart and the report)
|
|
58
|
+
* @property {string} [title] the report's title; default the view file's name, the chart's title, "Report"
|
|
59
|
+
* @property {string} [description] default the view file's description
|
|
60
|
+
* @property {string} [source] where the data comes from, for the report's details (a file name)
|
|
61
|
+
* @property {string} [template] html: a print template (parseTemplate), default DEFAULT_TEMPLATE
|
|
62
|
+
* @property {boolean | "auto"} [chart] html: with the chart; "auto" (default) when the view shows the chart
|
|
63
|
+
* @property {"image" | "inline"} [chartEmbed] html: the chart as a data: image (default) or as inline SVG
|
|
64
|
+
* @property {{ init: Function }} [echarts] the ECharts module (with its SVG renderer): svg, and html with a chart
|
|
65
|
+
* @property {{ width?: number, height?: number }} [size] of the chart in px, default 800 x 480
|
|
66
|
+
* @property {number} [tableCells] html: cells of the table at most, default REPORT_LIMITS.tableCells
|
|
67
|
+
* @property {boolean} [bom] csv: start with a byte order mark, so Excel reads it as UTF-8 (default true)
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** @type {Readonly<Record<string, ReadonlyArray<unknown>>>} The options that take one of a few values. */
|
|
71
|
+
const OPTION_VALUES = Object.freeze({ chart: [true, false, "auto"], chartEmbed: ["image", "inline"], bom: [true, false], strict: [true, false], trusted: [true, false] });
|
|
72
|
+
|
|
73
|
+
/** The names of RenderOptions: any other is refused, so a misspelt option is not ignored. */
|
|
74
|
+
const OPTION_KEYS = Object.freeze(["format", "locale", "timeZone", "now", "limits", "trusted", "strict", "labels", "texts", "tokens", "title", "description", "source", "template", "chart", "chartEmbed", "echarts", "size", "tableCells", "bom"]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} RenderResult
|
|
78
|
+
* @property {string} format
|
|
79
|
+
* @property {string} mediaType
|
|
80
|
+
* @property {string} extension
|
|
81
|
+
* @property {Uint8Array | null} body the file; null when `strict` and the view has an error
|
|
82
|
+
* @property {string} [text] the body as text, for json, csv, svg and html
|
|
83
|
+
* @property {import("pivotkit-core").Issue[]} issues the view's issues, then the report's (REPORT_ISSUE_CODES)
|
|
84
|
+
* @property {boolean} ok no issue of severity "error"
|
|
85
|
+
* @property {{ rows: number, columns: number, sourceRows: number, truncated: boolean } | null} summary the table's body rows and columns, the data's rows, whether a part was cut
|
|
86
|
+
* @property {import("pivotkit-core").View | null} view the normalized view that was rendered
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/** @param {string} key @param {string} message */
|
|
90
|
+
const badOption = (key, message) => new PivotkitError("INVALID_OPTION", `render: ${message}`, { option: key });
|
|
91
|
+
|
|
92
|
+
/** A report issue (a warning). */
|
|
93
|
+
const reportIssue = (code, message, params = {}) => ({ path: "", code, message, severity: /** @type {"warning"} */ ("warning"), params });
|
|
94
|
+
|
|
95
|
+
/** The data as encoded data: rows and columnar data are encoded, encoded data is used as it is. */
|
|
96
|
+
function encoded(data, options) {
|
|
97
|
+
if (Array.isArray(data) || (data && typeof data === "object" && Array.isArray(/** @type {any} */ (data).columns) && Array.isArray(/** @type {any} */ (data).data))) {
|
|
98
|
+
const columnar = /** @type {any} */ (data);
|
|
99
|
+
return encode(Array.isArray(data) ? data : { columns: columnar.columns, types: columnar.types, data: columnar.data }, { timeZone: options.timeZone ?? "UTC", ...(options.limits ? { limits: options.limits } : {}) });
|
|
100
|
+
}
|
|
101
|
+
if (data && typeof data === "object" && typeof (/** @type {any} */ (data).column) === "function") {
|
|
102
|
+
return /** @type {import("pivotkit-core").Encoded} */ (data);
|
|
103
|
+
}
|
|
104
|
+
throw new PivotkitError("INVALID_ARGUMENT", "render(view, data): data must be rows, columnar data ({ columns, types, data }) or encode()'s result");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The chart's size, checked against REPORT_LIMITS. */
|
|
108
|
+
function sizeOf(size) {
|
|
109
|
+
const width = size?.width ?? CHART_SIZE.width;
|
|
110
|
+
const height = size?.height ?? CHART_SIZE.height;
|
|
111
|
+
for (const [key, value, max] of [["width", width, REPORT_LIMITS.chartWidth], ["height", height, REPORT_LIMITS.chartHeight]]) {
|
|
112
|
+
if (!Number.isInteger(value) || value < 100 || value > max) {
|
|
113
|
+
throw badOption("size", `size.${key} must be a whole number from 100 to ${max}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { width, height };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** "Nov 13, 2026, 10:00 AM" of `now` in the locale and time zone. */
|
|
120
|
+
function generatedText(now, locale, timeZone) {
|
|
121
|
+
const date = now === undefined ? new Date() : new Date(/** @type {any} */ (now));
|
|
122
|
+
if (Number.isNaN(date.getTime())) {
|
|
123
|
+
throw badOption("now", "now must be a date, a time in ms or an ISO text");
|
|
124
|
+
}
|
|
125
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone }).format(date);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Renders a view over data as a file.
|
|
130
|
+
* @param {unknown} view a view, a view file or a template { name, view }, as an object or JSON text
|
|
131
|
+
* @param {unknown} data rows, columnar data ({ columns, types, data }) or encode()'s result
|
|
132
|
+
* @param {RenderOptions} [options]
|
|
133
|
+
* @returns {Promise<RenderResult>}
|
|
134
|
+
*/
|
|
135
|
+
export async function render(view, data, options = {}) {
|
|
136
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
137
|
+
throw badOption("options", "options must be an object");
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(options)) {
|
|
140
|
+
if (!OPTION_KEYS.includes(key)) {
|
|
141
|
+
const near = closest(key, OPTION_KEYS);
|
|
142
|
+
throw badOption(key, `unknown option ${JSON.stringify(key.slice(0, 40))}${near ? `; did you mean ${JSON.stringify(near)}?` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const format = options.format ?? "html";
|
|
146
|
+
if (!Object.hasOwn(RENDER_FORMATS, format)) {
|
|
147
|
+
throw badOption("format", `unknown format ${JSON.stringify(String(format)).slice(0, 40)}; one of ${Object.keys(RENDER_FORMATS).join(", ")}`);
|
|
148
|
+
}
|
|
149
|
+
const kind = RENDER_FORMATS[format];
|
|
150
|
+
const base = { format, mediaType: kind.mediaType, extension: kind.extension };
|
|
151
|
+
// a template is checked before any work
|
|
152
|
+
const template = format === "html" ? parseTemplate(options.template ?? DEFAULT_TEMPLATE) : null;
|
|
153
|
+
const size = format === "svg" || format === "html" ? sizeOf(options.size) : null;
|
|
154
|
+
// options that take one of a few values: anything else (a CLI's "on") would be read as the default
|
|
155
|
+
for (const [key, allowed] of Object.entries(OPTION_VALUES)) {
|
|
156
|
+
const value = /** @type {Record<string, unknown>} */ (options)[key];
|
|
157
|
+
if (value !== undefined && !allowed.includes(value)) {
|
|
158
|
+
throw badOption(key, `${key} takes ${allowed.map((v) => JSON.stringify(v)).join(", ")}, not ${JSON.stringify(String(value)).slice(0, 40)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if ((format === "svg" || (format === "html" && options.chart === true)) && typeof options.echarts?.init !== "function") {
|
|
162
|
+
throw badOption("echarts", `${format === "svg" ? "an SVG" : "a report with chart: true"} needs ECharts: pass the echarts module (with its SVG renderer) as options.echarts`);
|
|
163
|
+
}
|
|
164
|
+
if (options.now !== undefined) {
|
|
165
|
+
// checked here, so a wrong one is named as an option before any work
|
|
166
|
+
generatedText(options.now, "en-US", "UTC");
|
|
167
|
+
}
|
|
168
|
+
const issues = [];
|
|
169
|
+
const read = fromViewFile(typeof view === "string" ? view : JSON.stringify(view ?? null));
|
|
170
|
+
if (read.failed) {
|
|
171
|
+
issues.push({ path: "", code: "viewFile", message: `not a view: ${read.issues.at(-1)?.message ?? read.failed}`, severity: "error", params: { value: read.failed } });
|
|
172
|
+
}
|
|
173
|
+
const data2 = encoded(data, options);
|
|
174
|
+
const limits = options.trusted ? options.limits : outsideLimits(options.limits);
|
|
175
|
+
const normalized = normalize(read.view ?? {}, data2, { ...(limits ? { limits } : {}) });
|
|
176
|
+
issues.push(...normalized.issues);
|
|
177
|
+
const pivotOptions = { locale: options.locale ?? "en-US", ...(options.limits ? { limits: options.limits } : {}), ...(options.now !== undefined ? { now: options.now } : {}) };
|
|
178
|
+
const result = computePivot(data2, normalized.view, pivotOptions);
|
|
179
|
+
for (const issue of result.issues) {
|
|
180
|
+
if (!issues.some((i) => i.code === issue.code && i.path === issue.path)) {
|
|
181
|
+
issues.push(issue);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const ok = !issues.some((i) => i.severity === "error");
|
|
185
|
+
if (!ok && options.strict) {
|
|
186
|
+
return { ...base, body: null, issues, ok, summary: null, view: normalized.view };
|
|
187
|
+
}
|
|
188
|
+
const grid = toGrid(result, { ...(options.labels ? { labels: options.labels } : {}), ...(options.limits ? { limits: options.limits } : {}) });
|
|
189
|
+
const summary = { rows: grid.bodyRows.length, columns: grid.columnCount, sourceRows: data2.length, truncated: false };
|
|
190
|
+
const texts = textsFor(grid.locale, options.texts);
|
|
191
|
+
const title = (options.title ?? (read.name || normalized.view.chart.title || texts.untitled)).trim() || texts.untitled;
|
|
192
|
+
const chartOf = () =>
|
|
193
|
+
chartSvg(result, {
|
|
194
|
+
echarts: /** @type {any} */ (options.echarts),
|
|
195
|
+
style: tokenStyle(options.tokens),
|
|
196
|
+
// set for the formats that draw a chart (svg, html), the only callers of chartOf
|
|
197
|
+
.../** @type {{ width: number, height: number }} */ (size),
|
|
198
|
+
locale: grid.locale,
|
|
199
|
+
labels: options.labels,
|
|
200
|
+
limits: options.limits,
|
|
201
|
+
now: options.now,
|
|
202
|
+
maxCategories: REPORT_LIMITS.categories
|
|
203
|
+
});
|
|
204
|
+
const chartCut = (chart) => {
|
|
205
|
+
if (chart.categories < chart.of) {
|
|
206
|
+
summary.truncated = true;
|
|
207
|
+
issues.push(reportIssue("chartCut", fill(REPORT_TEXTS_EN.chartCut, { shown: chart.categories, count: chart.of }), { value: String(chart.categories), max: chart.of }));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/** @type {Uint8Array} */
|
|
211
|
+
let body;
|
|
212
|
+
if (format === "json") {
|
|
213
|
+
body = new TextEncoder().encode(toJson(grid));
|
|
214
|
+
} else if (format === "csv") {
|
|
215
|
+
body = toCsvBytes(grid, { bom: options.bom !== false });
|
|
216
|
+
} else if (format === "xlsx") {
|
|
217
|
+
body = toXlsx(grid, { sheetName: title, theme: conditionalColors(options.tokens) });
|
|
218
|
+
} else if (format === "svg") {
|
|
219
|
+
const chart = chartOf();
|
|
220
|
+
chartCut(chart);
|
|
221
|
+
body = new TextEncoder().encode(chart.svg);
|
|
222
|
+
} else {
|
|
223
|
+
const number = new Intl.NumberFormat(grid.locale);
|
|
224
|
+
const table = htmlTable(grid, { maxCells: options.tableCells ?? REPORT_LIMITS.tableCells, ...conditionalColors(options.tokens) });
|
|
225
|
+
let tableNote = "";
|
|
226
|
+
if (table.cut) {
|
|
227
|
+
summary.truncated = true;
|
|
228
|
+
issues.push(reportIssue("tableCut", fill(REPORT_TEXTS_EN.tableCut, { shown: table.cut.rows, count: table.cut.of }), { value: String(table.cut.rows), max: table.cut.of }));
|
|
229
|
+
tableNote = `<p class="pk-report-note">${escapeHtml(fill(texts.tableCut, { shown: number.format(table.cut.rows), count: number.format(table.cut.of) }))}</p>`;
|
|
230
|
+
}
|
|
231
|
+
const wantChart = options.chart === true || ((options.chart ?? "auto") === "auto" && normalized.view.display === "chart");
|
|
232
|
+
let chartHtml = "";
|
|
233
|
+
if (wantChart && typeof options.echarts?.init !== "function") {
|
|
234
|
+
issues.push(reportIssue("chartUnavailable", REPORT_TEXTS_EN.chartUnavailable));
|
|
235
|
+
} else if (wantChart) {
|
|
236
|
+
const chart = chartOf();
|
|
237
|
+
chartCut(chart);
|
|
238
|
+
const note = chart.categories < chart.of ? `<figcaption class="pk-report-note">${escapeHtml(fill(texts.chartCut, { shown: number.format(chart.categories), count: number.format(chart.of) }))}</figcaption>` : "";
|
|
239
|
+
const inner = options.chartEmbed === "inline" ? chart.svg : `<img alt="${escapeHtml(`${texts.chart}: ${title}`)}" width="${size?.width}" height="${size?.height}" src="data:image/svg+xml;base64,${base64(new TextEncoder().encode(chart.svg))}">`;
|
|
240
|
+
chartHtml = `<figure class="pk-report-chart">${inner}${note}</figure>`;
|
|
241
|
+
}
|
|
242
|
+
const described = describeView(normalized.view, data2, { ...(limits ? { limits } : {}) });
|
|
243
|
+
const timeZone = options.timeZone ?? grid.timeZone ?? "UTC";
|
|
244
|
+
const values = {
|
|
245
|
+
title,
|
|
246
|
+
description: options.description ?? read.description ?? "",
|
|
247
|
+
source: options.source ?? "",
|
|
248
|
+
generated: generatedText(options.now, grid.locale, timeZone),
|
|
249
|
+
rows: fill(texts.rowsCount, { count: number.format(data2.length) }),
|
|
250
|
+
filters: described.filters.length ? described.filters.map((f) => f.text).join("; ") : texts.noFilters,
|
|
251
|
+
summary: described.text,
|
|
252
|
+
lang: grid.locale,
|
|
253
|
+
dir: RTL.has(grid.locale.split("-")[0].toLowerCase()) ? "rtl" : "ltr"
|
|
254
|
+
};
|
|
255
|
+
const detail = (label, value) => (value ? `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>` : "");
|
|
256
|
+
const markup = {
|
|
257
|
+
style: `<style>${reportCss({ columns: grid.columnCount, tokens: cleanTokens(options.tokens ?? {}), extra: table.css })}</style>`,
|
|
258
|
+
chart: chartHtml,
|
|
259
|
+
table: table.html + tableNote,
|
|
260
|
+
details: `<dl class="pk-report-details">${detail(texts.source, values.source)}${detail(texts.rows, values.rows)}${detail(texts.filters, values.filters)}${detail(texts.generated, values.generated)}</dl>`
|
|
261
|
+
};
|
|
262
|
+
body = new TextEncoder().encode(fillTemplate(/** @type {any} */ (template), { ...values, ...markup }));
|
|
263
|
+
}
|
|
264
|
+
const out = { ...base, body, issues, ok, summary, view: normalized.view };
|
|
265
|
+
return kind.text ? { ...out, text: new TextDecoder().decode(body) } : out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The English texts, for the issues (a program reads them, not the report's reader). */
|
|
269
|
+
const REPORT_TEXTS_EN = textsFor("en");
|
|
270
|
+
|
|
271
|
+
/** Bytes as base64, in browsers and Node alike. */
|
|
272
|
+
function base64(bytes) {
|
|
273
|
+
let binary = "";
|
|
274
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
275
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
276
|
+
}
|
|
277
|
+
return btoa(binary);
|
|
278
|
+
}
|