@opencorestack/opengridx 3.9.1 → 3.10.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.d.ts +55 -0
  3. package/dist/opengridx.es.js +6346 -6279
  4. package/dist/opengridx.es.js.map +1 -1
  5. package/dist/opengridx.umd.js +16 -16
  6. package/dist/opengridx.umd.js.map +1 -1
  7. package/docs/API_REFERENCE.md +4 -0
  8. package/docs/features/ai-toolkit.md +1 -0
  9. package/docs/performance.md +1 -1
  10. package/docs/roadmap.md +5 -5
  11. package/lib/components/Cell/Cell.tsx +2 -1
  12. package/lib/components/DataGrid/DataGrid.tsx +19 -18
  13. package/lib/components/DataGrid/GridListView.tsx +1 -1
  14. package/lib/components/DataGrid/GridPinnedRows.tsx +1 -1
  15. package/lib/components/DataGrid/GridViewport.tsx +2 -1
  16. package/lib/components/DataGrid/GridVirtualRows.tsx +1 -1
  17. package/lib/components/Header/Header.tsx +4 -3
  18. package/lib/components/Row/Row.tsx +6 -2
  19. package/lib/components/Toolbar/AggregationPanel.tsx +90 -0
  20. package/lib/components/Toolbar/ColumnsPanelWrapper.tsx +56 -0
  21. package/lib/components/Toolbar/FilterPanelWrapper.tsx +82 -0
  22. package/lib/components/Toolbar/GridToolbar.tsx +7 -380
  23. package/lib/components/Toolbar/ToolbarIcons.tsx +35 -0
  24. package/lib/components/Toolbar/useToolbarPanel.ts +71 -0
  25. package/lib/hooks/core/gridKeyDown.ts +272 -0
  26. package/lib/hooks/core/useDataGrid.ts +13 -5
  27. package/lib/hooks/core/useGridApiMethods.ts +2 -2
  28. package/lib/hooks/core/useGridCellRangeDrag.ts +345 -0
  29. package/lib/hooks/core/useGridCellSelection.ts +17 -297
  30. package/lib/hooks/core/useGridColumnLookup.ts +2 -1
  31. package/lib/hooks/core/useGridColumns.tsx +3 -2
  32. package/lib/hooks/core/useGridControlledState.ts +3 -2
  33. package/lib/hooks/core/useGridHierarchy.ts +1 -1
  34. package/lib/hooks/core/useGridKeyboardNavigation.ts +51 -235
  35. package/lib/hooks/core/useGridLiveRowSelection.ts +2 -2
  36. package/lib/hooks/core/useGridRowInteractions.ts +1 -1
  37. package/lib/hooks/core/useGridRowSelection.ts +11 -2
  38. package/lib/hooks/core/useGridToolbarProps.ts +3 -2
  39. package/lib/hooks/features/useGridAiAssistant.ts +3 -2
  40. package/lib/hooks/useColumnReorder.ts +2 -1
  41. package/lib/hooks/useTreeData.ts +4 -2
  42. package/lib/types/locale.ts +22 -0
  43. package/lib/utils/aggregation/index.ts +16 -0
  44. package/lib/utils/aiAssistant.ts +46 -15
  45. package/lib/utils/export/excelCells.ts +244 -0
  46. package/lib/utils/export/exportCsv.ts +179 -0
  47. package/lib/utils/export/exportExcelHtml.ts +220 -0
  48. package/lib/utils/export/exportJson.ts +179 -0
  49. package/lib/utils/export/exportRows.ts +85 -0
  50. package/lib/utils/export/exportToExcelAdvanced.ts +22 -242
  51. package/lib/utils/export/index.ts +5 -695
  52. package/lib/utils/export/printGrid.ts +282 -0
  53. package/lib/utils/gridLocale.ts +30 -0
  54. package/lib/utils/selectionSets.ts +36 -0
  55. package/lib/utils/untypedColumns.ts +16 -0
  56. package/llms.txt +3 -1
  57. package/package.json +4 -2
@@ -0,0 +1,179 @@
1
+
2
+ import type {
3
+ GridColDef,
4
+ GridRowId,
5
+ GridRowModel,
6
+ GridValidRowModel,
7
+ GridAggregationModel,
8
+ GridGroupedExportRow,
9
+ } from '../../types';
10
+ import { groupHeaderLabel } from './groupLabel';
11
+ import {
12
+ aggregationForExport,
13
+ getExportColumns,
14
+ getRawExportValue,
15
+ hasSelection,
16
+ pickExportedAggregates,
17
+ rowsForExport,
18
+ shouldExportGrouped,
19
+ } from './exportShared';
20
+
21
+ export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
22
+ fileName?: string;
23
+ includeHeaders?: boolean;
24
+ delimiter?: string;
25
+ /**
26
+ * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
27
+ * selected rows are exported flat), and the totals row is recomputed over the selected rows.
28
+ */
29
+ selectedRows?: (string | number)[];
30
+ /**
31
+ * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
32
+ * copy them onto `row.id` (v3.0+). Defaults to `row.id`.
33
+ */
34
+ getRowId?: (row: R) => GridRowId;
35
+ aggregationResult?: Record<string, unknown> | null;
36
+ aggregationModel?: GridAggregationModel | null;
37
+ /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
38
+ groupedRows?: GridGroupedExportRow[];
39
+ /**
40
+ * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
41
+ * spreadsheet apps show them as text instead of running them as formulas (CSV injection).
42
+ * Plain signed numbers are left alone. Default: `true`
43
+ * @since v3.0
44
+ */
45
+ escapeFormulas?: boolean;
46
+ /**
47
+ * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
48
+ * correctly. Default: `true`
49
+ * @since v3.0
50
+ */
51
+ bom?: boolean;
52
+ }
53
+ import { downloadFile } from './exportRows';
54
+
55
+ export interface JsonExportOptions<R extends GridValidRowModel = GridRowModel> {
56
+ fileName?: string;
57
+ pretty?: boolean;
58
+ /**
59
+ * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
60
+ * selected rows are exported flat), and the aggregation values are recomputed over them.
61
+ */
62
+ selectedRows?: (string | number)[];
63
+ /**
64
+ * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
65
+ * copy them onto `row.id` (v3.0+). Defaults to `row.id`.
66
+ */
67
+ getRowId?: (row: R) => GridRowId;
68
+ aggregationResult?: Record<string, unknown> | null;
69
+ aggregationModel?: GridAggregationModel | null;
70
+ /** When provided, emits a nested grouping tree instead of a flat array. */
71
+ groupedRows?: GridGroupedExportRow[];
72
+ }
73
+
74
+ /**
75
+ * Export data to JSON format. Values are written raw (after `valueGetter`, without
76
+ * `valueFormatter`), including aggregation values, so the output can be parsed as data.
77
+ */
78
+ export function exportToJson<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef<R>[], options?: JsonExportOptions<R>): void;
79
+ export function exportToJson<R extends GridValidRowModel = GridRowModel>(rows: R[], columns: GridColDef[], options?: JsonExportOptions<R>): void;
80
+ export function exportToJson<R extends GridRowModel>(
81
+ rows: R[],
82
+ columns: GridColDef<R>[],
83
+ options: JsonExportOptions<R> = {}
84
+ ): void {
85
+ const {
86
+ fileName = 'export.json',
87
+ pretty = true,
88
+ selectedRows,
89
+ groupedRows,
90
+ } = options;
91
+
92
+ const exportColumns = getExportColumns(columns);
93
+
94
+ type GroupNode = {
95
+ group: string;
96
+ field: string;
97
+ value: unknown;
98
+ rows: Record<string, unknown>[];
99
+ subtotals?: Record<string, unknown>;
100
+ children?: GroupNode[];
101
+ };
102
+
103
+ type JsonPayload =
104
+ | Record<string, unknown>[]
105
+ | { data: Record<string, unknown>[]; aggregation: { labels: Record<string, unknown>; values: Record<string, unknown> } }
106
+ | { groups: GroupNode[]; grandTotal?: Record<string, unknown> };
107
+
108
+ const rowObject = (row: R): Record<string, unknown> => {
109
+ const obj: Record<string, unknown> = {};
110
+ exportColumns.forEach(col => { obj[col.field] = getRawExportValue(row, col); });
111
+ return obj;
112
+ };
113
+
114
+ let outData: JsonPayload;
115
+
116
+ if (shouldExportGrouped(groupedRows, selectedRows)) {
117
+ // Build nested tree from the flat ordered list
118
+ const stack: GroupNode[] = [];
119
+ const roots: GroupNode[] = [];
120
+ let grandTotal: Record<string, unknown> | undefined;
121
+
122
+ groupedRows.forEach(entry => {
123
+ if (entry.type === 'group-header') {
124
+ const node: GroupNode = {
125
+ group: groupHeaderLabel(entry, columns),
126
+ field: entry.groupField ?? '',
127
+ value: entry.groupValue,
128
+ rows: [],
129
+ children: [],
130
+ };
131
+ while (stack.length > entry.depth) stack.pop();
132
+ if (stack.length === 0) {
133
+ roots.push(node);
134
+ } else {
135
+ (stack[stack.length - 1].children ??= []).push(node);
136
+ }
137
+ stack.push(node);
138
+ } else if (entry.type === 'leaf' && entry.row) {
139
+ if (stack.length > 0) stack[stack.length - 1].rows.push(rowObject(entry.row as R));
140
+ } else if (entry.type === 'group-subtotal' && entry.aggregatedValues && stack.length > 0) {
141
+ stack[stack.length - 1].subtotals = pickExportedAggregates(entry.aggregatedValues, exportColumns);
142
+ stack.pop();
143
+ } else if (entry.type === 'grand-total' && entry.aggregatedValues) {
144
+ grandTotal = pickExportedAggregates(entry.aggregatedValues, exportColumns);
145
+ }
146
+ });
147
+
148
+ outData = grandTotal ? { groups: roots, grandTotal } : { groups: roots };
149
+ } else {
150
+ const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId);
151
+
152
+ if (rowsToExport.length === 0) {
153
+ console.warn('No rows to export');
154
+ return;
155
+ }
156
+
157
+ const data = rowsToExport.map(rowObject);
158
+
159
+ const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel);
160
+ if (aggResult) {
161
+ const aggModel = options.aggregationModel || {};
162
+ const summaryLabelObj: Record<string, unknown> = {};
163
+ exportColumns.forEach(col => { if (aggModel[col.field]) summaryLabelObj[col.field] = aggModel[col.field].toUpperCase(); });
164
+ const summaryDataObj: Record<string, unknown> = {};
165
+ exportColumns.forEach(col => {
166
+ if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) summaryDataObj[col.field] = aggResult[col.field];
167
+ });
168
+ outData = { data, aggregation: { labels: summaryLabelObj, values: summaryDataObj } };
169
+ } else {
170
+ outData = data;
171
+ }
172
+ }
173
+
174
+ downloadFile(pretty ? JSON.stringify(outData, null, 2) : JSON.stringify(outData), fileName, 'application/json;charset=utf-8;');
175
+ }
176
+
177
+ // ============================================================================
178
+ // Print
179
+ // ============================================================================
@@ -0,0 +1,85 @@
1
+ // Shared by the CSV, HTML-Excel, JSON and print exporters: one data cell's text, the aggregate texts
2
+ // of a total row, and the browser download.
3
+
4
+ import type {
5
+ GridColDef,
6
+ GridRowId,
7
+ GridRowModel,
8
+ GridValidRowModel,
9
+ GridAggregationModel,
10
+ GridGroupedExportRow,
11
+ } from '../../types';
12
+ import {
13
+ formatExportAggregate,
14
+ formatExportValue,
15
+ getRawExportValue,
16
+ isTrustedNonText,
17
+ } from './exportShared';
18
+
19
+ export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
20
+ fileName?: string;
21
+ includeHeaders?: boolean;
22
+ delimiter?: string;
23
+ /**
24
+ * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the
25
+ * selected rows are exported flat), and the totals row is recomputed over the selected rows.
26
+ */
27
+ selectedRows?: (string | number)[];
28
+ /**
29
+ * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not
30
+ * copy them onto `row.id` (v3.0+). Defaults to `row.id`.
31
+ */
32
+ getRowId?: (row: R) => GridRowId;
33
+ aggregationResult?: Record<string, unknown> | null;
34
+ aggregationModel?: GridAggregationModel | null;
35
+ /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */
36
+ groupedRows?: GridGroupedExportRow[];
37
+ /**
38
+ * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so
39
+ * spreadsheet apps show them as text instead of running them as formulas (CSV injection).
40
+ * Plain signed numbers are left alone. Default: `true`
41
+ * @since v3.0
42
+ */
43
+ escapeFormulas?: boolean;
44
+ /**
45
+ * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文)
46
+ * correctly. Default: `true`
47
+ * @since v3.0
48
+ */
49
+ bom?: boolean;
50
+ }
51
+
52
+ /** One exported cell of a data row: its text, and whether that text was formatted from a non-text value. */
53
+ export interface ExportCell {
54
+ text: string;
55
+ raw: unknown;
56
+ /** The text came from the grid's own formatting of a number, date or boolean (see `isTrustedNonText`). */
57
+ fromNonText: boolean;
58
+ }
59
+
60
+ export function exportCell<R extends GridRowModel>(row: R, col: GridColDef<R>): ExportCell {
61
+ const raw = getRawExportValue(row, col);
62
+ const text = formatExportValue(row, col, raw);
63
+ return { text, raw, fromNonText: isTrustedNonText(raw, text, !!col.valueFormatter) };
64
+ }
65
+
66
+ /** Aggregate text per export column for one subtotal / total record. */
67
+ export function aggregateTexts<R extends GridRowModel>(
68
+ exportColumns: GridColDef<R>[],
69
+ aggregates: Record<string, unknown>,
70
+ aggModel: GridAggregationModel,
71
+ ): string[] {
72
+ return exportColumns.map(col => formatExportAggregate(col, aggModel[col.field], aggregates[col.field], aggregates));
73
+ }
74
+
75
+ export function downloadFile(content: string, fileName: string, mimeType: string): void {
76
+ const blob = new Blob([content], { type: mimeType });
77
+ const url = URL.createObjectURL(blob);
78
+ const link = document.createElement('a');
79
+ link.href = url;
80
+ link.download = fileName;
81
+ document.body.appendChild(link);
82
+ link.click();
83
+ document.body.removeChild(link);
84
+ URL.revokeObjectURL(url);
85
+ }
@@ -1,18 +1,37 @@
1
1
 
2
- import type { GridColDef, GridRowId, GridRowModel, GridValidRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types';
2
+ import type {
3
+ GridColDef,
4
+ GridRowId,
5
+ GridRowModel,
6
+ GridValidRowModel,
7
+ GridAggregationModel,
8
+ GridGroupedExportRow,
9
+ } from '../../types';
3
10
  import { groupHeaderLabel } from './groupLabel';
4
11
  import {
5
12
  aggregationForExport,
6
13
  formatExportAggregate,
7
- formatExportValue,
8
14
  getExportColumns,
9
- getRawExportValue,
10
15
  hasSelection,
11
16
  isCountAggregation,
12
17
  normalizeAggregateValue,
13
18
  pickSelectedRows,
14
19
  sanitizeSheetName,
15
20
  } from './exportShared';
21
+ import {
22
+ AggregateCell,
23
+ toExcelDate,
24
+ dataCellValue,
25
+ INTEGER_FORMAT,
26
+ columnNumFmt,
27
+ cellNumFmt,
28
+ colWidth,
29
+ imageUrlOf,
30
+ fetchImages,
31
+ argb,
32
+ bottomBorder,
33
+ topBorder,
34
+ } from './excelCells';
16
35
 
17
36
  // ─── Types ────────────────────────────────────────────────────────────────────
18
37
 
@@ -105,225 +124,6 @@ export interface ExcelAdvancedExportOptions<R extends GridValidRowModel = GridRo
105
124
 
106
125
  // ─── Cell values ──────────────────────────────────────────────────────────────
107
126
 
108
- /** The only values handed to ExcelJS. Objects are never passed through, because ExcelJS reads
109
- * `{ formula }`, `{ hyperlink }`, `{ richText }` and `{ error }` objects as live cell content. */
110
- type ExcelCellValue = string | number | boolean | Date | null;
111
-
112
- /** A subtotal / total cell value, with the number format it needs when it differs from the column's. */
113
- interface AggregateCell {
114
- value: ExcelCellValue;
115
- numFmt?: string;
116
- }
117
-
118
- type CellKind = 'number' | 'boolean' | 'date' | 'string';
119
-
120
- function cellKind(colDef: GridColDef<GridRowModel>, value: unknown): CellKind {
121
- if (colDef.type === 'number') return 'number';
122
- if (colDef.type === 'boolean') return 'boolean';
123
- if (colDef.type === 'date') return 'date';
124
- if (typeof value === 'number') return 'number';
125
- if (typeof value === 'boolean') return 'boolean';
126
- if (value instanceof Date) return 'date';
127
- return 'string';
128
- }
129
-
130
- /**
131
- * Excel date serials carry no timezone and are read as wall-clock time, while ExcelJS converts a
132
- * Date from its UTC instant. Re-anchor the local wall-clock fields at UTC so the cell shows the
133
- * date and time the grid shows. Invalid dates become null.
134
- */
135
- function toExcelDate(date: Date): Date | null {
136
- if (Number.isNaN(date.getTime())) return null;
137
- return new Date(Date.UTC(
138
- date.getFullYear(), date.getMonth(), date.getDate(),
139
- date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds(),
140
- ));
141
- }
142
-
143
- const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
144
- const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i;
145
- const NUMERIC_STRING = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i;
146
-
147
- /** A date-column value as a Date: null when missing or invalid, undefined when not a date at all. */
148
- function dateCellValue(value: unknown): Date | null | undefined {
149
- if (value instanceof Date) return toExcelDate(value);
150
- if (typeof value === 'number') return Number.isFinite(value) ? toExcelDate(new Date(value)) : null;
151
- if (typeof value !== 'string') return undefined;
152
- const text = value.trim();
153
- const dateOnly = ISO_DATE_ONLY.exec(text);
154
- if (dateOnly) {
155
- // A calendar date: already wall-clock, so build it at UTC directly.
156
- const [year, month, day] = [Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3])];
157
- const date = new Date(Date.UTC(year, month - 1, day));
158
- return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined;
159
- }
160
- if (ISO_DATE_TIME.test(text)) {
161
- const date = new Date(text.replace(' ', 'T'));
162
- return Number.isNaN(date.getTime()) ? undefined : toExcelDate(date);
163
- }
164
- return undefined;
165
- }
166
-
167
- /**
168
- * A native value for a number / boolean / date cell: null for an empty cell (missing, NaN,
169
- * Infinity, Invalid Date), undefined when the value is not of that kind (e.g. 'n/a' in a
170
- * number column).
171
- */
172
- function typedCellValue(kind: Exclude<CellKind, 'string'>, value: unknown): number | boolean | Date | null | undefined {
173
- if (value == null || value === '') return null;
174
- if (kind === 'number') {
175
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
176
- if (typeof value === 'bigint') {
177
- const n = Number(value);
178
- return Number.isSafeInteger(n) ? n : undefined;
179
- }
180
- if (typeof value === 'string' && NUMERIC_STRING.test(value.trim())) {
181
- const n = Number(value.trim());
182
- return Number.isFinite(n) ? n : undefined;
183
- }
184
- return undefined;
185
- }
186
- if (kind === 'boolean') {
187
- if (typeof value === 'boolean') return value;
188
- if (typeof value === 'string') {
189
- const text = value.trim().toLowerCase();
190
- if (text === 'true') return true;
191
- if (text === 'false') return false;
192
- }
193
- return undefined;
194
- }
195
- return dateCellValue(value);
196
- }
197
-
198
- /** Any value as inert cell content: primitives and Dates as themselves, objects as JSON text. */
199
- function inertCellValue(value: unknown): ExcelCellValue {
200
- if (value == null) return null;
201
- if (typeof value === 'string' || typeof value === 'boolean') return value;
202
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
203
- if (value instanceof Date) return toExcelDate(value);
204
- if (typeof value === 'object') {
205
- try {
206
- return JSON.stringify(value) ?? String(value);
207
- } catch {
208
- return String(value);
209
- }
210
- }
211
- return String(value);
212
- }
213
-
214
- /**
215
- * The value of a data cell. Number, boolean and date cells are native (their `valueFormatter` is
216
- * not used, so Excel's number format applies); a value that is missing or not of the column's
217
- * kind falls back to the text the grid shows. Other cells get the `valueFormatter` text.
218
- */
219
- function dataCellValue(row: GridRowModel, col: GridColDef<GridRowModel>): ExcelCellValue {
220
- const raw = getRawExportValue(row, col);
221
- const kind = cellKind(col, raw);
222
- if (kind !== 'string') {
223
- const typed = typedCellValue(kind, raw);
224
- if (typed != null) return typed;
225
- if (!col.valueFormatter) return typed === null ? null : inertCellValue(raw);
226
- }
227
- return col.valueFormatter ? formatExportValue(row, col, raw) : inertCellValue(raw);
228
- }
229
-
230
- // ─── Number formats ──────────────────────────────────────────────────────────
231
-
232
- const INTEGER_FORMAT = '#,##0';
233
- const DECIMAL_FORMAT = '#,##0.##';
234
-
235
- /**
236
- * Default format for a number-column value. '#,##0.##' alone shows whole numbers with a trailing
237
- * decimal point in Excel ("5."), so whole numbers get '#,##0'.
238
- */
239
- function defaultNumberFormat(value: number): string {
240
- return Number.isInteger(value) ? INTEGER_FORMAT : DECIMAL_FORMAT;
241
- }
242
-
243
- function columnNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined): string | undefined {
244
- if (style?.numFmt) return style.numFmt;
245
- if (colDef.type === 'date') return 'yyyy-mm-dd';
246
- return undefined;
247
- }
248
-
249
- /** The number format for a native cell value, or undefined to keep the column's format. */
250
- function cellNumFmt(colDef: GridColDef<GridRowModel>, style: ExcelColumnStyle | undefined, value: ExcelCellValue): string | undefined {
251
- if (style?.numFmt || typeof value !== 'number' || colDef.type !== 'number') return undefined;
252
- return defaultNumberFormat(value);
253
- }
254
-
255
- // ─── Column display width (chars) ────────────────────────────────────────────
256
-
257
- function colWidth(colDef: GridColDef<GridRowModel>, override?: number): number {
258
- if (override != null) return override;
259
- const px = (colDef.width ?? 120) as number;
260
- return Math.min(60, Math.max(8, (px - 5) / 7));
261
- }
262
-
263
- // ─── Image helpers ────────────────────────────────────────────────────────────
264
-
265
- type SupportedImageExt = 'png' | 'jpeg' | 'gif';
266
-
267
- /**
268
- * The image format from the file's magic bytes. Excel can only show PNG, JPEG and GIF here; SVG,
269
- * WebP, AVIF and anything else return null so the URL text is written instead.
270
- */
271
- function detectImageExtension(bytes: Uint8Array): SupportedImageExt | null {
272
- if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png';
273
- if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg';
274
- if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'gif';
275
- return null;
276
- }
277
-
278
- /** The image URL of an embedImage cell: the cell value after `valueGetter`. */
279
- function imageUrlOf(row: GridRowModel, col: GridColDef<GridRowModel>): string | null {
280
- const value = getRawExportValue(row, col);
281
- return typeof value === 'string' && value ? value : null;
282
- }
283
-
284
- /**
285
- * Fetch a list of image URLs in parallel.
286
- * CORS failures and unsupported formats are logged and skipped — raw URL text is written to the
287
- * cell instead so data is never silently lost.
288
- */
289
- async function fetchImages(
290
- urls: string[]
291
- ): Promise<Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>> {
292
- const settled = await Promise.allSettled(
293
- urls.map(async (url) => {
294
- let bytes: Uint8Array;
295
- if (url.startsWith('data:')) {
296
- // ── data: URI — decode base64 directly, no network needed ──────────
297
- const b64 = url.slice(url.indexOf(',') + 1);
298
- const binary = atob(b64);
299
- bytes = new Uint8Array(binary.length);
300
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
301
- } else {
302
- // ── Remote URL — fetch with CORS ──────────────────────────────────
303
- const res = await fetch(url, { mode: 'cors' });
304
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
305
- bytes = new Uint8Array(await res.arrayBuffer());
306
- }
307
- const ext = detectImageExtension(bytes);
308
- if (!ext) throw new Error('unsupported image format (only PNG, JPEG and GIF can be embedded)');
309
- return { url, buffer: bytes.slice().buffer, ext };
310
- })
311
- );
312
- const map = new Map<string, { buffer: ArrayBuffer; extension: SupportedImageExt }>();
313
- settled.forEach((result, i) => {
314
- if (result.status === 'fulfilled') {
315
- map.set(result.value.url, { buffer: result.value.buffer, extension: result.value.ext });
316
- } else {
317
- console.warn(
318
- `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` +
319
- `(CORS/network error or unsupported format — raw URL written as fallback):`,
320
- result.reason
321
- );
322
- }
323
- });
324
- return map;
325
- }
326
-
327
127
  // ─── Main export function ─────────────────────────────────────────────────────
328
128
 
329
129
  /**
@@ -724,23 +524,3 @@ export async function exportToExcelAdvanced<R extends GridRowModel>(
724
524
  }
725
525
 
726
526
  // ─── Helpers ──────────────────────────────────────────────────────────────────
727
-
728
- /** Convert '#rrggbb' or '#rgb' to ExcelJS ARGB 'FFrrggbb' */
729
- function argb(hex: string): string {
730
- const clean = hex.replace('#', '');
731
- if (clean.length === 3) {
732
- return 'FF' + clean.split('').map(c => c + c).join('');
733
- }
734
- if (clean.length === 6) {
735
- return 'FF' + clean;
736
- }
737
- return 'FF' + clean.padEnd(6, '0');
738
- }
739
-
740
- function bottomBorder(): Partial<import('exceljs').Borders> {
741
- return { bottom: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
742
- }
743
-
744
- function topBorder(): Partial<import('exceljs').Borders> {
745
- return { top: { style: 'medium', color: { argb: 'FFcbd5e1' } } };
746
- }