@sportsdataverse/sdvtables 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.
@@ -0,0 +1,190 @@
1
+ import { RowFilter } from '@sportsdataverse/sdvplot';
2
+ import { T as TableSpec, C as ColumnSpec } from './spec-A88vJX5A.js';
3
+
4
+ type SortDir = "asc" | "desc";
5
+ interface Sort {
6
+ readonly col: string;
7
+ readonly dir: SortDir;
8
+ }
9
+ /** A column filter as a function: tests the column's cell `value` (the whole `row` comes along for context). */
10
+ type ValuePredicate<Row> = (value: unknown, row: Row) => boolean;
11
+ /** What `setFilter` takes for a column: a predicate on its value, a case-insensitive substring, or an exact number or boolean. */
12
+ type FilterValue<Row> = ValuePredicate<Row> | string | number | boolean;
13
+ type Comparator = (a: unknown, b: unknown) => number;
14
+ /** J31: what `subscribe` listeners receive. Every mutation emits exactly one event. */
15
+ type TableEvent = {
16
+ readonly type: "change";
17
+ } | {
18
+ readonly type: "hover";
19
+ readonly id: string | null;
20
+ } | {
21
+ readonly type: "select";
22
+ readonly ids: ReadonlySet<string>;
23
+ };
24
+ /**
25
+ * Task 10 (A45, A48): the keyboard cursor of an interactive table. `row` is the body row holding the grid's one tab
26
+ * stop, as a page-relative index (that row's `data-row`); `col` is the column `s` sorts, null until `h`/`l` picks one.
27
+ */
28
+ interface TableCursor {
29
+ readonly row: number;
30
+ readonly col: string | null;
31
+ }
32
+ /** The whole user-controlled state of a table: sort, filters, page, hidden columns, and the J31 link fields. Immutable; every mutation replaces it. */
33
+ interface TableState<Row> {
34
+ readonly sort: Sort | null;
35
+ readonly filters: Readonly<Record<string, FilterValue<Row>>>;
36
+ readonly globalFilter: string;
37
+ /** 0-based */
38
+ readonly page: number;
39
+ /** positive integer, or Infinity for "all rows" */
40
+ readonly pageSize: number;
41
+ /** column keys hidden by toggleColumn */
42
+ readonly hidden: readonly string[];
43
+ /** J31: set by setExternalFilter; ANDed with the column and global filters */
44
+ readonly externalFilter: RowFilter<Row> | null;
45
+ /** J31: row ids (see `rowId`); NOT pruned to the rows present, so a linked store's ids round-trip intact */
46
+ readonly selection: ReadonlySet<string>;
47
+ /** Task 10: the keyboard cursor; its row is kept on the current page */
48
+ readonly cursor: TableCursor;
49
+ }
50
+ /** What `Table.getSnapshot()` returns: the state plus the derived visible page. Referentially stable until the next mutation. */
51
+ interface TableSnapshot<Row> {
52
+ readonly state: TableState<Row>;
53
+ /** the visible page: filtered → sorted → sliced */
54
+ readonly rows: readonly Row[];
55
+ readonly filteredCount: number;
56
+ /** always at least 1 */
57
+ readonly pageCount: number;
58
+ }
59
+ /** Initial state for `createTable`; `pageSize` beats `spec.interactive.pageSize`, and `sort` must name a spec column. */
60
+ interface TableOptions {
61
+ readonly pageSize?: number;
62
+ readonly sort?: Sort;
63
+ }
64
+ /**
65
+ * The headless table engine: holds the rows and a `TableState`, derives the visible page, and notifies subscribers.
66
+ * Renderers (static HTML, hydrate, React) read `rows`/`columns` and call the setters; nothing here touches the DOM.
67
+ *
68
+ * @example A hover set before any view attaches: the view lights it when it does (getHover)
69
+ * ```ts
70
+ * import { createTable, defineTable } from "@sportsdataverse/sdvtables";
71
+ * import { hydrate, renderHTML } from "@sportsdataverse/sdvtables/html";
72
+ *
73
+ * // 2024 AFC: Kansas City 15 wins, Buffalo 13
74
+ * const spec = defineTable<{ team: string; wins: number }>()
75
+ * .columns((c) => [c.text("team"), c.int("wins")])
76
+ * .rowKey("team")
77
+ * .build();
78
+ * const table = createTable(spec, [
79
+ * { team: "KC", wins: 15 },
80
+ * { team: "BUF", wins: 13 },
81
+ * ]);
82
+ * table.setHover("BUF"); // a linked figure's hover, before the table is on the page
83
+ * table.getHover(); // "BUF"
84
+ * const root = document.createElement("div");
85
+ * root.innerHTML = renderHTML(table, { fonts: false });
86
+ * hydrate(root, table); // Buffalo's row gets sdvt-hover as it attaches
87
+ * root;
88
+ * ```
89
+ */
90
+ interface Table<Row> {
91
+ readonly spec: TableSpec<Row>;
92
+ /** the source rows: the ones given to `createTable`, or the last `setRows` */
93
+ readonly allRows: readonly Row[];
94
+ readonly state: TableState<Row>;
95
+ readonly rows: readonly Row[];
96
+ readonly filteredCount: number;
97
+ readonly pageCount: number;
98
+ /** visible columns in spec order */
99
+ readonly columns: readonly ColumnSpec<Row>[];
100
+ setSort(col: string, dir: SortDir | null): void;
101
+ setFilter(col: string, filter: FilterValue<Row> | null | undefined): void;
102
+ setGlobalFilter(text: string): void;
103
+ setPage(n: number): void;
104
+ setPageSize(n: number): void;
105
+ toggleColumn(col: string, visible?: boolean): void;
106
+ /**
107
+ * Replace the source rows (a React parent's new array, a live feed). Sort, filters, the external filter, hidden
108
+ * columns and selection are kept; the page is clamped to the new page count; one `"change"` event. Without a
109
+ * `rowKey`, row ids are indices into the NEW rows, so a kept selection means the rows at those positions.
110
+ */
111
+ setRows(rows: readonly Row[]): void;
112
+ /** J31: `String(row[spec.rowKey])` ("" when missing), else the row's index in `allRows` */
113
+ rowId(row: Row): string;
114
+ /** J31: same function again is a no-op; a new one resets the page */
115
+ setExternalFilter(filter: RowFilter<Row> | null): void;
116
+ /** J31: emits `{ type: "select" }`; an equal set is a no-op */
117
+ setSelection(ids: ReadonlySet<string>): void;
118
+ getSelection(): ReadonlySet<string>;
119
+ /** J31: emits `{ type: "hover" }` without changing state; the same id twice in a row is a no-op */
120
+ setHover(id: string | null): void;
121
+ /**
122
+ * J31: the last `setHover` id, or null. A view that attaches (`hydrate`) or mounts (`<SdvTable table/>`) after a
123
+ * hover reads it, since no event comes until the hover changes.
124
+ */
125
+ getHover(): string | null;
126
+ /**
127
+ * Task 10: move the keyboard cursor. `row` is clamped to the current page; `col` must name a spec column, or be
128
+ * null. One `"change"` event; an equal cursor (after clamping) is a no-op, and a non-finite `row` is ignored.
129
+ */
130
+ setCursor(row: number, col: string | null): void;
131
+ subscribe(fn: (event: TableEvent) => void): () => void;
132
+ /** referentially stable until the next mutation — what useSyncExternalStore reads */
133
+ getSnapshot(): TableSnapshot<Row>;
134
+ }
135
+ /** Phase 4 column kinds whose values sort numerically. */
136
+ declare const NUMERIC_KINDS: ReadonlySet<string>;
137
+ /** null, undefined, "", NaN, and an invalid `Date` are missing; `withMissingLast` sorts them last. */
138
+ declare function isMissing(v: unknown): boolean;
139
+ /**
140
+ * The comparator `applySort` uses for `col`: its own `compare`, else numeric for a numeric kind or numeric samples, by
141
+ * time for `Date` samples, else `en`-locale text.
142
+ * Engine plumbing for custom renderers: it may change before 1.0.
143
+ *
144
+ * @beta
145
+ */
146
+ declare function comparatorFor<Row>(col: ColumnSpec<Row>, rows: readonly Row[]): Comparator;
147
+ /** Missing values sort last in BOTH directions; never returns NaN. */
148
+ declare function withMissingLast(cmp: Comparator, dir: SortDir): Comparator;
149
+ /**
150
+ * The spec's column with this key; throws `TableSpecError` naming every column when there is none.
151
+ * Engine plumbing for custom renderers: it may change before 1.0.
152
+ *
153
+ * @beta
154
+ */
155
+ declare function findColumn<Row>(spec: TableSpec<Row>, key: string): ColumnSpec<Row>;
156
+ /**
157
+ * A sorted copy of `rows` (stable; missing values last in both directions); `sort` null keeps the input order.
158
+ * Engine plumbing for custom renderers: it may change before 1.0.
159
+ *
160
+ * @beta
161
+ */
162
+ declare function applySort<Row>(spec: TableSpec<Row>, rows: readonly Row[], sort: Sort | null): Row[];
163
+ /**
164
+ * The rows `state` keeps, in input order: its external filter, every column filter and the global search over the
165
+ * visible columns, all ANDed.
166
+ * Engine plumbing for custom renderers: it may change before 1.0.
167
+ *
168
+ * @beta
169
+ */
170
+ declare function applyFilters<Row>(spec: TableSpec<Row>, rows: readonly Row[], state: TableState<Row>): Row[];
171
+ /**
172
+ * Page `page` (0-based, clamped to the pages there are) of `rows` at `pageSize` (Infinity: all of them), and the
173
+ * page count, at least 1.
174
+ * Engine plumbing for custom renderers: it may change before 1.0.
175
+ *
176
+ * @beta
177
+ */
178
+ declare function paginate<Row>(rows: readonly Row[], page: number, pageSize: number): {
179
+ rows: Row[];
180
+ pageCount: number;
181
+ };
182
+ /** Header-click cycle shared by hydrate and React: asc → desc → none. */
183
+ declare function nextSortDir(current: Sort | null, col: string): SortDir | null;
184
+ /**
185
+ * Build a headless `Table` over `rows`: sort, filter, paginate, hide columns, and subscribe to changes.
186
+ * Throws `TableSpecError` for an unknown column or an invalid page size; never mutates `rows`.
187
+ */
188
+ declare function createTable<Row>(spec: TableSpec<Row>, rows: readonly Row[], options?: TableOptions): Table<Row>;
189
+
190
+ export { type Comparator as C, type FilterValue as F, NUMERIC_KINDS as N, type Sort as S, type TableCursor as T, type ValuePredicate as V, type Table as a, type SortDir as b, createTable as c, type TableEvent as d, type TableOptions as e, type TableSnapshot as f, type TableState as g, applyFilters as h, applySort as i, comparatorFor as j, findColumn as k, isMissing as l, nextSortDir as n, paginate as p, withMissingLast as w };
@@ -0,0 +1,88 @@
1
+ import { Aspect, Gravity } from '@sportsdataverse/sdvplot/export';
2
+ import { T as TableSpec } from './spec-A88vJX5A.js';
3
+ import '@sportsdataverse/sdvplot';
4
+
5
+ type TableItem<Row> = {
6
+ spec: TableSpec<Row>;
7
+ rows: readonly Row[];
8
+ } | string;
9
+ interface ComposeOptions {
10
+ title?: string;
11
+ subtitle?: string;
12
+ caption?: string;
13
+ sourceNote?: string;
14
+ captionRule?: boolean;
15
+ }
16
+ interface GridOptions extends ComposeOptions {
17
+ ncol?: number;
18
+ gap?: number;
19
+ align?: "top" | "center" | "bottom";
20
+ labels?: readonly string[];
21
+ }
22
+ interface StackOptions extends ComposeOptions {
23
+ gap?: number;
24
+ align?: "left" | "center" | "right";
25
+ }
26
+ /**
27
+ * A group value as `batchToPNG` puts it in a file name (Python `gt_save_batch`): each run of characters outside
28
+ * `A-Za-z0-9._-` becomes "-", the ends are trimmed of "-", and it is lower-cased.
29
+ * Export plumbing: it may change before 1.0.
30
+ *
31
+ * @beta
32
+ */
33
+ declare function slug(value: unknown): string;
34
+ /** Title/subtitle above, caption/source note below — the shared frame of gt_grid and gt_stack_tables. */
35
+ declare function composePage(body: string, { title, subtitle, caption, sourceNote, captionRule }?: ComposeOptions): string;
36
+ /** Port of gt_grid: small multiples, `ncol` across, rows follow from the count. */
37
+ declare function gridTables<Row>(items: readonly TableItem<Row>[], { ncol, gap, align, labels, ...compose }?: GridOptions): string;
38
+ /** Port of gt_stack_tables: a vertical stack. */
39
+ declare function stackTables<Row>(items: readonly TableItem<Row>[], { gap, align, ...compose }?: StackOptions): string;
40
+
41
+ /**
42
+ * Options for {@link htmlToPNG} and {@link tableToPNG}; defaults are Python `gt_save_crop`'s. Its `expand` (the page
43
+ * captured before trimming) is fixed here, as it only has to leave background around the content. (The HTML itself
44
+ * takes `RenderOptions` from `@sportsdataverse/sdvtables/html`.)
45
+ */
46
+ interface PNGOptions {
47
+ /** the final image width in pixels, the height following (Python `width`); absent: the rendered width */
48
+ width?: number;
49
+ /** the rendering zoom (Python `zoom`); default 2, a sharp (retina) image */
50
+ deviceScaleFactor?: number;
51
+ /** the color around the content; default "white" */
52
+ background?: string;
53
+ /** image pixels of `background` left around the trimmed content (Python `whitespace`, truncated to an integer); default 50 */
54
+ whitespace?: number;
55
+ /** stylesheet URLs (Google Fonts) awaited via document.fonts.ready */
56
+ fontLinks?: readonly string[];
57
+ /** also write the PNG here (must end in .png) */
58
+ file?: string;
59
+ }
60
+ /** Options for {@link socialCrop}; defaults are Python `gt_social_crop`'s (whitespace 60). */
61
+ interface SocialCropOptions extends PNGOptions {
62
+ aspect?: Aspect;
63
+ gravity?: Gravity;
64
+ }
65
+ /** Options for {@link batchToPNG}, as Python `gt_save_batch` (no `width`; each file is named by its group). */
66
+ interface BatchOptions extends Omit<PNGOptions, "width" | "file"> {
67
+ dir: string;
68
+ matchWidth?: boolean;
69
+ }
70
+ /**
71
+ * gt_save_crop for any HTML: rendered at `deviceScaleFactor`, trimmed to its content, `whitespace` image pixels of
72
+ * `background` put back around it, then scaled to `width` when given. An image past Chromium's canvas limit (65,535
73
+ * px a side, 268,435,456 px in all; near 1,000 plain rows at zoom 2) throws `SdvplotError` naming its size, before
74
+ * anything is drawn or written.
75
+ */
76
+ declare function htmlToPNG(html: string, options?: PNGOptions): Promise<Uint8Array>;
77
+ /** gt_save_crop: {@link htmlToPNG} of the rendered table. */
78
+ declare function tableToPNG<Row>(spec: TableSpec<Row>, rows: readonly Row[], options?: PNGOptions): Promise<Uint8Array>;
79
+ /** gt_social_crop: the trimmed, padded table on a fixed-ratio canvas placed by `gravity`; the table is never cropped. */
80
+ declare function socialCrop<Row>(spec: TableSpec<Row>, rows: readonly Row[], { aspect, gravity, ...rest }?: SocialCropOptions): Promise<Uint8Array>;
81
+ /**
82
+ * gt_save_batch: one image per group value, `{group}` in the file name replaced by `slug(value)`. A group whose table
83
+ * fails to build (`build` or the spec throws) is skipped and named in one warning at the end; when none builds, the
84
+ * error lists every failure. Python's `quiet` progress lines are not ported.
85
+ */
86
+ declare function batchToPNG<Row, G extends keyof Row>(rows: readonly Row[], group: G, build: (groupRows: Row[], value: Row[G]) => TableItem<Row>, filePattern: string, { dir, matchWidth, ...rest }: BatchOptions): Promise<string[]>;
87
+
88
+ export { type BatchOptions, type ComposeOptions, type GridOptions, type PNGOptions, type SocialCropOptions, type StackOptions, type TableItem, batchToPNG, composePage, gridTables, htmlToPNG, slug, socialCrop, stackTables, tableToPNG };
package/dist/export.js ADDED
@@ -0,0 +1,310 @@
1
+ import { renderHTML } from './chunk-VLF2S6VW.js';
2
+ import './chunk-3TJR7TKS.js';
3
+ import { escapeHtml, escapeAttr } from './chunk-R375P3MN.js';
4
+ import './chunk-UTOVFIFT.js';
5
+ import { mkdir, writeFile } from 'fs/promises';
6
+ import { join } from 'path';
7
+ import { InputError, SdvplotError, warn, OptionalDependencyError } from '@sportsdataverse/sdvplot';
8
+ import { parseAspect, parseGravity, checkColor, peerMissing, canvasFor, offsetFor } from '@sportsdataverse/sdvplot/export';
9
+
10
+ var COMPOSE_CSS = ".sdvt-compose{display:inline-block;font-family:system-ui,-apple-system,sans-serif}.sdvt-compose-title{font-size:28px;font-weight:700;color:#111111;text-align:center;margin:0 0 4px}.sdvt-compose-title:last-child{margin-bottom:12px}.sdvt-compose-subtitle{font-size:16px;font-weight:400;color:#666666;text-align:center;margin:0 0 12px}.sdvt-compose-caption{font-size:12px;font-weight:400;color:#8A8A8A;text-align:center;margin:10px 0 0}.sdvt-compose-source{font-size:12px;font-weight:400;color:#8A8A8A;text-align:right;margin:6px 0 0}.sdvt-compose-rule{border-bottom:1px solid #8A8A8A;padding-bottom:6px}.sdvt-compose-label{font-size:12px;font-weight:600;color:#555555;text-align:left;margin:0 0 6px}";
11
+ function slug(value) {
12
+ return String(value).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
13
+ }
14
+ function itemHTML(item) {
15
+ return typeof item === "string" ? item : renderHTML(item.spec, item.rows);
16
+ }
17
+ function checkItems(items) {
18
+ if (items.length === 0) throw new InputError("at least one table is required");
19
+ }
20
+ function checkPixels(name, value) {
21
+ if (!(Number.isFinite(value) && value >= 0))
22
+ throw new InputError(`${name} must be a non-negative number of pixels, got ${String(value)}`);
23
+ }
24
+ function placeOf(align, places) {
25
+ const place = places[align];
26
+ if (place === void 0)
27
+ throw new InputError(
28
+ `align must be one of ${Object.keys(places).join(", ")}, got ${JSON.stringify(align)}`
29
+ );
30
+ return place;
31
+ }
32
+ var WRAPPER = /<div class="sdvt [^"]*" id="([A-Za-z][\w-]*)"/g;
33
+ function uniqueIds(body) {
34
+ const starts = [...body.matchAll(WRAPPER)];
35
+ const taken = new Set(starts.map((m) => m[1]));
36
+ if (taken.size === starts.length) return body;
37
+ const seen = /* @__PURE__ */ new Set();
38
+ let out = body.slice(0, starts[0]?.index);
39
+ starts.forEach((m, i) => {
40
+ const id = m[1];
41
+ let table = body.slice(m.index, starts[i + 1]?.index);
42
+ if (seen.has(id)) {
43
+ let n2 = 2;
44
+ while (taken.has(`${id}-${n2}`)) n2++;
45
+ const to = `${id}-${n2}`;
46
+ taken.add(to);
47
+ table = table.replace(new RegExp(`(\\s(?:id|for)=")${id}(?=["-])`, "g"), `$1${to}`).replace(
48
+ /<style>[\s\S]*?<\/style>/g,
49
+ (css) => (
50
+ // a selector only: the next brace is the rule's `{`, so a color equal to the id (`#fade;`) is left alone
51
+ css.replace(new RegExp(`#${id}(?![\\w-])(?=[^{}]*\\{)`, "g"), `#${to}`)
52
+ )
53
+ );
54
+ }
55
+ seen.add(id);
56
+ out += table;
57
+ });
58
+ return out;
59
+ }
60
+ function composePage(body, { title, subtitle, caption, sourceNote, captionRule = false } = {}) {
61
+ const head = title || subtitle ? `<header class="sdvt-compose-head">${title ? `<div class="sdvt-compose-title">${escapeHtml(title)}</div>` : ""}${subtitle ? `<p class="sdvt-compose-subtitle">${escapeHtml(subtitle)}</p>` : ""}</header>` : "";
62
+ const foot = caption || sourceNote ? `<footer class="sdvt-compose-foot">${caption ? `<p class="sdvt-compose-caption${captionRule ? " sdvt-compose-rule" : ""}">${escapeHtml(caption)}</p>` : ""}${sourceNote ? `<p class="sdvt-compose-source">${escapeHtml(sourceNote)}</p>` : ""}</footer>` : "";
63
+ return `<div class="sdvt-compose"><style>${COMPOSE_CSS}</style>${head}${uniqueIds(body)}${foot}</div>`;
64
+ }
65
+ function gridTables(items, { ncol = 2, gap = 24, align = "top", labels, ...compose } = {}) {
66
+ checkItems(items);
67
+ if (!Number.isInteger(ncol) || ncol < 1)
68
+ throw new InputError(`ncol must be an integer of at least 1, got ${String(ncol)}`);
69
+ const place = placeOf(align, { top: "start", center: "center", bottom: "end" });
70
+ checkPixels("gap", gap);
71
+ if (labels !== void 0 && labels.length === 0)
72
+ throw new InputError("labels must be non-empty; omit it for no labels");
73
+ const cells = items.map((item, i) => {
74
+ const label = labels === void 0 ? "" : `<div class="sdvt-compose-label">${escapeHtml(labels[i % labels.length] ?? "")}</div>`;
75
+ return `<div class="sdvt-compose-cell">${label}${itemHTML(item)}</div>`;
76
+ });
77
+ const grid = `<div class="sdvt-grid" style="display:grid;grid-template-columns:repeat(${ncol},max-content);gap:${gap}px;align-items:${place};justify-content:center">${cells.join("")}</div>`;
78
+ return composePage(grid, compose);
79
+ }
80
+ function stackTables(items, { gap = 16, align = "center", ...compose } = {}) {
81
+ checkItems(items);
82
+ const place = placeOf(align, { left: "flex-start", center: "center", right: "flex-end" });
83
+ checkPixels("gap", gap);
84
+ const stack = `<div class="sdvt-stack" style="display:flex;flex-direction:column;gap:${gap}px;align-items:${place}">${items.map(itemHTML).join("")}</div>`;
85
+ return composePage(stack, compose);
86
+ }
87
+
88
+ // src/export/index.ts
89
+ var VIEWPORT = 1200;
90
+ var EXPAND = 5;
91
+ var MAX_SIDE = 65535;
92
+ var MAX_AREA = 32768 * 8192;
93
+ var REMEDY = "put fewer rows in each image (rows.slice() a page at a time, batchToPNG by a group column, or gridTables to set tables side by side), or lower deviceScaleFactor";
94
+ var n = (v) => v.toLocaleString("en-US");
95
+ function checkCanvas(what, w, h, o) {
96
+ if (w <= MAX_SIDE && h <= MAX_SIDE && w * h <= MAX_AREA) return;
97
+ throw new SdvplotError(
98
+ `${what} ${n(w)} \xD7 ${n(h)} px at deviceScaleFactor ${o.deviceScaleFactor}, past Chromium's canvas limit of ${n(MAX_SIDE)} px a side and ${n(MAX_AREA)} px in all; ${REMEDY}`
99
+ );
100
+ }
101
+ var checkPng = (file) => {
102
+ if (!/\.png$/i.test(file)) throw new InputError(`file must end in .png, got ${JSON.stringify(file)}`);
103
+ };
104
+ function resolve({
105
+ width,
106
+ deviceScaleFactor = 2,
107
+ background = "white",
108
+ whitespace = 50,
109
+ fontLinks = [],
110
+ file
111
+ }) {
112
+ if (width !== void 0 && !(Number.isInteger(width) && width > 0))
113
+ throw new InputError(`width must be a positive integer of pixels, got ${String(width)}`);
114
+ if (!(Number.isFinite(deviceScaleFactor) && deviceScaleFactor > 0))
115
+ throw new InputError(`deviceScaleFactor must be a positive number, got ${String(deviceScaleFactor)}`);
116
+ if (!(Number.isFinite(whitespace) && whitespace >= 0))
117
+ throw new InputError(`whitespace must be a non-negative number of pixels, got ${String(whitespace)}`);
118
+ checkColor("background", background);
119
+ if (file !== void 0) checkPng(file);
120
+ return { width, deviceScaleFactor, background, whitespace: Math.trunc(whitespace), fontLinks, file };
121
+ }
122
+ async function launch() {
123
+ const pw = await import('playwright').catch((e) => {
124
+ throw new OptionalDependencyError(
125
+ peerMissing(e, "playwright") ? "sdvtables/export needs the optional peer playwright, which is not installed: pnpm add -D playwright && pnpm exec playwright install chromium" : `sdvtables/export could not load the optional peer playwright (installed, but it failed to load): ${String(e)}`,
126
+ { cause: e }
127
+ );
128
+ });
129
+ try {
130
+ return await pw.chromium.launch();
131
+ } catch (e) {
132
+ if (e instanceof Error && /Executable doesn't exist/.test(e.message))
133
+ throw new OptionalDependencyError(
134
+ "sdvtables/export found playwright but not its chromium browser: pnpm exec playwright install chromium",
135
+ { cause: e }
136
+ );
137
+ throw e;
138
+ }
139
+ }
140
+ function page(body, o) {
141
+ const links = o.fontLinks.map((href) => `<link rel="stylesheet" href="${escapeAttr(href)}">`).join("");
142
+ return `<!doctype html><html><head><meta charset="utf-8">${links}<style>html,body{margin:0;background:${o.background}}#sdv-root{display:inline-block;padding:${EXPAND}px;background:${o.background}}</style></head><body><div id="sdv-root">${body}</div></body></html>`;
143
+ }
144
+ async function trimBox(png) {
145
+ const img = new Image();
146
+ img.src = `data:image/png;base64,${png}`;
147
+ await img.decode();
148
+ const c = document.createElement("canvas");
149
+ c.width = img.width;
150
+ c.height = img.height;
151
+ const g = c.getContext("2d");
152
+ g.drawImage(img, 0, 0);
153
+ const px = new Uint32Array(g.getImageData(0, 0, c.width, c.height).data.buffer);
154
+ const bg = px[0];
155
+ let [x0, y0, x1, y1] = [c.width, c.height, -1, -1];
156
+ for (let y = 0, i = 0; y < c.height; y++)
157
+ for (let x = 0; x < c.width; x++, i++)
158
+ if (px[i] !== bg) {
159
+ if (x < x0) x0 = x;
160
+ if (x > x1) x1 = x;
161
+ if (y < y0) y0 = y;
162
+ y1 = y;
163
+ }
164
+ return x1 < 0 ? [0, 0, c.width, c.height] : [x0, y0, x1 - x0 + 1, y1 - y0 + 1];
165
+ }
166
+ async function drawPNG(a) {
167
+ const img = new Image();
168
+ img.src = `data:image/png;base64,${a.png}`;
169
+ await img.decode();
170
+ const c = document.createElement("canvas");
171
+ c.width = a.w;
172
+ c.height = a.h;
173
+ const g = c.getContext("2d");
174
+ g.fillStyle = a.bg;
175
+ g.fillRect(0, 0, a.w, a.h);
176
+ const [x, y, tw, th] = a.trim;
177
+ g.drawImage(img, x, y, tw, th, a.dx, a.dy, tw, th);
178
+ let out = c;
179
+ if (a.fw !== a.w || a.fh !== a.h) {
180
+ out = document.createElement("canvas");
181
+ out.width = a.fw;
182
+ out.height = a.fh;
183
+ const s = out.getContext("2d");
184
+ s.imageSmoothingQuality = "high";
185
+ s.drawImage(c, 0, 0, a.fw, a.fh);
186
+ }
187
+ return out.toDataURL("image/png").slice("data:image/png;base64,".length);
188
+ }
189
+ async function snap(p, html, o) {
190
+ await p.setViewportSize({ width: VIEWPORT, height: 800 });
191
+ await p.setContent(page(html, o), { waitUntil: "load" });
192
+ await p.evaluate(() => document.fonts.ready);
193
+ const root = p.locator("#sdv-root");
194
+ const box = await root.boundingBox();
195
+ if (box) {
196
+ const z = o.deviceScaleFactor;
197
+ checkCanvas("the table renders", Math.ceil(box.width * z), Math.ceil(box.height * z), o);
198
+ }
199
+ if (box && box.width > VIEWPORT) await p.setViewportSize({ width: Math.ceil(box.width), height: 800 });
200
+ const png = Buffer.from(await root.screenshot({ type: "png" })).toString("base64");
201
+ return { png, trim: await p.evaluate(trimBox, png) };
202
+ }
203
+ async function finish(p, shot, o, { minWidth = 0, ratio, gravity = "center" } = {}) {
204
+ const [, , tw, th] = shot.trim;
205
+ const ws = o.whitespace;
206
+ let [w, h] = [Math.max(tw, minWidth) + 2 * ws, th + 2 * ws];
207
+ let [dx, dy] = [Math.floor((w - 2 * ws - tw) / 2) + ws, ws];
208
+ if (ratio !== void 0) {
209
+ const [cw, ch] = canvasFor(w, h, ratio);
210
+ const [ox, oy] = offsetFor(cw - w, ch - h, gravity);
211
+ [w, h, dx, dy] = [cw, ch, dx + ox, dy + oy];
212
+ }
213
+ const [fw, fh] = o.width === void 0 ? [w, h] : [o.width, Math.max(1, Math.floor(h * o.width / w + 0.5))];
214
+ checkCanvas("the image would be", w, h, o);
215
+ checkCanvas("the image scaled to `width` would be", fw, fh, o);
216
+ const b64 = await p.evaluate(drawPNG, {
217
+ png: shot.png,
218
+ trim: shot.trim,
219
+ w,
220
+ h,
221
+ dx,
222
+ dy,
223
+ fw,
224
+ fh,
225
+ bg: o.background
226
+ });
227
+ if (b64 === "")
228
+ throw new SdvplotError(`Chromium returned an empty image for ${n(fw)} \xD7 ${n(fh)} px; ${REMEDY}`);
229
+ const png = new Uint8Array(Buffer.from(b64, "base64"));
230
+ if (o.file !== void 0) await writeFile(o.file, png);
231
+ return png;
232
+ }
233
+ async function withPage(o, fn) {
234
+ const browser = await launch();
235
+ try {
236
+ return await fn(await browser.newPage({ deviceScaleFactor: o.deviceScaleFactor }));
237
+ } finally {
238
+ await browser.close();
239
+ }
240
+ }
241
+ async function htmlToPNG(html, options = {}) {
242
+ const o = resolve(options);
243
+ return withPage(o, async (p) => finish(p, await snap(p, html, o), o));
244
+ }
245
+ function tableToPNG(spec, rows, options = {}) {
246
+ return htmlToPNG(renderHTML(spec, rows), options);
247
+ }
248
+ async function socialCrop(spec, rows, { aspect = "1:1", gravity = "center", ...rest } = {}) {
249
+ const ratio = parseAspect(aspect);
250
+ const place = parseGravity(gravity);
251
+ const o = resolve({ whitespace: 60, ...rest });
252
+ const html = renderHTML(spec, rows);
253
+ return withPage(o, async (p) => finish(p, await snap(p, html, o), o, { ratio, gravity: place }));
254
+ }
255
+ async function batchToPNG(rows, group, build, filePattern, { dir, matchWidth = true, ...rest }) {
256
+ if (!filePattern.includes("{group}"))
257
+ throw new InputError(`filePattern must contain "{group}", got ${filePattern}`);
258
+ checkPng(filePattern);
259
+ const o = resolve(rest);
260
+ const groups = /* @__PURE__ */ new Map();
261
+ for (const r of rows) {
262
+ const v = r[group];
263
+ if (v === null || v === void 0) continue;
264
+ const bucket = groups.get(v);
265
+ if (bucket) bucket.push(r);
266
+ else groups.set(v, [r]);
267
+ }
268
+ if (groups.size === 0) throw new InputError(`no non-missing values in column ${String(group)}`);
269
+ const names = [...groups.keys()].map((v) => filePattern.replace("{group}", slug(v)));
270
+ const shared = [...new Set(names.filter((n2, i) => names.indexOf(n2) !== i))].sort();
271
+ if (shared.length > 0)
272
+ throw new InputError(
273
+ `group values write to the same file name: ${shared.join(", ")}; rename the values first`
274
+ );
275
+ const items = [];
276
+ const failed = [];
277
+ [...groups].forEach(([v, rs], i) => {
278
+ try {
279
+ const built = build(rs, v);
280
+ items.push({
281
+ file: join(dir, names[i]),
282
+ html: typeof built === "string" ? built : renderHTML(built.spec, built.rows)
283
+ });
284
+ } catch (e) {
285
+ failed.push(`${String(v)}: ${e instanceof Error ? e.message : String(e)}`);
286
+ }
287
+ });
288
+ if (items.length === 0) throw new SdvplotError(`No group built successfully:
289
+ ${failed.join("\n")}`);
290
+ await mkdir(dir, { recursive: true });
291
+ const out = await withPage(o, async (p) => {
292
+ const shots = [];
293
+ for (const it of items) shots.push(await snap(p, it.html, o));
294
+ const minWidth = matchWidth ? Math.max(...shots.map((s) => s.trim[2])) : 0;
295
+ for (const [i, it] of items.entries())
296
+ await finish(p, shots[i], { ...o, file: it.file }, { minWidth });
297
+ return items.map((it) => it.file);
298
+ });
299
+ if (failed.length > 0)
300
+ warn(
301
+ `sdvtables:batch:${filePattern}:${failed.join("|")}`,
302
+ `${failed.length} group(s) failed and were skipped:
303
+ ${failed.join("\n")}`
304
+ );
305
+ return out;
306
+ }
307
+
308
+ export { batchToPNG, composePage, gridTables, htmlToPNG, slug, socialCrop, stackTables, tableToPNG };
309
+ //# sourceMappingURL=export.js.map
310
+ //# sourceMappingURL=export.js.map