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/CHANGELOG.md +88 -0
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +78 -4
- package/src/default-template.js +33 -0
- package/src/html-table.js +104 -0
- package/src/index.js +24 -0
- package/src/operations.js +808 -0
- package/src/render.js +279 -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/operations.d.ts +362 -0
- package/types/render.d.ts +177 -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/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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
+
}
|