# pivotkit-report

`pivotkit-report` runs a view over data and writes a file: an HTML report to print or save as PDF, CSV, JSON, an Excel workbook (.xlsx) or the chart as SVG. It joins pivotkit-core's steps (the view file, `normalize`, `computePivot`, `toGrid` and its exporters, `chartPlan` and the chart's marks), so the table and the chart are the ones the report shows on the screen. It runs in Node and in the browser; the [command line](<https://pivotkit.eize.dev/reference/cli/index.md>) and the report's own Export menu ([HTML report, SVG, print](<https://pivotkit.eize.dev/use/export/index.md>)) use it.

```
npm install pivotkit-report echarts
```

ECharts (`>=6.1.0 <7`) is an optional peer dependency: only the SVG and the chart of an HTML report need it, and you pass the module in, so a page uses the copy it already has.

```
import { writeFile } from "node:fs/promises";
import * as echarts from "echarts";
import { render } from "pivotkit-report";

const view = { rows: ["Region"], values: [{ field: "Units", agg: "sum" }], display: "chart" };
const result = await render(view, rows, { format: "html", echarts, now: "2026-11-13", locale: "de-DE" });
if (!result.ok) throw new Error(result.issues.map((i) => i.message).join("; "));   // a bad view never throws
await writeFile("report.html", result.body);   // Uint8Array, the whole file
```

## render(view, data, options)

`view` is a view, a [view file](<https://pivotkit.eize.dev/reference/view-file/index.md>) or a template `{ name, view }`, as an object or JSON text. `data` is rows, columnar data (`{ columns, types, data }`, what [`readFile`](<https://pivotkit.eize.dev/reference/import/index.md>) returns) or `encode()`'s result. It returns a promise of:

| Property | What it is |
| --- | --- |
| `format`, `mediaType`, `extension` | `"html"`, `"text/html;charset=utf-8"`, `"html"`, and so on. |
| `body` | The file as a `Uint8Array`; `null` with `strict` when the view has an error. |
| `text` | The body as text, for `json`, `csv`, `svg` and `html`. |
| `issues` | The view's issues ([`VIEW_ISSUES`](<https://pivotkit.eize.dev/reference/core/index.md>)), then the report's own (`REPORT_ISSUE_CODES`). |
| `ok` | `false` when an issue has the severity `error`. |
| `summary` | `{ rows, columns, sourceRows, keptRows, truncated }`: the table's body rows and columns, the data's rows, the rows its filters keep, whether a part was cut. |
| `view` | The normalized view that was rendered. |

The whole body is built in memory; the [limits](<https://pivotkit.eize.dev/concepts/limits/index.md>) keep it bounded.

### Options

| Option | Default | What it does |
| --- | --- | --- |
| `format` | `"html"` | `json`, `csv`, `xlsx`, `svg` or `html`. |
| `locale` | `"en-US"` | Numbers, dates and the report's words when the view names no locale. |
| `timeZone` | `"UTC"` | Of rows not encoded yet, and of the "generated" time. |
| `now` | the clock | The "today" of relative date filters and the generated time; fix it for the same bytes on every run. |
| `limits` | `LIMITS` | pivotkit-core's [limits](<https://pivotkit.eize.dev/concepts/limits/index.md>). |
| `trusted` | `false` | The view is the host's own: its limits instead of `outsideLimits()`. |
| `strict` | `false` | No body when the view has an error. Without it the normalized view is rendered, as the report on screen does. |
| `labels`, `texts` | the locale's language | pivotkit-core's texts (totals, value names; by default [`reportLabels`](<https://pivotkit.eize.dev/reference/core/index.md>) of the view's or `locale`'s language: "Gesamtergebnis", "Summe von Units" for `de-DE`, the words the report on a page shows) and the report's words around the table (`REPORT_TEXTS`, English and German). |
| `title`, `description`, `source` | the view file's name and description | The report's heading, its line below, and the data's name in the details. |
| `tokens` | the light theme | `--pk-*` colours, font weights, font sizes and corners over [`LIGHT_TOKENS`](<https://pivotkit.eize.dev/reference/theming/index.md>), `WEIGHT_TOKENS` (`--pk-weight-normal` to `--pk-weight-bold`, the chart's title and group labels too), `SIZE_TOKENS` (the HTML report's font sizes: `--pk-font-size` 14 px, `--pk-small`, `--pk-report-title-size`, `--pk-print-size` and `--pk-print-small`, [theming](<https://pivotkit.eize.dev/reference/theming/#report-font-sizes>)) and `SHAPE_TOKENS` (`--pk-radius-card`, the chart card), and the chart's `--pk-chart-title-size`, `--pk-chart-text-size` and `--pk-radius-chart` in px (else 16 px, 14 px and 4 px): names and plain values only (a comment, a line break and `url(` anywhere, also in a quoted string, leave the token out), and no function but colours, `calc()`, `min()`, `max()`, `clamp()` and `var()`, so a token loads nothing (`url()` and `image-set()` are left out). A colour scale or data bars whose view names no colours take `--pk-scale-low`, `--pk-scale-mid` (when set) and `--pk-scale-high` (by default the heat tokens) and `--pk-chart-1` in the HTML report's table and in the workbook, when they are `#rgb` or `#rrggbb`. |
| `template` | `DEFAULT_TEMPLATE` | `html`: a [print template](<https://pivotkit.eize.dev/reference/print-template/index.md>). |
| `chart` | `"auto"` | `html`: the chart above the table: `"auto"` when the view shows the chart, `true`, `false`. |
| `chartEmbed` | `"image"` | `html`: the chart as a `data:` image, or `"inline"` SVG. |
| `echarts` |  | The ECharts module with its SVG renderer: `svg`, and `html` with a chart. |
| `size` | `{ width: 800, height: 480 }` | The chart's size in px, 100 to 8,000. |
| `tableCells` | 200,000 | `html`: the cells of the table at most; a larger one is cut after the last whole row and says so. |
| `bom` | `true` | `csv`: start with a byte order mark, so Excel reads the file as UTF-8. `false` for a program that reads the first header cell by its name. |

Any other option name is refused (`INVALID_OPTION`, with the closest name), so a misspelt one is not ignored.

### Library and command line

|  | `render()` | `pivotkit render` |
| --- | --- | --- |
| A view with an error | `ok: false`, body rendered | exit 1, nothing written (`--lenient` writes it, still exit 1) |
| A warning | in `issues` | stderr; exit 1 with `--strict` |
| Limits for the view | `outsideLimits()` unless `trusted` | `outsideLimits()` |
| A limit, a refused template | throws `PivotkitError` | exit 2 |

### Errors

A bad view never throws: its issues come back. `PivotkitError` is thrown for a wrong argument (`INVALID_ARGUMENT`), a wrong option (`INVALID_OPTION` with `details.option`: an unknown `format`, a `size` out of range, an unknown option, a `chart`, `chartEmbed`, `bom`, `strict` or `trusted` of a value it does not take, an SVG or `chart: true` without `echarts`, a `now` that is no date), a limit (`LIMIT_EXCEEDED`) and a refused template (`INVALID_TEMPLATE`, see [print template](<https://pivotkit.eize.dev/reference/print-template/index.md>)). The report's own issues are warnings: `chartUnavailable` (a view that shows the chart, rendered as HTML with `chart` `"auto"` and without `echarts`: the table is there, the chart is not; pass `strict` and check `ok`, or pass `chart: true`, when the chart must be there), `tableCut`, `chartCut` (the first `REPORT_LIMITS.categories` categories, 50) and `viewFile` (an error: the input is no view).

## The formats

| Format | Holds |
| --- | --- |
| `json` | pivotkit-core's [`toJson`](<https://pivotkit.eize.dev/reference/core/index.md>): the header texts per column and each row's labels and values. |
| `csv` | [`toCsvBytes`](<https://pivotkit.eize.dev/reference/core/index.md>): UTF-8 with a byte order mark, the locale's separator, formulas neutralized. |
| `xlsx` | [`toXlsx`](<https://pivotkit.eize.dev/reference/core/index.md>): every number with its decimals and formats, the sheet named after the title. |
| `svg` | The chart as the screen draws it ([`chartPlan`](<https://pivotkit.eize.dev/reference/core/index.md>), the theme's colours, the axis laid out for the size), the first 50 categories. |
| `html` | The report below. |

## The HTML report

The default template writes a page that loads and runs nothing: its Content-Security-Policy is the first element of `<head>` (`default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'`), the chart is an image (`data:image/svg+xml`), and there is no script.

- **The table** as the screen shows it: merged headers and row labels, totals and subtotals shaded and bold, colour scales and data bars, a collapsed group marked "+". Numbers as the table writes them. No sorting buttons, toggles or drill-down: a report is read, not used.
- **The chart** above the table when the view shows the chart (`chart: "auto"`), kept whole on a page.
- **The details**: the source, the data's rows, the filters in words and the time it was generated.
- **On screen** light or dark after the reader's system; **on paper** always light, with 12 mm margins, the header row on every page, rows never split, landscape from 9 columns and a smaller font (7 pt) from 17. A table wider than a landscape page is cut by the browser: split the view, or export Excel.

## The chart as SVG

ECharts' server-side renderer (`echarts.init(null, null, { renderer: "svg", ssr: true })`, then `renderToSVGString()`) draws it; `cleanSvg` then checks every element and attribute: only the elements a chart draws, no event attribute, no link but to a fragment or a `data:` image, no unescaped text, control characters left out, every `style` attribute turned into presentation attributes (so the SVG also shows under a strict Content-Security-Policy). Anything else is refused with `PivotkitError` (`details.reason`). Text from the data is never trusted to the renderer's escaping alone.

## Operations

`pivotkit-report/operations` is the one table of what pivotkit's front ends without a browser offer: the [command line](<https://pivotkit.eize.dev/reference/cli/index.md>), the [MCP server](<https://pivotkit.eize.dev/reference/mcp/index.md>) and the [REST service](<https://pivotkit.eize.dev/reference/rest/index.md>). Each entry of `OPERATIONS` has its `name`, `title` and `description`, its input and output as JSON Schema (draft 2020-12, no `$schema` key), its HTTP route under the REST service's `/api/v1` (`http`), the CLI command it is (`cli`), what it needs from its front end (`needs`), annotations, and `run(input, env)`:

| Operation | CLI | HTTP | What it does |
| --- | --- | --- | --- |
| `list_sources` | \- | `GET /sources` | The sources the caller may use (`env.sources`) |
| `describe_source` | `describe <data>` | `POST /sources/describe` | describeSource with example views; values only with `items` |
| `suggest_view` | `describe <data> --suggest` | `POST /views/suggest` | A draft view file from the source's fields (suggestLayout, `viewOfLayout`), marked `Draft:`, with its `picks`; a `layout` replaces the parts it gives |
| `get_view_guide` | \- | `GET /views/guide` | `VIEW_GUIDE`: a view's format, its vocabulary from `CATALOG`, examples |
| `validate_view` | `validate` | `POST /views/validate` | Findings and the fields the view uses; with a `layout` instead of a `view`, also the `view` it makes |
| `describe_view` | `describe <view>` | `POST /views/describe` | describeView in parts, with findings |
| `render_view` | `render` | `POST /views/render` | render(); the file comes back as `file`, the front end puts it somewhere; a `layout` instead of a `view` (the CLI's layout flags), with the `view` it made in the result |
| `create_link` | \- | `POST /views/link` | A link into the source's page (`env.link`) |
| `draft_schedule` | \- | `POST /schedules/draft` | A draft report document (`pivotkit-report-draft`, version 0) for a person to approve |

```
import { OPERATIONS } from "pivotkit-report/operations";
const env = { data: async (source) => ({ data: encode(rows), name: source }) };
const { result, file } = await OPERATIONS.render_view.run({ source: "sales", view, format: "csv" }, env);
```

A `layout` is a view by field names, pivotkit-core's [`viewFromLayout`](<https://pivotkit.eize.dev/reference/core/#view-from-field-names>) input: `{ rows, cols, values: [{ field, agg }], groups: [{ field, by, size }], filters, chart, map }`, checked by its JSON Schema (no other keys, at most 64 entries per list). `viewOfLayout(description, layout, { suggest, locale })` is the one way the operations and the command line turn it into a view: with `suggest`, suggestLayout's picks first and the layout's parts over them. Its notes become findings by `LAYOUT_NOTE_SEVERITY`: `noSuggestion` (nothing to group by) an error, `dateGrouped` an `info`, `unusedGroup` and `unusedMap` warnings. A request with both a `view` and a `layout`, or neither, is `INVALID_INPUT`.

`env` gives what the operations need: `data(source)` (encoded data, or only `{ name, type }` fields for validate and describe), `sources()`, `link(source)`, `template(source)`, `echarts`, `limits`, `locale`, `timeZone`, `now`, and `renderOptions` for a front end's own render options. A view with errors is no error (`ok` or `valid` false, with `findings`); what a front end must handle is `OperationError` with a `code` of `OPERATION_ERRORS` and its HTTP `status`. `findingOf` turns a pivotkit-core issue into a finding (cleaned, cut at 200 characters); `exampleViews(fields)` builds views from a data set's fields.

## Exports

`render`, `RENDER_FORMATS`, `REPORT_LIMITS` (`template`, `chartWidth`, `chartHeight`, `tableCells`, `categories`), `REPORT_ISSUE_CODES`, `CHART_SIZE`, `cleanSvg`, `DEFAULT_TEMPLATE`, `REPORT_CSP`, `REPORT_TEXTS`, `LIGHT_TOKENS`, `DARK_TOKENS`, `WEIGHT_TOKENS`, `SHAPE_TOKENS`, `SIZE_TOKENS`; `pivotkit-report/template`: `parseTemplate`, `fillTemplate`, `escapeHtml`, `PLACEHOLDERS`, `ATTRIBUTES`, `TEMPLATE_LIMIT` ([print template](<https://pivotkit.eize.dev/reference/print-template/index.md>)); `pivotkit-report/operations`: `OPERATIONS`, `OPERATION_NAMES`, `OPERATION_ERRORS`, `OperationError`, `VIEW_GUIDE`, `exampleViews`, `findingOf`, `findingText`, `FINDING_TEXT`, `viewOfLayout`, `LAYOUT_NOTE_SEVERITY`, `checkInput` (an operation's input against its JSON Schema) ([operations](<#operations>)).
