pivotkit-report 0.0.0-stage → 0.2.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/src/render.js ADDED
@@ -0,0 +1,279 @@
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, reportLabels, 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, keptRows: number, truncated: boolean } | null} summary the table's body rows and columns, the data's rows, the rows its filters keep, 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
+ // the report's own words in the language of its locale (pivotkit-core's REPORT_LABELS, as the report on a page shows them), unless given
189
+ const grid = toGrid(result, { labels: options.labels ?? reportLabels(result.locale ?? pivotOptions.locale), ...(options.limits ? { limits: options.limits } : {}) });
190
+ const summary = { rows: grid.bodyRows.length, columns: grid.columnCount, sourceRows: data2.length, keptRows: result.rows.length, truncated: false };
191
+ const texts = textsFor(grid.locale, options.texts);
192
+ const title = (options.title ?? (read.name || normalized.view.chart.title || texts.untitled)).trim() || texts.untitled;
193
+ const chartOf = () =>
194
+ chartSvg(result, {
195
+ echarts: /** @type {any} */ (options.echarts),
196
+ style: tokenStyle(options.tokens),
197
+ // set for the formats that draw a chart (svg, html), the only callers of chartOf
198
+ .../** @type {{ width: number, height: number }} */ (size),
199
+ locale: grid.locale,
200
+ labels: options.labels,
201
+ limits: options.limits,
202
+ now: options.now,
203
+ maxCategories: REPORT_LIMITS.categories
204
+ });
205
+ const chartCut = (chart) => {
206
+ if (chart.categories < chart.of) {
207
+ summary.truncated = true;
208
+ issues.push(reportIssue("chartCut", fill(REPORT_TEXTS_EN.chartCut, { shown: chart.categories, count: chart.of }), { value: String(chart.categories), max: chart.of }));
209
+ }
210
+ };
211
+ /** @type {Uint8Array} */
212
+ let body;
213
+ if (format === "json") {
214
+ body = new TextEncoder().encode(toJson(grid));
215
+ } else if (format === "csv") {
216
+ body = toCsvBytes(grid, { bom: options.bom !== false });
217
+ } else if (format === "xlsx") {
218
+ body = toXlsx(grid, { sheetName: title, theme: conditionalColors(options.tokens) });
219
+ } else if (format === "svg") {
220
+ const chart = chartOf();
221
+ chartCut(chart);
222
+ body = new TextEncoder().encode(chart.svg);
223
+ } else {
224
+ const number = new Intl.NumberFormat(grid.locale);
225
+ const table = htmlTable(grid, { maxCells: options.tableCells ?? REPORT_LIMITS.tableCells, ...conditionalColors(options.tokens) });
226
+ let tableNote = "";
227
+ if (table.cut) {
228
+ summary.truncated = true;
229
+ 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 }));
230
+ tableNote = `<p class="pk-report-note">${escapeHtml(fill(texts.tableCut, { shown: number.format(table.cut.rows), count: number.format(table.cut.of) }))}</p>`;
231
+ }
232
+ const wantChart = options.chart === true || ((options.chart ?? "auto") === "auto" && normalized.view.display === "chart");
233
+ let chartHtml = "";
234
+ if (wantChart && typeof options.echarts?.init !== "function") {
235
+ issues.push(reportIssue("chartUnavailable", REPORT_TEXTS_EN.chartUnavailable));
236
+ } else if (wantChart) {
237
+ const chart = chartOf();
238
+ chartCut(chart);
239
+ 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>` : "";
240
+ 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))}">`;
241
+ chartHtml = `<figure class="pk-report-chart">${inner}${note}</figure>`;
242
+ }
243
+ const described = describeView(normalized.view, data2, { ...(limits ? { limits } : {}) });
244
+ const timeZone = options.timeZone ?? grid.timeZone ?? "UTC";
245
+ const values = {
246
+ title,
247
+ description: options.description ?? read.description ?? "",
248
+ source: options.source ?? "",
249
+ generated: generatedText(options.now, grid.locale, timeZone),
250
+ rows: fill(texts.rowsCount, { count: number.format(data2.length) }),
251
+ filters: described.filters.length ? described.filters.map((f) => f.text).join("; ") : texts.noFilters,
252
+ summary: described.text,
253
+ lang: grid.locale,
254
+ dir: RTL.has(grid.locale.split("-")[0].toLowerCase()) ? "rtl" : "ltr"
255
+ };
256
+ const detail = (label, value) => (value ? `<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>` : "");
257
+ const markup = {
258
+ style: `<style>${reportCss({ columns: grid.columnCount, tokens: cleanTokens(options.tokens ?? {}), extra: table.css })}</style>`,
259
+ chart: chartHtml,
260
+ table: table.html + tableNote,
261
+ details: `<dl class="pk-report-details">${detail(texts.source, values.source)}${detail(texts.rows, number.format(data2.length))}${detail(texts.filters, values.filters)}${detail(texts.generated, values.generated)}</dl>`
262
+ };
263
+ body = new TextEncoder().encode(fillTemplate(/** @type {any} */ (template), { ...values, ...markup }));
264
+ }
265
+ const out = { ...base, body, issues, ok, summary, view: normalized.view };
266
+ return kind.text ? { ...out, text: new TextDecoder().decode(body) } : out;
267
+ }
268
+
269
+ /** The English texts, for the issues (a program reads them, not the report's reader). */
270
+ const REPORT_TEXTS_EN = textsFor("en");
271
+
272
+ /** Bytes as base64, in browsers and Node alike. */
273
+ function base64(bytes) {
274
+ let binary = "";
275
+ for (let i = 0; i < bytes.length; i += 0x8000) {
276
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
277
+ }
278
+ return btoa(binary);
279
+ }
@@ -0,0 +1,61 @@
1
+ /*
2
+ * The report's stylesheet ({{style}}): the tokens (light, dark on a dark screen, always light on
3
+ * paper), the table as the screen's report draws it (pivotkit-react's styles.css: totals shaded and
4
+ * bold, colour scales and data bars from --pk-cell-bg, --pk-bar and --pk-bar-color) and the print
5
+ * rules: margins, the header row on every page, rows not split, the chart kept whole, landscape for a
6
+ * wide table and a smaller font (--pk-print-small) for a very wide one. Every font size is a token
7
+ * (SIZE_TOKENS), so a host's sizes reach it.
8
+ */
9
+
10
+ import { DARK_TOKENS, LIGHT_TOKENS, REPORT_FONT, SHAPE_TOKENS, SIZE_TOKENS, WEIGHT_TOKENS, declarations } from "./tokens.js";
11
+
12
+ /** Columns from which a printed table is turned to landscape, and from which its font gets smaller. */
13
+ export const PRINT_WIDE = Object.freeze({ landscape: 8, small: 16 });
14
+
15
+ /**
16
+ * @param {{ columns: number, tokens?: Record<string, string>, extra?: string }} options
17
+ * columns: the table's; tokens: the host's (cleaned), over the light ones; extra: the cells' colour rules
18
+ */
19
+ export function reportCss({ columns, tokens = {}, extra = "" }) {
20
+ const font = tokens["font-family"] ?? REPORT_FONT;
21
+ const light = declarations({ ...LIGHT_TOKENS, ...WEIGHT_TOKENS, ...SHAPE_TOKENS, ...SIZE_TOKENS, ...tokens });
22
+ // the chart is drawn in the light colours (the host's), on a screen in dark mode too: its card keeps
23
+ // the light surface the chart's image is drawn on
24
+ const chartSurface = { ...LIGHT_TOKENS, ...tokens }["--pk-surface"];
25
+ const page = columns > PRINT_WIDE.landscape ? "size:landscape;" : "";
26
+ return `:root{${light};color-scheme:light dark}
27
+ @media screen and (prefers-color-scheme:dark){:root{${declarations(DARK_TOKENS)}}}
28
+ *{box-sizing:border-box}
29
+ body{margin:0;background:var(--pk-surface);color:var(--pk-text);font:var(--pk-font-size)/1.45 ${font}}
30
+ .pk-report{padding:24px}
31
+ .pk-report h1{font-size:var(--pk-report-title-size);font-weight:var(--pk-weight-bold);line-height:1.3;margin:0 0 4px}
32
+ .pk-report-description{margin:0 0 12px;color:var(--pk-muted)}
33
+ .pk-report-description:empty{display:none}
34
+ .pk-report-chart{margin:12px 0 16px;padding:8px;background:${chartSurface};border:1px solid var(--pk-border);border-radius:var(--pk-radius-card);break-inside:avoid;width:max-content;max-width:100%}
35
+ .pk-report-chart img,.pk-report-chart svg{display:block;max-width:100%;height:auto}
36
+ .pk-table{border-collapse:collapse;font-variant-numeric:tabular-nums;margin:12px 0 0}
37
+ .pk-table th,.pk-table td{border:1px solid var(--pk-border);padding:4px 8px;vertical-align:top}
38
+ .pk-th{background:var(--pk-surface-2);text-align:left;font-weight:var(--pk-weight-strong)}
39
+ .pk-th.pk-row{font-weight:var(--pk-weight-normal);background:var(--pk-surface)}
40
+ .pk-num{text-align:right;white-space:nowrap}
41
+ .pk-table .pk-sub{background:var(--pk-total);font-weight:var(--pk-weight-strong)}
42
+ .pk-table .pk-grand{background:var(--pk-total-strong);font-weight:var(--pk-weight-bold)}
43
+ .pk-collapsed::before{content:"+ ";color:var(--pk-muted)}
44
+ .pk-td.pk-has-scale{background:color-mix(in srgb,var(--pk-cell-bg) 48%,var(--pk-surface))}
45
+ .pk-td.pk-has-bar{background-image:linear-gradient(90deg,color-mix(in srgb,var(--pk-bar-color,var(--pk-chart-1)) 32%,transparent) var(--pk-bar),transparent var(--pk-bar));background-repeat:no-repeat;background-size:100% 64%;background-position:left center}
46
+ .pk-report-note{color:var(--pk-muted);margin:8px 0}
47
+ .pk-report-details{display:grid;grid-template-columns:max-content 1fr;gap:2px 12px;margin:16px 0 0;color:var(--pk-muted);font-size:var(--pk-small)}
48
+ .pk-report-details dt{font-weight:var(--pk-weight-strong)}
49
+ .pk-report-details dd{margin:0}
50
+ @page{margin:12mm;${page}}
51
+ @media print{
52
+ body{font-size:var(--pk-print-size);-webkit-print-color-adjust:exact;print-color-adjust:exact}
53
+ .pk-report{padding:0}
54
+ .pk-report-chart{border:none;padding:0}
55
+ thead{display:table-header-group}
56
+ tr{break-inside:avoid}
57
+ .pk-table th,.pk-table td{padding:2px 6px}
58
+ ${columns > PRINT_WIDE.small ? ".pk-table{font-size:var(--pk-print-small)}" : ""}
59
+ }
60
+ ${extra}`;
61
+ }
package/src/svg.js ADDED
@@ -0,0 +1,170 @@
1
+ /*
2
+ * The chart as SVG: pivotkit-core's ECharts option with the report's marks (pivotkit-core/chart, the
3
+ * same look as on the screen) drawn by ECharts' server-side SVG renderer (`init(null, null, { renderer:
4
+ * "svg", ssr: true })` and `renderToSVGString()`, ECharts 5.3 and later), then checked and cleaned by
5
+ * cleanSvg: the text of a chart comes from the data, and an SVG opened on its own can run script, so
6
+ * nothing is trusted to the renderer's escaping alone.
7
+ */
8
+
9
+ import { PivotkitError, chartPlan, chartThemeFrom, computePivot, swapAxes, toEChartsOption, toSeries } from "pivotkit-core";
10
+ import { decorateOption } from "pivotkit-core/chart";
11
+ import { BIDI_CONTROLS } from "./texts.js";
12
+ import { LIGHT_TOKENS } from "./tokens.js";
13
+
14
+ /** Presentation attributes a `style` attribute's declarations become (other declarations are dropped). */
15
+ const PRESENTATION = new Set(["fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity", "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "opacity", "font-size", "font-family", "font-weight", "font-style", "text-anchor", "dominant-baseline", "paint-order", "visibility"]);
16
+ /** Elements an ECharts SVG may hold. */
17
+ const ELEMENTS = new Set(["svg", "g", "rect", "path", "text", "tspan", "circle", "ellipse", "line", "polyline", "polygon", "defs", "clipPath", "linearGradient", "radialGradient", "stop", "pattern", "image", "title", "desc"]);
18
+
19
+ /** Control characters XML does not allow (an SVG with one is not shown at all): left out. */
20
+ // eslint-disable-next-line no-control-regex
21
+ const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
22
+
23
+ /** Whether XML 1.0 allows a character (Char): a numeric reference to any other makes the SVG unreadable. */
24
+ const xmlChar = (code) => code === 0x9 || code === 0xa || code === 0xd || (code >= 0x20 && code <= 0xd7ff && code !== 0x7f) || (code >= 0xe000 && code <= 0xfffd) || (code >= 0x10000 && code <= 0x10ffff);
25
+
26
+ /** Whether a numeric reference stays: a character XML allows that is no bidi control. */
27
+ const keptReference = (code) => xmlChar(code) && !Number.isNaN(code) && !String.fromCodePoint(code).match(BIDI_CONTROLS);
28
+
29
+ /** An attribute name as XML writes one: a quote, "=" or "<" in a name would break the SVG as XML. */
30
+ const ATTRIBUTE_NAME = /^[A-Za-z_][\w:.-]*$/;
31
+ /** The namespaces an ECharts SVG declares: the default one is SVG's, xlink: for old links. */
32
+ const NAMESPACES = Object.freeze({ xmlns: "http://www.w3.org/2000/svg", "xmlns:xlink": "http://www.w3.org/1999/xlink" });
33
+
34
+ const decode = (text) => text.replace(/&(quot|#39|apos|lt|gt|amp);/g, (_, e) => ({ quot: '"', "#39": "'", apos: "'", lt: "<", gt: ">", amp: "&" })[e]);
35
+ const encode = (text) => text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
36
+
37
+ /** @param {string} reason */
38
+ const unsafe = (reason) => new PivotkitError("INVALID_ARGUMENT", `the chart's SVG was refused: ${reason}`, { reason });
39
+
40
+ /**
41
+ * Checks an SVG string and makes it safe to open and to show under a strict Content-Security-Policy:
42
+ * only the elements an ECharts chart draws, no event handler attribute, no link but to a fragment or
43
+ * a data: image, no attribute name but a plain one, no namespace but SVG's and xlink's, no text that is not escaped; every `style` attribute becomes presentation attributes
44
+ * (a policy without 'unsafe-inline' blocks style attributes, not those) and a <style> element (ECharts'
45
+ * hover states) and characters XML does not allow, also as numeric references, are left out. Throws INVALID_ARGUMENT
46
+ * (`details.reason`) for anything else.
47
+ * @param {string} svg
48
+ * @returns {string}
49
+ */
50
+ export function cleanSvg(svg) {
51
+ const out = [];
52
+ const tag = /<(\/?)([a-zA-Z][\w:-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'))?)*)\s*(\/?)>/y;
53
+ const attribute = /\s+([^\s=>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
54
+ let i = svg.startsWith("<?xml") ? svg.indexOf("?>") + 2 : 0;
55
+ let depth = 0;
56
+ while (i < svg.length) {
57
+ const lt = svg.indexOf("<", i);
58
+ const text = svg.slice(i, lt === -1 ? svg.length : lt);
59
+ if (/[<>]/.test(text) || /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/.test(text)) {
60
+ throw unsafe("text that is not escaped");
61
+ }
62
+ out.push(text.replace(CONTROL, "").replace(BIDI_CONTROLS, "").replace(/&#(x[0-9a-fA-F]+|\d+);/g, (entity, number) => (keptReference(number[0] === "x" ? parseInt(number.slice(1), 16) : Number(number)) ? entity : "")));
63
+ if (lt === -1) {
64
+ break;
65
+ }
66
+ // ECharts writes the hover states of its marks as a <style> of CSS classes: a static image has
67
+ // no hover, and an SVG shown under a strict Content-Security-Policy may hold no <style>
68
+ if (/^<style[\s>]/i.test(svg.slice(lt, lt + 7))) {
69
+ const end = svg.search(/<\/style\s*>/i);
70
+ if (end === -1 || end < lt) {
71
+ throw unsafe("a <style> element that is not closed");
72
+ }
73
+ i = svg.indexOf(">", end) + 1;
74
+ continue;
75
+ }
76
+ if (svg.startsWith("<!--", lt)) {
77
+ const end = svg.indexOf("-->", lt);
78
+ i = end === -1 ? svg.length : end + 3;
79
+ continue;
80
+ }
81
+ tag.lastIndex = lt;
82
+ const m = tag.exec(svg);
83
+ if (!m) {
84
+ throw unsafe("markup that is not a plain element");
85
+ }
86
+ const [, closing, name, attrs, selfClosing] = m;
87
+ if (!ELEMENTS.has(name)) {
88
+ throw unsafe(`the element <${name}>`);
89
+ }
90
+ let written = `<${closing}${name}`;
91
+ const presentation = new Map();
92
+ for (const [, attrName, double, single] of attrs.matchAll(attribute)) {
93
+ const value = decode(double ?? single ?? "").replace(CONTROL, "").replace(BIDI_CONTROLS, "");
94
+ const lower = attrName.toLowerCase();
95
+ if (!ATTRIBUTE_NAME.test(attrName)) {
96
+ throw unsafe("an attribute name that is not a plain name");
97
+ }
98
+ if ((lower === "xmlns" || lower.startsWith("xmlns:")) && NAMESPACES[lower] !== value) {
99
+ throw unsafe(`the namespace ${attrName}`);
100
+ }
101
+ if (lower.startsWith("on")) {
102
+ throw unsafe(`the event attribute ${attrName}`);
103
+ }
104
+ if ((lower === "href" || lower === "xlink:href" || lower === "src") && !(/^#[\w-]+$/.test(value) || /^data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]*$/.test(value))) {
105
+ throw unsafe(`a link in ${attrName}`);
106
+ }
107
+ if (lower === "style") {
108
+ for (const declaration of value.split(";")) {
109
+ const colon = declaration.indexOf(":");
110
+ const prop = declaration.slice(0, colon).trim().toLowerCase();
111
+ const v = declaration.slice(colon + 1).trim();
112
+ if (colon > 0 && PRESENTATION.has(prop) && !/url\s*\(|expression\s*\(/i.test(v)) {
113
+ presentation.set(prop, v);
114
+ }
115
+ }
116
+ continue;
117
+ }
118
+ if (/url\s*\(\s*['"]?\s*(?!#)/i.test(value)) {
119
+ throw unsafe(`an address in ${attrName}`);
120
+ }
121
+ written += ` ${attrName}="${encode(value)}"`;
122
+ }
123
+ for (const [prop, v] of presentation) {
124
+ if (!new RegExp(`\\s${prop}=`).test(written)) {
125
+ written += ` ${prop}="${encode(v)}"`;
126
+ }
127
+ }
128
+ written += `${selfClosing}>`;
129
+ out.push(written);
130
+ if (name === "svg") {
131
+ depth += closing ? -1 : selfClosing ? 0 : 1;
132
+ }
133
+ i = tag.lastIndex;
134
+ }
135
+ if (depth !== 0) {
136
+ throw unsafe("an <svg> element that is not closed");
137
+ }
138
+ return out.join("");
139
+ }
140
+
141
+ /**
142
+ * The chart of a pivot as SVG, as pivotkit-react draws it: chartPlan's type (the view's, or what
143
+ * "auto" picks; rows and columns swapped for the chart where it says so), the theme's colours from
144
+ * `style`, the axis labels laid out for the size, at most `maxCategories` categories.
145
+ * @param {import("pivotkit-core").PivotResult} result
146
+ * @param {{ echarts: { init: Function }, style: { getPropertyValue(name: string): string }, width: number, height: number,
147
+ * locale: string, labels?: object, limits?: object, now?: any, maxCategories: number }} options
148
+ * @returns {{ svg: string, categories: number, of: number }}
149
+ */
150
+ export function chartSvg(result, { echarts, style, width, height, locale, labels, limits, now, maxCategories }) {
151
+ const view = result.view;
152
+ const plan = chartPlan(view, result.encoded);
153
+ const drawn = plan.swap ? computePivot(result.encoded, swapAxes(view), { locale, limits, ...(now !== undefined ? { now } : {}) }) : result;
154
+ const series = toSeries(drawn, { labels, limits, maxCategories });
155
+ const option = toEChartsOption(drawn, { theme: chartThemeFrom(style), locale, labels, limits, maxCategories, series, chart: plan.chart });
156
+ const decorated = decorateOption(option, style, { width, height, categoryNames: series.categories, axisLabels: view.chart.axisLabels });
157
+ const surface = style.getPropertyValue("--pk-surface").trim() || LIGHT_TOKENS["--pk-surface"];
158
+ const chart = echarts.init(null, null, { renderer: "svg", ssr: true, width, height });
159
+ let svg;
160
+ try {
161
+ // axisEvery is the report's own note, not ECharts'
162
+ const { axisEvery, ...option2 } = decorated;
163
+ void axisEvery;
164
+ chart.setOption({ ...option2, backgroundColor: surface, animation: false });
165
+ svg = chart.renderToSVGString();
166
+ } finally {
167
+ chart.dispose();
168
+ }
169
+ return { svg: cleanSvg(svg), categories: series.categories.length, of: series.categoryCount };
170
+ }