@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
package/src/export.ts CHANGED
@@ -1,549 +1,1452 @@
1
- /// <reference path="./pdfmake-shims.d.ts" />
2
- import type { RowData, TableFeatures } from '@svgrid/grid'
3
- import type { SvGridApi } from '@svgrid/grid'
4
- import { assertEnterpriseLicensed } from './license'
5
- import { installSmartShim, type SmartDataExporterInstance } from './smart-shim'
6
-
7
- export type ExportFormat = 'xlsx' | 'pdf' | 'csv' | 'tsv' | 'html'
8
-
9
- export type ExportColumn = {
10
- /** Data field name on the row object. */
11
- field: string
12
- /** Header label to render in the exported file. Defaults to `field`. */
13
- header?: string
14
- }
15
-
16
- /**
17
- * Per-cell style descriptor. Mirrors a subset of CSS; the keys the
18
- * underlying exporter honours are font / colour / background / border /
19
- * alignment. Anything else is ignored gracefully.
20
- */
21
- export type ExportCellStyle = {
22
- color?: string
23
- backgroundColor?: string
24
- fontWeight?: 'normal' | 'bold' | number
25
- fontStyle?: 'normal' | 'italic'
26
- fontSize?: number | string
27
- fontFamily?: string
28
- border?: string
29
- textAlign?: 'left' | 'right' | 'center'
30
- verticalAlign?: 'top' | 'middle' | 'bottom'
31
- }
32
-
33
- /**
34
- * Document-level style. Apply blanket styles to the header row, the
35
- * value rows, or selectively per cell-reference (e.g. `'B2'`).
36
- */
37
- export type ExportStyles = {
38
- /** Style applied to every header cell. */
39
- headerRow?: ExportCellStyle
40
- /** Style applied to every data row. Even / odd zebra are derived from this if `rowAlternate` is set. */
41
- rows?: ExportCellStyle
42
- /** Optional zebra background for odd-indexed rows. */
43
- rowAlternate?: ExportCellStyle
44
- /** Per-cell overrides keyed by Excel-style reference (`'A1'`, `'C3'`). */
45
- cells?: Record<string, ExportCellStyle>
46
- }
47
-
48
- /**
49
- * Header / footer entries for xlsx + pdf. Each line is rendered top to
50
- * bottom on the page. Embed an image with `{ image: dataUrl }`; embed
51
- * text with `{ text: '...', style?: ExportCellStyle }`.
52
- */
53
- export type ExportHeaderFooterLine =
54
- | { text: string; style?: ExportCellStyle }
55
- | { image: string; width?: number; height?: number }
56
- | { left?: string; center?: string; right?: string }
57
-
58
- export type ExportSheet<TData> = {
59
- /** Sheet/tab label. Required. */
60
- label: string
61
- /** Rows for this sheet. */
62
- rows: ReadonlyArray<TData>
63
- /** Per-sheet columns. Falls back to the top-level `columns` if omitted. */
64
- columns?: ReadonlyArray<ExportColumn>
65
- /** Per-sheet styles. */
66
- styles?: ExportStyles
67
- }
68
-
69
- export type ExportOptions<TData> = {
70
- format: ExportFormat
71
- /** Base filename (extension is appended if missing). Defaults to "grid". */
72
- filename?: string
73
- /**
74
- * Columns to include. If omitted, every key of the first row is exported
75
- * (in original object order) using the field name as the header label.
76
- */
77
- columns?: ReadonlyArray<ExportColumn>
78
- /**
79
- * Source rows. If omitted, the current displayed rows from the api are used.
80
- * Provide explicitly when you want to override (e.g. export the whole
81
- * dataset rather than the filtered view).
82
- */
83
- rows?: ReadonlyArray<TData>
84
- /** PDF only. Page orientation. Defaults to "portrait". */
85
- pageOrientation?: 'portrait' | 'landscape'
86
- /**
87
- * Cell + row styles. Apply once to match a light/dark theme, or per-cell
88
- * for conditional formatting. xlsx and pdf honour these; csv/tsv ignore
89
- * them; html bakes them into inline `style=` attributes.
90
- */
91
- styles?: ExportStyles
92
- /** Page header lines (xlsx, pdf, html). Logos go here as `{ image: ... }`. */
93
- header?: ReadonlyArray<ExportHeaderFooterLine>
94
- /** Page footer lines. Common pattern: page number on the right. */
95
- footer?: ReadonlyArray<ExportHeaderFooterLine>
96
- /**
97
- * Multi-sheet export (xlsx only). When set, each entry becomes one
98
- * sheet/tab. The top-level `rows` / `columns` are ignored.
99
- */
100
- sheets?: ReadonlyArray<ExportSheet<TData>>
101
- /**
102
- * If a row has a column whose value matches one of these field names
103
- * AND the value looks like a URL or data URL, the cell is exported as
104
- * an embedded image (xlsx). Defaults to `[]` (no auto-detection).
105
- */
106
- imageFields?: ReadonlyArray<string>
107
- /**
108
- * Pixel dimensions used when embedding images. Smart's xlsx writer
109
- * draws the image at this size relative to the cell origin. Defaults
110
- * to `{ width: 32, height: 32 }`. Set larger for thumbnails, smaller
111
- * for inline icons.
112
- */
113
- imageSize?: { width: number; height: number }
114
- /**
115
- * Group flat rows by one or more field names. The exporter wraps each
116
- * group in an Excel outline row (with the +/- expand button), and
117
- * emits a `<value> <field>` group header above every cluster. Maps
118
- * straight to Smart DataExporter's constructor `groupBy` arg. xlsx
119
- * only; csv/tsv/html flatten the groups back out.
120
- */
121
- groupBy?: ReadonlyArray<string>
122
- /**
123
- * Mark rows as a hierarchical (tree) data source. Each row should
124
- * declare its own children either via the `subRows` convention or by
125
- * matching the parent/child shape Smart expects on the input rows.
126
- * Mutually exclusive with `groupBy`. xlsx only.
127
- */
128
- hierarchical?: boolean
129
- /**
130
- * Merged cells to write into the sheet (xlsx / pdf). Each entry spans
131
- * `colSpan` columns and `rowSpan` rows starting at the given zero-based
132
- * **body** row / column index (the header row is not counted). Mirrors the
133
- * grid's own `MergeSpec` shape closely, so grid merges map straight through -
134
- * convert a `MergeSpec` (`{ rowIndex, columnId, rowspan, colspan }`) by
135
- * resolving `columnId` to its column index. Mutually exclusive with
136
- * `groupBy` / `hierarchical`.
137
- */
138
- merges?: ReadonlyArray<ExportMerge>
139
- }
140
-
141
- /**
142
- * A merged cell region for {@link ExportOptions.merges}. `row` / `col` are
143
- * zero-based indices into the exported body (header excluded). A cell that
144
- * spans two columns to the right is `{ row, col, colSpan: 2 }`.
145
- */
146
- export type ExportMerge = {
147
- row: number
148
- col: number
149
- /** Columns to span (default 1). */
150
- colSpan?: number
151
- /** Rows to span (default 1). */
152
- rowSpan?: number
153
- }
154
-
155
- let exporterCtorPromise: Promise<
156
- new (
157
- options: Record<string, unknown>,
158
- groupBy?: ReadonlyArray<string>,
159
- filterBy?: Record<string, unknown>,
160
- conditionalFormatting?: unknown,
161
- ) => SmartDataExporterInstance
162
- > | null = null
163
-
164
- async function getDataExporter() {
165
- if (typeof window === 'undefined') {
166
- throw new Error('@svgrid/enterprise: export requires a browser environment')
167
- }
168
- if (!exporterCtorPromise) {
169
- exporterCtorPromise = (async () => {
170
- installSmartShim()
171
- // Side-effect import: the IIFE registers Smart.Utilities.DataExporter
172
- // on the global Smart namespace that installSmartShim() set up.
173
- await import('./smart.export.js')
174
- const Ctor = window.Smart?.Utilities?.DataExporter
175
- if (!Ctor) {
176
- throw new Error('@svgrid/enterprise: failed to load Smart.Utilities.DataExporter')
177
- }
178
- return Ctor
179
- })()
180
- }
181
- return exporterCtorPromise
182
- }
183
-
184
- async function ensureGlobals(format: ExportFormat): Promise<void> {
185
- const g = globalThis as unknown as { JSZip?: unknown; pdfMake?: unknown }
186
- if (format === 'xlsx' && g.JSZip == null) {
187
- let mod: unknown
188
- try {
189
- mod = await import('jszip')
190
- } catch {
191
- throw new Error(
192
- '@svgrid/enterprise: xlsx export requires the "jszip" peer dependency. ' +
193
- 'Install it with: pnpm add jszip',
194
- )
195
- }
196
- g.JSZip = (mod as { default?: unknown }).default ?? mod
197
- }
198
- if (format === 'pdf' && g.pdfMake == null) {
199
- let pdfMakeMod: unknown
200
- let vfsMod: unknown
201
- try {
202
- pdfMakeMod = await import('pdfmake/build/pdfmake')
203
- vfsMod = await import('pdfmake/build/vfs_fonts')
204
- } catch {
205
- throw new Error(
206
- '@svgrid/enterprise: pdf export requires the "pdfmake" peer dependency. ' +
207
- 'Install it with: pnpm add pdfmake',
208
- )
209
- }
210
- const pdfMake = ((pdfMakeMod as { default?: unknown }).default ?? pdfMakeMod) as {
211
- vfs?: Record<string, string>
212
- createPdf: (def: unknown) => { download(name: string): void; getBlob(cb: (b: Blob) => void): void }
213
- }
214
- const vfsRoot = (vfsMod as { default?: unknown }).default ?? vfsMod
215
- // pdfmake's vfs_fonts file historically exports either { pdfMake: { vfs } }
216
- // or { default: { vfs } } or { vfs } depending on bundler. Try each shape.
217
- const candidate =
218
- (vfsRoot as { pdfMake?: { vfs?: Record<string, string> } }).pdfMake?.vfs ??
219
- (vfsRoot as { vfs?: Record<string, string> }).vfs ??
220
- (vfsRoot as { default?: { vfs?: Record<string, string> } }).default?.vfs
221
- if (candidate) pdfMake.vfs = candidate
222
- g.pdfMake = pdfMake
223
- }
224
- }
225
-
226
- function buildExportPayload<
227
- TFeatures extends TableFeatures,
228
- TData extends RowData,
229
- >(
230
- api: SvGridApi<TFeatures, TData>,
231
- opts: ExportOptions<TData>,
232
- ): {
233
- rows: ReadonlyArray<Record<string, unknown>>
234
- filename: string
235
- } {
236
- const sourceRows: ReadonlyArray<TData> = opts.rows ?? api.getDisplayedRows()
237
- let cols: ReadonlyArray<ExportColumn>
238
- if (opts.columns && opts.columns.length > 0) {
239
- cols = opts.columns
240
- } else {
241
- // Auto-derive columns from the grid's own columnDefs. This picks up the
242
- // human-readable `header` labels so the exported file shows what the
243
- // user sees on-screen (e.g. "Order ID" rather than "orderId"). Only
244
- // visible, field-backed columns are exported.
245
- const gridCols = api.getColumns().filter((c) => c.visible && c.field)
246
- if (gridCols.length > 0) {
247
- cols = gridCols.map((c) => ({ field: c.field!, header: c.header }))
248
- } else if (sourceRows.length > 0) {
249
- cols = Object.keys(sourceRows[0] as Record<string, unknown>)
250
- .filter((k) => !k.startsWith('_'))
251
- .map((field) => ({ field }))
252
- } else {
253
- cols = []
254
- }
255
- }
256
- const headerRow: Record<string, unknown> = {}
257
- for (const c of cols) headerRow[c.field] = c.header ?? c.field
258
-
259
- const projected: Array<Record<string, unknown>> = [headerRow]
260
- for (const r of sourceRows) {
261
- const row: Record<string, unknown> = {}
262
- const src = r as unknown as Record<string, unknown>
263
- for (const c of cols) row[c.field] = src[c.field]
264
- projected.push(row)
265
- }
266
- const filename = (opts.filename ?? 'grid').trim() || 'grid'
267
- return { rows: projected, filename }
268
- }
269
-
270
- /**
271
- * Translate the public {@link ExportStyles} shape into the keys the Smart
272
- * exporter actually consumes:
273
- * `style.header` → all header cells
274
- * `style.rows` → all data rows. Cell-prop keys (color, backgroundColor,
275
- * fontWeight, fontStyle, fontSize, fontFamily,
276
- * textAlign, verticalAlign, textDecoration, numFmt)
277
- * are recognised by Smart's `storeCellStyle`.
278
- * `style.rows.alternationCount` + `alternationStart` +
279
- * `alternationIndex1BackgroundColor` (etc.) zebra striping
280
- * `style.cells.<ref>` HTML-only per-cell ref overrides
281
- */
282
- function translateStyles(styles: ExportStyles | undefined): Record<string, unknown> | undefined {
283
- if (!styles) return undefined
284
- const out: Record<string, unknown> = {}
285
- if (styles.headerRow) out.header = { ...styles.headerRow }
286
- const rows: Record<string, unknown> = styles.rows ? { ...styles.rows } : {}
287
- if (styles.rowAlternate) {
288
- rows.alternationCount = 2
289
- rows.alternationStart = 0
290
- if (styles.rowAlternate.backgroundColor)
291
- rows.alternationIndex1BackgroundColor = styles.rowAlternate.backgroundColor
292
- if (styles.rowAlternate.color)
293
- rows.alternationIndex1Color = styles.rowAlternate.color
294
- }
295
- if (Object.keys(rows).length > 0) out.rows = rows
296
- if (styles.cells) out.cells = styles.cells
297
- return out
298
- }
299
-
300
- let _imgIdCounter = 0
301
- const _imgIdCache = new Map<string, string>()
302
- function imageIdFor(dataUrl: string): string {
303
- const cached = _imgIdCache.get(dataUrl)
304
- if (cached) return cached
305
- const id = `img${++_imgIdCounter}`
306
- _imgIdCache.set(dataUrl, id)
307
- return id
308
- }
309
-
310
- /**
311
- * Build the image object Smart's xlsx writer expects. The exporter pulls
312
- * `{ id, base64, imageType, width, height }` off the return of
313
- * `addImageToCell(...).image` and stores it under `this.images` →
314
- * eventually written to the workbook as `xl/media/imageN.<imageType>`.
315
- *
316
- * `base64` may be a full data URL - Smart strips the prefix when writing.
317
- */
318
- function makeSmartImage(dataUrl: string, width = 64, height = 64): {
319
- id: string; base64: string; imageType: string; width: number; height: number
320
- } {
321
- // Extract MIME → imageType. Defaults to png for safety.
322
- const mime = /^data:image\/([a-zA-Z0-9+]+);/i.exec(dataUrl)?.[1]?.toLowerCase() ?? 'png'
323
- const imageType = mime === 'jpg' ? 'jpeg'
324
- : mime === 'svg+xml' ? 'svg'
325
- : mime
326
- return {
327
- id: imageIdFor(dataUrl),
328
- base64: dataUrl,
329
- imageType,
330
- width,
331
- height,
332
- }
333
- }
334
-
335
- /**
336
- * Build the header / footer arrays the Smart exporter understands.
337
- * Each input line becomes one full-width row in the spreadsheet with
338
- * `style.mergeAcross` so the line spans every column.
339
- *
340
- * Image lines are written as a data-URL string into the first column and
341
- * the wrapper's `addImageToCell` hook embeds them as real picture cells.
342
- */
343
- function buildContentRows(
344
- lines: ReadonlyArray<ExportHeaderFooterLine> | undefined,
345
- datafields: ReadonlyArray<string>,
346
- ): Array<{ cells: Record<string, unknown>; style?: Record<string, unknown> }> | undefined {
347
- if (!lines || lines.length === 0 || datafields.length === 0) return undefined
348
- const first = datafields[0]!
349
- const out: Array<{ cells: Record<string, unknown>; style?: Record<string, unknown> }> = []
350
- for (const line of lines) {
351
- const cells: Record<string, unknown> = {}
352
- let style: Record<string, unknown> | undefined
353
- if ('image' in line) {
354
- cells[first] = line.image
355
- style = { mergeAcross: true, textAlign: 'left' }
356
- } else if ('text' in line) {
357
- cells[first] = line.text
358
- style = { mergeAcross: true, ...(line.style ?? {}) }
359
- } else {
360
- // 3-column { left, center, right }. Distribute across the first,
361
- // middle, and last datafield.
362
- const mid = datafields[Math.floor(datafields.length / 2)]!
363
- const last = datafields[datafields.length - 1]!
364
- if (line.left !== undefined) cells[first] = line.left
365
- if (line.center !== undefined) cells[mid] = line.center
366
- if (line.right !== undefined) cells[last] = line.right
367
- style = { textAlign: 'left' }
368
- }
369
- out.push({ cells, style })
370
- }
371
- return out
372
- }
373
-
374
- /**
375
- * Build an `addImageToCell` handler that embeds a value as a real image
376
- * cell whenever the value is a data URL. Smart calls this for every body
377
- * cell AND every header/footer cell, so the same handler covers both
378
- * code paths.
379
- *
380
- * `bodyImageSize` controls the embedded pixel size for body cells (the
381
- * thumbnails). Header/footer image lines use their own size hint
382
- * already encoded in the `cells[first]` value's metadata - here we just
383
- * embed with a slightly larger default so logos read well in the page
384
- * banner.
385
- */
386
- function buildImageHandler(
387
- imageFields: ReadonlyArray<string> | undefined,
388
- bodyImageSize: { width: number; height: number },
389
- ): NonNullable<SmartDataExporterInstance['addImageToCell']> {
390
- const allowed = imageFields && imageFields.length > 0 ? new Set(imageFields) : null
391
- return (_rowIndex, dataField, value) => {
392
- if (typeof value !== 'string') return null
393
- if (!/^data:image\//i.test(value)) return null
394
- if (allowed && !allowed.has(dataField)) {
395
- // Header/footer image lines come through the first column too -
396
- // those cells aren't in `imageFields`, but the wrapper wrote the
397
- // data URL there itself. Embed bigger (logo banner).
398
- return { image: makeSmartImage(value, 96, 96), value: '' }
399
- }
400
- return { image: makeSmartImage(value, bodyImageSize.width, bodyImageSize.height), value: '' }
401
- }
402
- }
403
-
404
- /**
405
- * Build the constructor-options object passed to the Smart DataExporter.
406
- * Constructor only consumes a small set of keys; instance-level options
407
- * (`headerContent`, `footerContent`, `addImageToCell`) are applied
408
- * separately in {@link exportGrid} after `new DataExporter(...)`.
409
- */
410
- function buildExporterOptions<TData>(
411
- opts: ExportOptions<TData>,
412
- ): Record<string, unknown> {
413
- const out: Record<string, unknown> = {
414
- exportHeader: true,
415
- pageOrientation: opts.pageOrientation ?? 'portrait',
416
- }
417
- const style = translateStyles(opts.styles)
418
- if (style) out.style = style
419
- // Smart's xlsx writer emits Excel-native row outlining when
420
- // `hierarchical: true` is set on the constructor options OR when the
421
- // second constructor arg is a non-empty groupBy array. We thread
422
- // hierarchical through here and groupBy through the constructor call
423
- // site in exportGrid.
424
- if (opts.hierarchical) out.hierarchical = true
425
- // Merged cells: translate the friendly {row, col, rowSpan, colSpan} shape to
426
- // Smart's native {cell: [row, col], rowspan, colspan}. Skip 1x1 "merges"
427
- // (nothing to span). Ignored when grouping/hierarchy owns the row layout.
428
- if (opts.merges?.length && !opts.groupBy?.length && !opts.hierarchical) {
429
- const mergedCells = opts.merges
430
- .map((m) => ({
431
- cell: [m.row, m.col],
432
- rowspan: Math.max(1, Math.floor(m.rowSpan ?? 1)),
433
- colspan: Math.max(1, Math.floor(m.colSpan ?? 1)),
434
- }))
435
- .filter((m) => m.rowspan > 1 || m.colspan > 1)
436
- if (mergedCells.length) out.mergedCells = mergedCells
437
- }
438
- return out
439
- }
440
-
441
- /**
442
- * Apply instance-level options the constructor does not consume.
443
- * Mutates and returns the exporter for fluent use.
444
- */
445
- function applyInstanceOptions<TData>(
446
- exporter: SmartDataExporterInstance,
447
- opts: ExportOptions<TData>,
448
- datafields: ReadonlyArray<string>,
449
- ): SmartDataExporterInstance {
450
- const headerRows = buildContentRows(opts.header, datafields)
451
- const footerRows = buildContentRows(opts.footer, datafields)
452
- if (headerRows) exporter.headerContent = headerRows
453
- if (footerRows) exporter.footerContent = footerRows
454
-
455
- // Wire image embedding when the caller opts in via imageFields OR the
456
- // header/footer carries an image line. Both code paths funnel through
457
- // the same handler.
458
- const hasHeaderOrFooterImage =
459
- (opts.header?.some((l) => 'image' in l) ?? false) ||
460
- (opts.footer?.some((l) => 'image' in l) ?? false)
461
- if (opts.imageFields?.length || hasHeaderOrFooterImage) {
462
- const bodySize = opts.imageSize ?? { width: 32, height: 32 }
463
- exporter.addImageToCell = buildImageHandler(opts.imageFields, bodySize)
464
- }
465
- return exporter
466
- }
467
-
468
- export async function exportGrid<
469
- TFeatures extends TableFeatures,
470
- TData extends RowData,
471
- >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<void> {
472
- assertEnterpriseLicensed('Export')
473
- await ensureGlobals(opts.format)
474
- const Ctor = await getDataExporter()
475
-
476
- // Multi-sheet path. Smart's `spreadsheets` option takes an array of
477
- // `{ label, dataSource, columns, dataFields? }`. Each entry becomes one
478
- // tab; the exporter prepends its own header row built from `columns`.
479
- if (opts.sheets && opts.sheets.length > 0) {
480
- if (opts.format !== 'xlsx') {
481
- throw new Error(`@svgrid/enterprise: multi-sheet export requires format 'xlsx', got '${opts.format}'`)
482
- }
483
- // Same column-derivation fallback used by the single-sheet path: pull
484
- // the human-readable headers from the grid when the caller didn't pass
485
- // explicit columns.
486
- const gridCols = api.getColumns().filter((c) => c.visible && c.field)
487
- const fallbackCols: ReadonlyArray<ExportColumn> =
488
- gridCols.length > 0
489
- ? gridCols.map((c) => ({ field: c.field!, header: c.header }))
490
- : []
491
- const sheets = opts.sheets.map((sheet) => {
492
- const cols = (sheet.columns ?? opts.columns ?? (
493
- fallbackCols.length > 0
494
- ? fallbackCols
495
- : sheet.rows.length > 0
496
- ? Object.keys(sheet.rows[0] as Record<string, unknown>)
497
- .filter((k) => !k.startsWith('_'))
498
- .map((field) => ({ field } as ExportColumn))
499
- : []
500
- ))
501
- // Project each row to only the included fields (in column order).
502
- const dataSource = sheet.rows.map((r) => {
503
- const src = r as unknown as Record<string, unknown>
504
- const out: Record<string, unknown> = {}
505
- for (const c of cols) out[c.field] = src[c.field]
506
- return out
507
- })
508
- return {
509
- label: sheet.label,
510
- dataSource,
511
- columns: cols.map((c) => ({ dataField: c.field, label: c.header ?? c.field })),
512
- dataFields: cols.map((c) => c.field),
513
- style: translateStyles(sheet.styles ?? opts.styles),
514
- }
515
- })
516
- const filename = (opts.filename ?? 'grid').trim() || 'grid'
517
- const ctorOpts = buildExporterOptions(opts)
518
- // Smart needs at least one main sheet to operate. Use the first sheet
519
- // as the "main" exportData payload AND keep the full set on the
520
- // `spreadsheets` property so the workbook ends up with one tab per
521
- // entry.
522
- const firstSheet = sheets[0]!
523
- const exporter = new Ctor(ctorOpts)
524
- exporter.spreadsheets = sheets
525
- applyInstanceOptions(exporter, opts, firstSheet.dataFields)
526
- // First sheet's data goes through the standard exportData path with
527
- // its own header row.
528
- const headerRow: Record<string, unknown> = {}
529
- for (const c of firstSheet.columns) headerRow[c.dataField] = c.label
530
- const firstSheetRows = [headerRow, ...firstSheet.dataSource]
531
- exporter.exportData(firstSheetRows, opts.format, filename)
532
- return
533
- }
534
-
535
- const { rows, filename } = buildExportPayload(api, opts)
536
- if (rows.length <= 1) {
537
- throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
538
- }
539
- const datafields = Object.keys(rows[0]!)
540
- // The constructor's SECOND arg is `groupBy`: when non-empty AND
541
- // `hierarchical` is NOT set, Smart wraps each group of rows in an
542
- // Excel outline row with an expand/collapse button at the group key.
543
- const groupBy = !opts.hierarchical && opts.groupBy && opts.groupBy.length > 0
544
- ? opts.groupBy
545
- : undefined
546
- const exporter = new Ctor(buildExporterOptions(opts), groupBy)
547
- applyInstanceOptions(exporter, opts, datafields)
548
- exporter.exportData(rows, opts.format, filename)
549
- }
1
+ /// <reference path="./pdfmake-shims.d.ts" />
2
+ import type { CellFormatConfig, RowData, TableFeatures } from '@svgrid/grid'
3
+ import type { SvGridApi } from '@svgrid/grid'
4
+ // Import the pure formatters from the grid's Svelte-free `/format` subpath so
5
+ // this module (and the enterprise unit tests) don't drag in SvGrid.svelte.
6
+ import { formatValueForExport, toExcelNumFmt, valueForExcel } from '@svgrid/grid/format'
7
+ import { assertEnterpriseLicensed } from './license'
8
+ import { installSmartShim, type SmartDataExporterInstance } from './smart-shim'
9
+ import {
10
+ downloadBlobFile,
11
+ downloadTextFile,
12
+ serializeDelimited,
13
+ serializeHtml,
14
+ serializeJson,
15
+ serializeMarkdown,
16
+ serializeXml,
17
+ type CsvOptions,
18
+ type SerializeProgress,
19
+ } from './export-serialize'
20
+ import { serializeSpreadsheetML } from './export-xls'
21
+ import { buildXlsxParts, packageXlsx, type XlsxCell, type XlsxCondFormat, type XlsxTableSpec } from './export-ooxml'
22
+ import { buildPdfDocDefinition, type PdfBodyRow, type PdfExportOptions } from './export-pdf'
23
+ import { buildConditionalResolver, type ExportCellVisualFn } from './export-conditional'
24
+ import type { ConditionalFormat } from '@svgrid/grid/format'
25
+
26
+ /**
27
+ * Export target formats.
28
+ * - `xlsx` modern OOXML workbook (needs `jszip`),
29
+ * - `xls` legacy Excel 2003 XML Spreadsheet (no peer dependency),
30
+ * - `pdf` paginated document (needs `pdfmake`),
31
+ * - `csv` / `tsv` / `html` / `json` / `xml` / `md` native, no peer deps.
32
+ */
33
+ export type ExportFormat =
34
+ | 'xlsx'
35
+ | 'xls'
36
+ | 'pdf'
37
+ | 'csv'
38
+ | 'tsv'
39
+ | 'html'
40
+ | 'json'
41
+ | 'xml'
42
+ | 'md'
43
+
44
+ /** Formats served by the native, dependency-free, streaming serializers. */
45
+ const NATIVE_TEXT_FORMATS: ReadonlyArray<ExportFormat> = [
46
+ 'csv',
47
+ 'tsv',
48
+ 'html',
49
+ 'json',
50
+ 'xml',
51
+ 'md',
52
+ ]
53
+
54
+ export type ExportColumn<TData = RowData> = {
55
+ /** Data field name on the row object. */
56
+ field: string
57
+ /** Header label to render in the exported file. Defaults to `field`. */
58
+ header?: string
59
+ /**
60
+ * Column format config. When set (and `rawValues` is off), the exporter
61
+ * renders the value the way the grid does on screen - currency symbol,
62
+ * date pattern, percent, etc. Auto-populated from the grid's own columns
63
+ * when you don't pass `columns` explicitly.
64
+ */
65
+ format?: CellFormatConfig
66
+ /** Cell alignment, carried into html / pdf output. */
67
+ align?: 'left' | 'center' | 'right'
68
+ /** Explicit column width in pixels (xlsx / xls). Overrides auto-fit. */
69
+ width?: number
70
+ /**
71
+ * Turn this column's cells into hyperlinks. Return the URL for a row (or
72
+ * null for no link). Rendered as clickable links in xls / html / pdf /
73
+ * print. (xlsx via the vendored writer doesn't support cell links.)
74
+ */
75
+ link?: (row: TData) => string | null | undefined
76
+ /**
77
+ * Escape hatch for columns whose on-screen value comes from a custom cell
78
+ * renderer (a snippet), a `fieldFn`, or a lookup the raw field can't
79
+ * express. Return the value to write for this row. Takes precedence over
80
+ * `format`. Return a number / Date to keep it typed in xlsx.
81
+ */
82
+ exportValue?: (row: TData) => string | number | Date | null | undefined
83
+ }
84
+
85
+ /** Progress reported during an export. `phase` is 'project' while building
86
+ * rows, 'serialize' while writing text formats, 'write' around the xlsx/pdf
87
+ * writer. `ratio` is 0..1. */
88
+ export type ExportProgress =
89
+ | SerializeProgress
90
+ | { phase: 'project' | 'write'; ratio: number; row?: number; total?: number }
91
+
92
+ /**
93
+ * The built export file. Returned by `exportData` for the dependency-free
94
+ * paths (csv/tsv/html/json/xml/md, xls, pdf, single-sheet xlsx) so callers can
95
+ * preview / upload / email / attach it instead of (or as well as) downloading.
96
+ * With `download: false`, nothing is downloaded and this is returned.
97
+ */
98
+ export type ExportResult = {
99
+ blob: Blob
100
+ filename: string
101
+ mime: string
102
+ /** Number of data rows written (excludes the header). */
103
+ rowCount: number
104
+ byteSize: number
105
+ }
106
+
107
+ /**
108
+ * Per-cell style descriptor. Mirrors a subset of CSS; the keys the
109
+ * underlying exporter honours are font / colour / background / border /
110
+ * alignment. Anything else is ignored gracefully.
111
+ */
112
+ export type ExportCellStyle = {
113
+ color?: string
114
+ backgroundColor?: string
115
+ fontWeight?: 'normal' | 'bold' | number
116
+ fontStyle?: 'normal' | 'italic'
117
+ fontSize?: number | string
118
+ fontFamily?: string
119
+ border?: string
120
+ textAlign?: 'left' | 'right' | 'center'
121
+ verticalAlign?: 'top' | 'middle' | 'bottom'
122
+ }
123
+
124
+ /**
125
+ * Document-level style. Apply blanket styles to the header row, the
126
+ * value rows, or selectively per cell-reference (e.g. `'B2'`).
127
+ */
128
+ export type ExportStyles = {
129
+ /** Style applied to every header cell. */
130
+ headerRow?: ExportCellStyle
131
+ /** Style applied to every data row. Even / odd zebra are derived from this if `rowAlternate` is set. */
132
+ rows?: ExportCellStyle
133
+ /** Optional zebra background for odd-indexed rows. */
134
+ rowAlternate?: ExportCellStyle
135
+ /** Per-cell overrides keyed by Excel-style reference (`'A1'`, `'C3'`). */
136
+ cells?: Record<string, ExportCellStyle>
137
+ }
138
+
139
+ /**
140
+ * Header / footer entries for xlsx + pdf. Each line is rendered top to
141
+ * bottom on the page. Embed an image with `{ image: dataUrl }`; embed
142
+ * text with `{ text: '...', style?: ExportCellStyle }`.
143
+ */
144
+ export type ExportHeaderFooterLine =
145
+ | { text: string; style?: ExportCellStyle }
146
+ | { image: string; width?: number; height?: number }
147
+ | { left?: string; center?: string; right?: string }
148
+
149
+ export type ExportSheet<TData> = {
150
+ /** Sheet/tab label. Required. */
151
+ label: string
152
+ /** Rows for this sheet. */
153
+ rows: ReadonlyArray<TData>
154
+ /** Per-sheet columns. Falls back to the top-level `columns` if omitted. */
155
+ columns?: ReadonlyArray<ExportColumn<TData>>
156
+ /** Per-sheet styles. */
157
+ styles?: ExportStyles
158
+ }
159
+
160
+ /**
161
+ * Which rows to export:
162
+ * 'displayed' (default) - the current filtered / sorted / paginated view,
163
+ * 'selected' - only checked rows,
164
+ * 'all' - the full underlying dataset (pre-filter),
165
+ * an explicit array - export exactly these.
166
+ */
167
+ export type ExportRowSource<TData> = ReadonlyArray<TData> | 'displayed' | 'selected' | 'all'
168
+
169
+ export type ExportOptions<TData> = {
170
+ format: ExportFormat
171
+ /** Base filename (extension is appended if missing). Defaults to "grid". */
172
+ filename?: string
173
+ /**
174
+ * Columns to include. If omitted, the grid's own visible columns are used
175
+ * (carrying their header labels + `format`), falling back to the keys of
176
+ * the first row.
177
+ */
178
+ columns?: ReadonlyArray<ExportColumn<TData>>
179
+ /**
180
+ * Source rows. Defaults to `'displayed'` (the current view). Pass
181
+ * `'selected'` / `'all'`, or an explicit array to override.
182
+ */
183
+ rows?: ExportRowSource<TData>
184
+ /**
185
+ * By default the exporter writes the FORMATTED display value (what the user
186
+ * sees) so the file matches the grid. Set `rawValues: true` to write the
187
+ * underlying raw values instead (numbers stay numeric in xlsx; useful for
188
+ * downstream data pipelines).
189
+ */
190
+ rawValues?: boolean
191
+ /**
192
+ * Auto-carry the grid's active row grouping into the export (xlsx outline
193
+ * rows). On by default; set false to ignore grouping, or pass `groupBy`
194
+ * explicitly to override. Ignored when you pass `groupBy` / `hierarchical`
195
+ * / `sheets` yourself.
196
+ */
197
+ autoGroup?: boolean
198
+ /** CSV / TSV tuning: delimiter, line ending, UTF-8 BOM. */
199
+ csv?: CsvOptions
200
+ /**
201
+ * Freeze the header row so it stays visible when scrolling (xlsx / xls).
202
+ * Default true.
203
+ */
204
+ freezeHeader?: boolean
205
+ /** Freeze this many leading columns (xlsx / xls). Default 0. */
206
+ freezeColumns?: number
207
+ /**
208
+ * Size columns to their content (xlsx / xls). Default true. A column's
209
+ * explicit `width` always wins.
210
+ */
211
+ autoFitColumns?: boolean
212
+ /**
213
+ * Export long integer IDs (> 15 significant digits) as TEXT so Excel's
214
+ * 15-digit float precision doesn't silently mangle them. Default false.
215
+ * (xlsx / xls.)
216
+ */
217
+ precisionSafe?: boolean
218
+ /**
219
+ * Wrap the xlsx data in a native Excel Table (filter dropdowns, banded rows,
220
+ * structured refs). `{ totalsRow: true }` adds a totals row with live
221
+ * SUM/AVG formulas for numeric columns. Single-sheet xlsx only.
222
+ */
223
+ excelTable?: boolean | { totalsRow?: boolean; style?: string }
224
+ /** Progress callback for large exports (row projection + serialization). */
225
+ onProgress?: (progress: ExportProgress) => void
226
+ /** Abort an in-flight export (checked between chunks). Throws `AbortError`. */
227
+ signal?: AbortSignal
228
+ /**
229
+ * When false, don't trigger a browser download - just build the file and
230
+ * return it as an {@link ExportResult} (`{ blob, filename, mime, rowCount,
231
+ * byteSize }`) so the app can preview / upload / email it. Default true.
232
+ * Not supported for the vendored-writer xlsx paths (grouped / multi-sheet /
233
+ * images / blanket styles), which download directly.
234
+ */
235
+ download?: boolean
236
+ /** PDF only. Page orientation. Defaults to "portrait" (auto-landscape for
237
+ * wide grids). Shorthand for `pdf.pageOrientation`. */
238
+ pageOrientation?: 'portrait' | 'landscape'
239
+ /** PDF layout options: page size, margins, title / subtitle / logo, theme
240
+ * colors, column widths, repeated header, page numbers. */
241
+ pdf?: PdfExportOptions
242
+ /**
243
+ * Conditional formatting rules - the same array you pass to
244
+ * `<SvGrid conditionalFormats>`. Carried into the styled formats (pdf, xlsx,
245
+ * html): cell background / text color / bold, plus icon-set glyphs. Ignored
246
+ * by the data formats (csv / tsv / json / xml / md).
247
+ */
248
+ conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>
249
+ /**
250
+ * Cell + row styles. Apply once to match a light/dark theme, or per-cell
251
+ * for conditional formatting. xlsx and pdf honour these; csv/tsv ignore
252
+ * them; html bakes them into inline `style=` attributes.
253
+ */
254
+ styles?: ExportStyles
255
+ /** Page header lines (xlsx, pdf, html). Logos go here as `{ image: ... }`. */
256
+ header?: ReadonlyArray<ExportHeaderFooterLine>
257
+ /** Page footer lines. Common pattern: page number on the right. */
258
+ footer?: ReadonlyArray<ExportHeaderFooterLine>
259
+ /**
260
+ * Multi-sheet export (xlsx only). When set, each entry becomes one
261
+ * sheet/tab. The top-level `rows` / `columns` are ignored.
262
+ */
263
+ sheets?: ReadonlyArray<ExportSheet<TData>>
264
+ /**
265
+ * If a row has a column whose value matches one of these field names
266
+ * AND the value looks like a URL or data URL, the cell is exported as
267
+ * an embedded image (xlsx). Defaults to `[]` (no auto-detection).
268
+ */
269
+ imageFields?: ReadonlyArray<string>
270
+ /**
271
+ * Pixel dimensions used when embedding images. Smart's xlsx writer
272
+ * draws the image at this size relative to the cell origin. Defaults
273
+ * to `{ width: 32, height: 32 }`. Set larger for thumbnails, smaller
274
+ * for inline icons.
275
+ */
276
+ imageSize?: { width: number; height: number }
277
+ /**
278
+ * Group flat rows by one or more field names. The exporter wraps each
279
+ * group in an Excel outline row (with the +/- expand button), and
280
+ * emits a `<value> <field>` group header above every cluster. Maps
281
+ * straight to Smart DataExporter's constructor `groupBy` arg. xlsx
282
+ * only; csv/tsv/html flatten the groups back out.
283
+ */
284
+ groupBy?: ReadonlyArray<string>
285
+ /**
286
+ * Mark rows as a hierarchical (tree) data source. Each row should
287
+ * declare its own children either via the `subRows` convention or by
288
+ * matching the parent/child shape Smart expects on the input rows.
289
+ * Mutually exclusive with `groupBy`. xlsx only.
290
+ */
291
+ hierarchical?: boolean
292
+ /**
293
+ * Merged cells to write into the sheet (xlsx / pdf). Each entry spans
294
+ * `colSpan` columns and `rowSpan` rows starting at the given zero-based
295
+ * **body** row / column index (the header row is not counted). Mirrors the
296
+ * grid's own `MergeSpec` shape closely, so grid merges map straight through -
297
+ * convert a `MergeSpec` (`{ rowIndex, columnId, rowspan, colspan }`) by
298
+ * resolving `columnId` to its column index. Mutually exclusive with
299
+ * `groupBy` / `hierarchical`.
300
+ */
301
+ merges?: ReadonlyArray<ExportMerge>
302
+ }
303
+
304
+ /**
305
+ * A merged cell region for {@link ExportOptions.merges}. `row` / `col` are
306
+ * zero-based indices into the exported body (header excluded). A cell that
307
+ * spans two columns to the right is `{ row, col, colSpan: 2 }`.
308
+ */
309
+ export type ExportMerge = {
310
+ row: number
311
+ col: number
312
+ /** Columns to span (default 1). */
313
+ colSpan?: number
314
+ /** Rows to span (default 1). */
315
+ rowSpan?: number
316
+ }
317
+
318
+ let exporterCtorPromise: Promise<
319
+ new (
320
+ options: Record<string, unknown>,
321
+ groupBy?: ReadonlyArray<string>,
322
+ filterBy?: Record<string, unknown>,
323
+ conditionalFormatting?: unknown,
324
+ ) => SmartDataExporterInstance
325
+ > | null = null
326
+
327
+ async function getDataExporter() {
328
+ if (typeof window === 'undefined') {
329
+ throw new Error('@svgrid/enterprise: export requires a browser environment')
330
+ }
331
+ if (!exporterCtorPromise) {
332
+ exporterCtorPromise = (async () => {
333
+ installSmartShim()
334
+ // Side-effect import: the IIFE registers Smart.Utilities.DataExporter
335
+ // on the global Smart namespace that installSmartShim() set up.
336
+ await import('./smart.export.js')
337
+ const Ctor = window.Smart?.Utilities?.DataExporter
338
+ if (!Ctor) {
339
+ throw new Error('@svgrid/enterprise: failed to load Smart.Utilities.DataExporter')
340
+ }
341
+ return Ctor
342
+ })()
343
+ }
344
+ return exporterCtorPromise
345
+ }
346
+
347
+ async function ensureGlobals(format: ExportFormat): Promise<void> {
348
+ const g = globalThis as unknown as { JSZip?: unknown; pdfMake?: unknown }
349
+ if (format === 'xlsx' && g.JSZip == null) {
350
+ let mod: unknown
351
+ try {
352
+ mod = await import('jszip')
353
+ } catch {
354
+ throw new Error(
355
+ '@svgrid/enterprise: xlsx export requires the "jszip" peer dependency. ' +
356
+ 'Install it with: pnpm add jszip',
357
+ )
358
+ }
359
+ g.JSZip = (mod as { default?: unknown }).default ?? mod
360
+ }
361
+ if (format === 'pdf' && g.pdfMake == null) {
362
+ let pdfMakeMod: unknown
363
+ let vfsMod: unknown
364
+ try {
365
+ pdfMakeMod = await import('pdfmake/build/pdfmake')
366
+ vfsMod = await import('pdfmake/build/vfs_fonts')
367
+ } catch {
368
+ throw new Error(
369
+ '@svgrid/enterprise: pdf export requires the "pdfmake" peer dependency. ' +
370
+ 'Install it with: pnpm add pdfmake',
371
+ )
372
+ }
373
+ const pdfMake = ((pdfMakeMod as { default?: unknown }).default ?? pdfMakeMod) as {
374
+ vfs?: Record<string, string>
375
+ createPdf: (def: unknown) => { download(name: string): void; getBlob(cb: (b: Blob) => void): void }
376
+ }
377
+ const vfsRoot = (vfsMod as { default?: unknown }).default ?? vfsMod
378
+ // pdfmake's vfs_fonts file historically exports either { pdfMake: { vfs } }
379
+ // or { default: { vfs } } or { vfs } depending on bundler. Try each shape.
380
+ const candidate =
381
+ (vfsRoot as { pdfMake?: { vfs?: Record<string, string> } }).pdfMake?.vfs ??
382
+ (vfsRoot as { vfs?: Record<string, string> }).vfs ??
383
+ (vfsRoot as { default?: { vfs?: Record<string, string> } }).default?.vfs
384
+ if (candidate) pdfMake.vfs = candidate
385
+ g.pdfMake = pdfMake
386
+ }
387
+ }
388
+
389
+ /** Resolve the {@link ExportRowSource} shorthand against the grid api. */
390
+ export function resolveRowSource<TFeatures extends TableFeatures, TData extends RowData>(
391
+ api: SvGridApi<TFeatures, TData>,
392
+ source: ExportRowSource<TData> | undefined,
393
+ ): ReadonlyArray<TData> {
394
+ if (Array.isArray(source)) return source
395
+ switch (source) {
396
+ case 'selected':
397
+ return api.getSelectedRows()
398
+ case 'all':
399
+ return api.getData()
400
+ case 'displayed':
401
+ case undefined:
402
+ default:
403
+ return api.getDisplayedRows()
404
+ }
405
+ }
406
+
407
+ /** Resolve the export columns, carrying grid header labels + `format`. */
408
+ export function resolveColumns<TFeatures extends TableFeatures, TData extends RowData>(
409
+ api: SvGridApi<TFeatures, TData>,
410
+ opts: Pick<ExportOptions<TData>, 'columns'>,
411
+ sourceRows: ReadonlyArray<TData>,
412
+ ): ReadonlyArray<ExportColumn<TData>> {
413
+ if (opts.columns && opts.columns.length > 0) return opts.columns
414
+ const gridCols = api.getColumns().filter((c) => c.visible && c.field)
415
+ if (gridCols.length > 0) {
416
+ return gridCols.map((c) => ({
417
+ field: c.field!,
418
+ header: c.header,
419
+ format: c.format,
420
+ align: c.align,
421
+ }))
422
+ }
423
+ if (sourceRows.length > 0) {
424
+ return Object.keys(sourceRows[0] as Record<string, unknown>)
425
+ .filter((k) => !k.startsWith('_'))
426
+ .map((field) => ({ field }) as ExportColumn<TData>)
427
+ }
428
+ return []
429
+ }
430
+
431
+ /**
432
+ * The value written for one cell: an `exportValue` hook wins; otherwise the
433
+ * raw field value, formatted to the on-screen display string unless
434
+ * `rawValues` is set.
435
+ */
436
+ function cellValueFor<TData extends RowData>(
437
+ col: ExportColumn<TData>,
438
+ rowData: TData,
439
+ rawValues: boolean,
440
+ ): unknown {
441
+ if (col.exportValue) {
442
+ const v = col.exportValue(rowData)
443
+ return v ?? ''
444
+ }
445
+ const raw = (rowData as unknown as Record<string, unknown>)[col.field]
446
+ if (rawValues) return raw
447
+ return formatValueForExport(raw, col.format)
448
+ }
449
+
450
+
451
+ /**
452
+ * Project source rows into the `[header, ...records]` shape the writers
453
+ * consume, applying per-column formatting / `exportValue`. Async so it can
454
+ * yield to the event loop (and honour an abort) on large datasets.
455
+ */
456
+ export async function projectRows<TData extends RowData>(
457
+ sourceRows: ReadonlyArray<TData>,
458
+ cols: ReadonlyArray<ExportColumn<TData>>,
459
+ opts: Pick<ExportOptions<TData>, 'rawValues' | 'onProgress' | 'signal'>,
460
+ ): Promise<Array<Record<string, unknown>>> {
461
+ const rawValues = opts.rawValues === true
462
+ const headerRow: Record<string, unknown> = {}
463
+ for (const c of cols) headerRow[c.field] = c.header ?? c.field
464
+
465
+ const projected: Array<Record<string, unknown>> = [headerRow]
466
+ const total = sourceRows.length
467
+ const CHUNK = 5000
468
+ for (let i = 0; i < total; i++) {
469
+ if (opts.signal?.aborted) throw new DOMException('Export aborted', 'AbortError')
470
+ const r = sourceRows[i]!
471
+ const row: Record<string, unknown> = {}
472
+ for (const c of cols) row[c.field] = cellValueFor(c, r, rawValues)
473
+ projected.push(row)
474
+ if (i > 0 && i % CHUNK === 0) {
475
+ opts.onProgress?.({ phase: 'project', ratio: i / total, row: i, total })
476
+ await new Promise((resolve) => setTimeout(resolve, 0))
477
+ }
478
+ }
479
+ opts.onProgress?.({ phase: 'project', ratio: 1, row: total, total })
480
+ return projected
481
+ }
482
+
483
+ /**
484
+ * Translate the public {@link ExportStyles} shape into the keys the Smart
485
+ * exporter actually consumes:
486
+ * `style.header` → all header cells
487
+ * `style.rows` → all data rows. Cell-prop keys (color, backgroundColor,
488
+ * fontWeight, fontStyle, fontSize, fontFamily,
489
+ * textAlign, verticalAlign, textDecoration, numFmt)
490
+ * are recognised by Smart's `storeCellStyle`.
491
+ * `style.rows.alternationCount` + `alternationStart` +
492
+ * `alternationIndex1BackgroundColor` (etc.) zebra striping
493
+ * `style.cells.<ref>` HTML-only per-cell ref overrides
494
+ */
495
+ function translateStyles(styles: ExportStyles | undefined): Record<string, unknown> | undefined {
496
+ if (!styles) return undefined
497
+ const out: Record<string, unknown> = {}
498
+ if (styles.headerRow) out.header = { ...styles.headerRow }
499
+ const rows: Record<string, unknown> = styles.rows ? { ...styles.rows } : {}
500
+ if (styles.rowAlternate) {
501
+ rows.alternationCount = 2
502
+ rows.alternationStart = 0
503
+ if (styles.rowAlternate.backgroundColor)
504
+ rows.alternationIndex1BackgroundColor = styles.rowAlternate.backgroundColor
505
+ if (styles.rowAlternate.color)
506
+ rows.alternationIndex1Color = styles.rowAlternate.color
507
+ }
508
+ if (Object.keys(rows).length > 0) out.rows = rows
509
+ if (styles.cells) out.cells = styles.cells
510
+ return out
511
+ }
512
+
513
+ let _imgIdCounter = 0
514
+ const _imgIdCache = new Map<string, string>()
515
+ function imageIdFor(dataUrl: string): string {
516
+ const cached = _imgIdCache.get(dataUrl)
517
+ if (cached) return cached
518
+ const id = `img${++_imgIdCounter}`
519
+ _imgIdCache.set(dataUrl, id)
520
+ return id
521
+ }
522
+
523
+ /**
524
+ * Build the image object Smart's xlsx writer expects. The exporter pulls
525
+ * `{ id, base64, imageType, width, height }` off the return of
526
+ * `addImageToCell(...).image` and stores it under `this.images`
527
+ * eventually written to the workbook as `xl/media/imageN.<imageType>`.
528
+ *
529
+ * `base64` may be a full data URL - Smart strips the prefix when writing.
530
+ */
531
+ function makeSmartImage(dataUrl: string, width = 64, height = 64): {
532
+ id: string; base64: string; imageType: string; width: number; height: number
533
+ } {
534
+ // Extract MIME → imageType. Defaults to png for safety.
535
+ const mime = /^data:image\/([a-zA-Z0-9+]+);/i.exec(dataUrl)?.[1]?.toLowerCase() ?? 'png'
536
+ const imageType = mime === 'jpg' ? 'jpeg'
537
+ : mime === 'svg+xml' ? 'svg'
538
+ : mime
539
+ return {
540
+ id: imageIdFor(dataUrl),
541
+ base64: dataUrl,
542
+ imageType,
543
+ width,
544
+ height,
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Build the header / footer arrays the Smart exporter understands.
550
+ * Each input line becomes one full-width row in the spreadsheet with
551
+ * `style.mergeAcross` so the line spans every column.
552
+ *
553
+ * Image lines are written as a data-URL string into the first column and
554
+ * the wrapper's `addImageToCell` hook embeds them as real picture cells.
555
+ */
556
+ function buildContentRows(
557
+ lines: ReadonlyArray<ExportHeaderFooterLine> | undefined,
558
+ datafields: ReadonlyArray<string>,
559
+ ): Array<{ cells: Record<string, unknown>; style?: Record<string, unknown> }> | undefined {
560
+ if (!lines || lines.length === 0 || datafields.length === 0) return undefined
561
+ const first = datafields[0]!
562
+ const out: Array<{ cells: Record<string, unknown>; style?: Record<string, unknown> }> = []
563
+ for (const line of lines) {
564
+ const cells: Record<string, unknown> = {}
565
+ let style: Record<string, unknown> | undefined
566
+ if ('image' in line) {
567
+ cells[first] = line.image
568
+ style = { mergeAcross: true, textAlign: 'left' }
569
+ } else if ('text' in line) {
570
+ cells[first] = line.text
571
+ style = { mergeAcross: true, ...(line.style ?? {}) }
572
+ } else {
573
+ // 3-column { left, center, right }. Distribute across the first,
574
+ // middle, and last datafield.
575
+ const mid = datafields[Math.floor(datafields.length / 2)]!
576
+ const last = datafields[datafields.length - 1]!
577
+ if (line.left !== undefined) cells[first] = line.left
578
+ if (line.center !== undefined) cells[mid] = line.center
579
+ if (line.right !== undefined) cells[last] = line.right
580
+ style = { textAlign: 'left' }
581
+ }
582
+ out.push({ cells, style })
583
+ }
584
+ return out
585
+ }
586
+
587
+ /**
588
+ * Build an `addImageToCell` handler that embeds a value as a real image
589
+ * cell whenever the value is a data URL. Smart calls this for every body
590
+ * cell AND every header/footer cell, so the same handler covers both
591
+ * code paths.
592
+ *
593
+ * `bodyImageSize` controls the embedded pixel size for body cells (the
594
+ * thumbnails). Header/footer image lines use their own size hint
595
+ * already encoded in the `cells[first]` value's metadata - here we just
596
+ * embed with a slightly larger default so logos read well in the page
597
+ * banner.
598
+ */
599
+ function buildImageHandler(
600
+ imageFields: ReadonlyArray<string> | undefined,
601
+ bodyImageSize: { width: number; height: number },
602
+ ): NonNullable<SmartDataExporterInstance['addImageToCell']> {
603
+ const allowed = imageFields && imageFields.length > 0 ? new Set(imageFields) : null
604
+ return (_rowIndex, dataField, value) => {
605
+ if (typeof value !== 'string') return null
606
+ if (!/^data:image\//i.test(value)) return null
607
+ if (allowed && !allowed.has(dataField)) {
608
+ // Header/footer image lines come through the first column too -
609
+ // those cells aren't in `imageFields`, but the wrapper wrote the
610
+ // data URL there itself. Embed bigger (logo banner).
611
+ return { image: makeSmartImage(value, 96, 96), value: '' }
612
+ }
613
+ return { image: makeSmartImage(value, bodyImageSize.width, bodyImageSize.height), value: '' }
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Build the constructor-options object passed to the Smart DataExporter.
619
+ * Constructor only consumes a small set of keys; instance-level options
620
+ * (`headerContent`, `footerContent`, `addImageToCell`) are applied
621
+ * separately in {@link exportGrid} after `new DataExporter(...)`.
622
+ */
623
+ function buildExporterOptions<TData>(
624
+ opts: ExportOptions<TData>,
625
+ ): Record<string, unknown> {
626
+ const out: Record<string, unknown> = {
627
+ exportHeader: true,
628
+ pageOrientation: opts.pageOrientation ?? 'portrait',
629
+ }
630
+ // Freeze the header row (and optional leading columns) so they stay in view
631
+ // when scrolling the xlsx. On by default.
632
+ if (opts.freezeHeader !== false) out.freezeHeader = true
633
+ if (opts.freezeColumns && opts.freezeColumns > 0) {
634
+ out.freezeColumnsCount = Math.floor(opts.freezeColumns)
635
+ }
636
+ const style = translateStyles(opts.styles)
637
+ if (style) out.style = style
638
+ // Smart's xlsx writer emits Excel-native row outlining when
639
+ // `hierarchical: true` is set on the constructor options OR when the
640
+ // second constructor arg is a non-empty groupBy array. We thread
641
+ // hierarchical through here and groupBy through the constructor call
642
+ // site in exportGrid.
643
+ if (opts.hierarchical) out.hierarchical = true
644
+ // Merged cells: translate the friendly {row, col, rowSpan, colSpan} shape to
645
+ // Smart's native {cell: [row, col], rowspan, colspan}. Skip 1x1 "merges"
646
+ // (nothing to span). Ignored when grouping/hierarchy owns the row layout.
647
+ if (opts.merges?.length && !opts.groupBy?.length && !opts.hierarchical) {
648
+ const mergedCells = opts.merges
649
+ .map((m) => ({
650
+ cell: [m.row, m.col],
651
+ rowspan: Math.max(1, Math.floor(m.rowSpan ?? 1)),
652
+ colspan: Math.max(1, Math.floor(m.colSpan ?? 1)),
653
+ }))
654
+ .filter((m) => m.rowspan > 1 || m.colspan > 1)
655
+ if (mergedCells.length) out.mergedCells = mergedCells
656
+ }
657
+ return out
658
+ }
659
+
660
+ /**
661
+ * Apply instance-level options the constructor does not consume.
662
+ * Mutates and returns the exporter for fluent use.
663
+ */
664
+ function applyInstanceOptions<TData>(
665
+ exporter: SmartDataExporterInstance,
666
+ opts: ExportOptions<TData>,
667
+ datafields: ReadonlyArray<string>,
668
+ ): SmartDataExporterInstance {
669
+ const headerRows = buildContentRows(opts.header, datafields)
670
+ const footerRows = buildContentRows(opts.footer, datafields)
671
+ if (headerRows) exporter.headerContent = headerRows
672
+ if (footerRows) exporter.footerContent = footerRows
673
+
674
+ // Wire image embedding when the caller opts in via imageFields OR the
675
+ // header/footer carries an image line. Both code paths funnel through
676
+ // the same handler.
677
+ const hasHeaderOrFooterImage =
678
+ (opts.header?.some((l) => 'image' in l) ?? false) ||
679
+ (opts.footer?.some((l) => 'image' in l) ?? false)
680
+ if (opts.imageFields?.length || hasHeaderOrFooterImage) {
681
+ const bodySize = opts.imageSize ?? { width: 32, height: 32 }
682
+ exporter.addImageToCell = buildImageHandler(opts.imageFields, bodySize)
683
+ }
684
+ return exporter
685
+ }
686
+
687
+ /** Append `.ext` to a filename that doesn't already carry it. */
688
+ function ensureExt(name: string, ext: string): string {
689
+ return name.toLowerCase().endsWith('.' + ext) ? name : `${name}.${ext}`
690
+ }
691
+
692
+ /** Download the blob (unless `download === false`) and return the result. */
693
+ function deliver(
694
+ blob: Blob,
695
+ filename: string,
696
+ mime: string,
697
+ rowCount: number,
698
+ download: boolean | undefined,
699
+ ): ExportResult {
700
+ if (download !== false) downloadBlobFile(blob, filename)
701
+ return { blob, filename, mime, rowCount, byteSize: blob.size }
702
+ }
703
+
704
+ /** Text-file variant: download via `downloadTextFile`, also return the blob. */
705
+ function deliverText(
706
+ text: string,
707
+ filename: string,
708
+ mime: string,
709
+ rowCount: number,
710
+ download: boolean | undefined,
711
+ ): ExportResult {
712
+ if (download !== false) downloadTextFile(text, filename, mime)
713
+ const blob = new Blob([text], { type: mime })
714
+ return { blob, filename, mime, rowCount, byteSize: blob.size }
715
+ }
716
+
717
+ /**
718
+ * Estimate a per-column pixel width from the header + a sample of cell values,
719
+ * for xlsx / xls auto-fit. A column's explicit `width` always wins. ~7px per
720
+ * character, clamped to a sensible range.
721
+ */
722
+ function autoFitWidths<TData extends RowData>(
723
+ cols: ReadonlyArray<ExportColumn<TData>>,
724
+ rows: ReadonlyArray<TData>,
725
+ rawValues: boolean,
726
+ ): number[] {
727
+ const SAMPLE = Math.min(rows.length, 200)
728
+ return cols.map((c) => {
729
+ if (typeof c.width === 'number') return c.width
730
+ let maxChars = String(c.header ?? c.field).length
731
+ for (let i = 0; i < SAMPLE; i++) {
732
+ const v = cellValueFor(c, rows[i]!, rawValues)
733
+ const len = v == null ? 0 : String(v).length
734
+ if (len > maxChars) maxChars = len
735
+ }
736
+ return Math.max(48, Math.min(400, maxChars * 7 + 14))
737
+ })
738
+ }
739
+
740
+ /**
741
+ * Freeze-column count: explicit `freezeColumns` wins; otherwise derive it from
742
+ * the grid's left-pinned columns that lead the export column order. Silent
743
+ * (returns undefined) when neither applies.
744
+ */
745
+ function resolveFreezeColumns<TFeatures extends TableFeatures, TData extends RowData>(
746
+ api: SvGridApi<TFeatures, TData>,
747
+ opts: Pick<ExportOptions<TData>, 'freezeColumns'>,
748
+ cols: ReadonlyArray<ExportColumn<TData>>,
749
+ ): number | undefined {
750
+ if (typeof opts.freezeColumns === 'number') return opts.freezeColumns
751
+ const left = api.getColumnPinning().left
752
+ if (!left || left.length === 0) return undefined
753
+ const pinnedSet = new Set(left)
754
+ // Count leading export columns that are pinned-left (by field id).
755
+ let n = 0
756
+ for (const c of cols) {
757
+ if (pinnedSet.has(c.field)) n++
758
+ else break
759
+ }
760
+ return n > 0 ? n : undefined
761
+ }
762
+
763
+ /** Excel column letter for a 0-based column index (0 -> A, 26 -> AA). */
764
+ function colLetter(index: number): string {
765
+ let n = index
766
+ let s = ''
767
+ do {
768
+ s = String.fromCharCode(65 + (n % 26)) + s
769
+ n = Math.floor(n / 26) - 1
770
+ } while (n >= 0)
771
+ return s
772
+ }
773
+
774
+ /** A `(rowIdx, colIdx) => url` resolver from column `link` hooks, or undefined
775
+ * when no column defines links. */
776
+ export function buildLinkResolver<TData extends RowData>(
777
+ cols: ReadonlyArray<ExportColumn<TData>>,
778
+ rows: ReadonlyArray<TData>,
779
+ ): ((rowIdx: number, colIdx: number) => string | undefined) | undefined {
780
+ if (!cols.some((c) => c.link)) return undefined
781
+ return (rowIdx, colIdx) => {
782
+ const col = cols[colIdx]
783
+ const row = rows[rowIdx]
784
+ if (!col?.link || !row) return undefined
785
+ return col.link(row) || undefined
786
+ }
787
+ }
788
+
789
+ /** Build a `field -> align` map from resolved columns (for html / md). */
790
+ function alignMap<TData extends RowData>(
791
+ cols: ReadonlyArray<ExportColumn<TData>>,
792
+ ): Record<string, 'left' | 'center' | 'right'> {
793
+ const align: Record<string, 'left' | 'center' | 'right'> = {}
794
+ for (const c of cols) if (c.align) align[c.field] = c.align
795
+ return align
796
+ }
797
+
798
+ /**
799
+ * Serialize projected rows to the requested native format string. Shared by
800
+ * file export and clipboard export. Returns `{ text, mime, ext }`.
801
+ */
802
+ async function serializeNative<TData extends RowData>(
803
+ format: ExportFormat,
804
+ projected: ReadonlyArray<Record<string, unknown>>,
805
+ cols: ReadonlyArray<ExportColumn<TData>>,
806
+ opts: Pick<ExportOptions<TData>, 'onProgress' | 'signal' | 'csv' | 'filename'>,
807
+ cellStyle?: ExportCellVisualFn,
808
+ cellLink?: (rowIdx: number, colIdx: number) => string | undefined,
809
+ ): Promise<{ text: string; mime: string; ext: string }> {
810
+ const fields = cols.map((c) => c.field)
811
+ const p = { onProgress: opts.onProgress, signal: opts.signal }
812
+ switch (format) {
813
+ case 'html': {
814
+ const text = await serializeHtml(projected, fields, {
815
+ ...p,
816
+ title: (opts.filename ?? 'grid').trim() || 'grid',
817
+ align: alignMap(cols),
818
+ cellStyle,
819
+ cellLink,
820
+ })
821
+ return { text, mime: 'text/html;charset=utf-8', ext: 'html' }
822
+ }
823
+ case 'json': {
824
+ const text = await serializeJson(projected, fields, p)
825
+ return { text, mime: 'application/json;charset=utf-8', ext: 'json' }
826
+ }
827
+ case 'xml': {
828
+ const text = await serializeXml(projected, fields, p)
829
+ return { text, mime: 'application/xml;charset=utf-8', ext: 'xml' }
830
+ }
831
+ case 'md': {
832
+ const text = await serializeMarkdown(projected, fields, { ...p, align: alignMap(cols) })
833
+ return { text, mime: 'text/markdown;charset=utf-8', ext: 'md' }
834
+ }
835
+ case 'tsv': {
836
+ const text = await serializeDelimited(projected, fields, { ...p, csv: { ...opts.csv, delimiter: '\t' } })
837
+ return { text, mime: 'text/tab-separated-values;charset=utf-8', ext: 'tsv' }
838
+ }
839
+ case 'csv':
840
+ default: {
841
+ const delimiter = opts.csv?.delimiter ?? ','
842
+ const text = await serializeDelimited(projected, fields, { ...p, csv: { ...opts.csv, delimiter } })
843
+ return { text, mime: 'text/csv;charset=utf-8', ext: 'csv' }
844
+ }
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Native text export (csv / tsv / html / json / xml / md). No Smart exporter,
850
+ * no peer deps - pure streaming serialization with progress + abort. Values
851
+ * are formatted to match the on-screen display (json defaults to raw so it
852
+ * stays machine-readable).
853
+ */
854
+ async function exportTextFormat<
855
+ TFeatures extends TableFeatures,
856
+ TData extends RowData,
857
+ >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<ExportResult> {
858
+ if (opts.sheets && opts.sheets.length > 0) {
859
+ throw new Error(
860
+ `@svgrid/enterprise: multi-sheet export requires format 'xlsx', got '${opts.format}'`,
861
+ )
862
+ }
863
+ const sourceRows = resolveRowSource(api, opts.rows)
864
+ if (sourceRows.length === 0) {
865
+ throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
866
+ }
867
+ const cols = resolveColumns(api, opts, sourceRows)
868
+ // JSON is a data format: default to raw values so consumers get real
869
+ // numbers/dates, not "$19.95" strings. The other formats default formatted.
870
+ const projectOpts =
871
+ opts.format === 'json' ? { ...opts, rawValues: opts.rawValues ?? true } : opts
872
+ const projected = await projectRows(sourceRows, cols, projectOpts)
873
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
874
+
875
+ // HTML carries conditional formatting (fill / color / bold / icon); the other
876
+ // native formats are unstyled data.
877
+ const cellStyle =
878
+ opts.format === 'html'
879
+ ? buildConditionalResolver(cols, sourceRows, opts.conditionalFormats)
880
+ : undefined
881
+ const cellLink = opts.format === 'html' ? buildLinkResolver(cols, sourceRows) : undefined
882
+ const { text, mime, ext } = await serializeNative(opts.format, projected, cols, opts, cellStyle, cellLink)
883
+ return deliverText(text, ensureExt(filename, ext), mime, sourceRows.length, opts.download)
884
+ }
885
+
886
+ /**
887
+ * Legacy `.xls` export (Excel 2003 XML Spreadsheet). Dependency-free and
888
+ * streaming, like the text path, but produces a real typed spreadsheet.
889
+ */
890
+ async function exportXls<
891
+ TFeatures extends TableFeatures,
892
+ TData extends RowData,
893
+ >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<ExportResult> {
894
+ if (opts.sheets && opts.sheets.length > 0) {
895
+ throw new Error(
896
+ "@svgrid/enterprise: multi-sheet export requires format 'xlsx'. Use 'xls' for a single legacy sheet.",
897
+ )
898
+ }
899
+ const sourceRows = resolveRowSource(api, opts.rows)
900
+ if (sourceRows.length === 0) {
901
+ throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
902
+ }
903
+ const cols = resolveColumns(api, opts, sourceRows)
904
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
905
+ const visual = buildConditionalResolver(cols, sourceRows, opts.conditionalFormats)
906
+ const xml = await serializeSpreadsheetML(filename, cols, sourceRows, {
907
+ rawValues: opts.rawValues,
908
+ onProgress: opts.onProgress,
909
+ signal: opts.signal,
910
+ freezeHeader: opts.freezeHeader !== false,
911
+ freezeColumns: resolveFreezeColumns(api, opts, cols),
912
+ widths: opts.autoFitColumns !== false ? autoFitWidths(cols, sourceRows, opts.rawValues === true) : undefined,
913
+ cellVisual: opts.conditionalFormats?.length ? visual : undefined,
914
+ precisionSafe: opts.precisionSafe,
915
+ })
916
+ const mime = 'application/vnd.ms-excel;charset=utf-8'
917
+ return deliverText(xml, ensureExt(filename, 'xls'), mime, sourceRows.length, opts.download)
918
+ }
919
+
920
+ /** Columns whose values can be summed for a PDF subtotal row. */
921
+ function isSummableColumn<TData extends RowData>(col: ExportColumn<TData>): boolean {
922
+ return col.format?.type === 'number' || col.format?.type === 'currency'
923
+ }
924
+
925
+ /**
926
+ * Build a grouped PDF body: a bold group header per cluster (nested for
927
+ * multi-level grouping), the cluster's data rows, then a subtotal row summing
928
+ * the numeric / currency columns. Values use the same formatting as the grid.
929
+ */
930
+ export function buildGroupedPdfBody<TData extends RowData>(
931
+ cols: ReadonlyArray<ExportColumn<TData>>,
932
+ rows: ReadonlyArray<TData>,
933
+ groupBy: ReadonlyArray<string>,
934
+ ): PdfBodyRow[] {
935
+ const out: PdfBodyRow[] = []
936
+ const headerOf = (field: string) => cols.find((c) => c.field === field)?.header ?? field
937
+ // Put the "Subtotal" label in the first NON-numeric column; if every column
938
+ // is numeric, -1 means "don't overwrite a sum with the label".
939
+ const labelColIdx = cols.findIndex((c) => !isSummableColumn(c))
940
+
941
+ const recurse = (subset: ReadonlyArray<TData>, level: number) => {
942
+ if (level >= groupBy.length) {
943
+ for (const r of subset) {
944
+ out.push({ kind: 'data', cells: cols.map((c) => String(cellValueFor(c, r, false) ?? '')) })
945
+ }
946
+ return
947
+ }
948
+ const field = groupBy[level]!
949
+ // Preserve first-seen order of group keys.
950
+ const buckets = new Map<string, TData[]>()
951
+ for (const r of subset) {
952
+ const key = String((r as Record<string, unknown>)[field] ?? '')
953
+ const bucket = buckets.get(key)
954
+ if (bucket) bucket.push(r)
955
+ else buckets.set(key, [r])
956
+ }
957
+ for (const [key, groupRows] of buckets) {
958
+ out.push({ kind: 'group', label: `${headerOf(field)}: ${key} (${groupRows.length})`, level })
959
+ recurse(groupRows, level + 1)
960
+ // Subtotal row for the numeric columns of this cluster.
961
+ const hasSummable = cols.some(isSummableColumn)
962
+ if (hasSummable) {
963
+ const cells = cols.map((c) => {
964
+ if (!isSummableColumn(c)) return ''
965
+ const sum = groupRows.reduce(
966
+ (a, r) => a + (Number((r as Record<string, unknown>)[c.field]) || 0),
967
+ 0,
968
+ )
969
+ return formatValueForExport(sum, c.format)
970
+ })
971
+ if (labelColIdx >= 0) cells[labelColIdx] = `Subtotal (${key})`
972
+ out.push({ kind: 'subtotal', cells })
973
+ }
974
+ }
975
+ }
976
+ recurse(rows, 0)
977
+ return out
978
+ }
979
+
980
+ /**
981
+ * PDF export via our own pdfmake docDefinition (page setup, repeated header,
982
+ * page numbers, per-column alignment/widths, title/logo, theme colors, and
983
+ * group + subtotal rows carried from the grid's grouping). Values are the
984
+ * formatted display strings, so the PDF matches the grid.
985
+ */
986
+ async function exportPdf<
987
+ TFeatures extends TableFeatures,
988
+ TData extends RowData,
989
+ >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<ExportResult> {
990
+ await ensureGlobals('pdf')
991
+ const pdfMake = (globalThis as unknown as {
992
+ pdfMake?: {
993
+ createPdf: (def: unknown) => {
994
+ download(name: string): void
995
+ getBlob(cb: (b: Blob) => void): void
996
+ }
997
+ }
998
+ }).pdfMake
999
+ if (!pdfMake) {
1000
+ throw new Error('@svgrid/enterprise: pdf export requires the "pdfmake" peer dependency.')
1001
+ }
1002
+ const sourceRows = resolveRowSource(api, opts.rows)
1003
+ if (sourceRows.length === 0) {
1004
+ throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
1005
+ }
1006
+ const cols = resolveColumns(api, opts, sourceRows)
1007
+ const fields = cols.map((c) => c.field)
1008
+ const columns = cols.map((c) => ({ header: c.header ?? c.field, align: c.align }))
1009
+
1010
+ // Carry the grid's active grouping into group + subtotal rows (unless the
1011
+ // caller opted out or passed hierarchical), matching the xlsx outline path.
1012
+ const autoGrouping =
1013
+ opts.autoGroup !== false && !opts.groupBy?.length && !opts.hierarchical
1014
+ ? (api.getState().grouping ?? []).filter((id) => !!id).map((id) => String(id))
1015
+ : []
1016
+ const effGroupBy = opts.groupBy?.length
1017
+ ? opts.groupBy
1018
+ : autoGrouping.length > 0
1019
+ ? autoGrouping
1020
+ : undefined
1021
+
1022
+ if (opts.signal?.aborted) throw new DOMException('Export aborted', 'AbortError')
1023
+ opts.onProgress?.({ phase: 'write', ratio: 0, total: sourceRows.length })
1024
+
1025
+ const pdfOpts: PdfExportOptions = {
1026
+ ...opts.pdf,
1027
+ pageOrientation: opts.pdf?.pageOrientation ?? opts.pageOrientation,
1028
+ }
1029
+ const now = new Date()
1030
+ let def
1031
+ if (effGroupBy?.length) {
1032
+ // Conditional formatting is skipped for grouped PDFs (group / subtotal
1033
+ // rows own the layout); the grid colors still show on screen.
1034
+ const body = buildGroupedPdfBody(cols, sourceRows, effGroupBy)
1035
+ def = buildPdfDocDefinition({ columns, body, opts: pdfOpts, now })
1036
+ } else {
1037
+ const projected = await projectRows(sourceRows, cols, opts)
1038
+ const dataRows = projected.slice(1).map((r) => fields.map((f) => String(r[f] ?? '')))
1039
+ // Conditional formatting -> per-cell fill / color / bold + icon prefix.
1040
+ const visual = buildConditionalResolver(cols, sourceRows, opts.conditionalFormats)
1041
+ for (let r = 0; r < dataRows.length; r++) {
1042
+ for (let c = 0; c < cols.length; c++) {
1043
+ const icon = visual(r, c)?.icon
1044
+ if (icon) dataRows[r]![c] = `${icon} ${dataRows[r]![c] ?? ''}`
1045
+ }
1046
+ }
1047
+ const link = buildLinkResolver(cols, sourceRows)
1048
+ def = buildPdfDocDefinition({
1049
+ columns,
1050
+ rows: dataRows,
1051
+ dataCellStyle: (r, c) => {
1052
+ const v = visual(r, c)
1053
+ return v ? { fill: v.fill, color: v.color, bold: v.bold } : undefined
1054
+ },
1055
+ dataCellLink: link,
1056
+ opts: pdfOpts,
1057
+ now,
1058
+ })
1059
+ }
1060
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
1061
+ const blob = await new Promise<Blob>((resolve) => pdfMake.createPdf(def).getBlob(resolve))
1062
+ opts.onProgress?.({ phase: 'write', ratio: 1, total: sourceRows.length })
1063
+ return deliver(blob, ensureExt(filename, 'pdf'), 'application/pdf', sourceRows.length, opts.download)
1064
+ }
1065
+
1066
+ /**
1067
+ * Resolve one cell into the typed descriptor the native xlsx writer needs:
1068
+ * real numbers / dates with a number format (Excel sums + sorts), or a
1069
+ * formatted string, plus conditional-format fill / color / bold / icon and a
1070
+ * hyperlink.
1071
+ */
1072
+ async function buildXlsxCellMatrix<TData extends RowData>(
1073
+ cols: ReadonlyArray<ExportColumn<TData>>,
1074
+ rows: ReadonlyArray<TData>,
1075
+ rawValues: boolean,
1076
+ visual: ExportCellVisualFn,
1077
+ link: ((rowIdx: number, colIdx: number) => string | undefined) | undefined,
1078
+ opts?: { onProgress?: (p: ExportProgress) => void; signal?: AbortSignal; precisionSafe?: boolean },
1079
+ ): Promise<{ header: string[]; cells: XlsxCell[][] }> {
1080
+ const header = cols.map((c) => c.header ?? c.field)
1081
+ const precisionSafe = opts?.precisionSafe === true
1082
+ // Excel stores numbers as 64-bit floats (~15 significant digits); a longer
1083
+ // integer (an ID) would be silently rounded. In precision-safe mode we write
1084
+ // such values as text so they survive intact.
1085
+ const num = (value: number, base: object, numFmt?: string): XlsxCell =>
1086
+ precisionSafe && Math.abs(value) >= 1e15
1087
+ ? { t: 's', value: String(value), ...base }
1088
+ : { t: 'n', value, numFmt, ...base }
1089
+
1090
+ const cellFor = (row: TData, r: number, c: number): XlsxCell => {
1091
+ const col = cols[c]!
1092
+ const v = visual(r, c)
1093
+ const base = { align: col.align, fill: v?.fill, color: v?.color, bold: v?.bold, link: link?.(r, c) }
1094
+ if (col.exportValue) {
1095
+ const ev = col.exportValue(row)
1096
+ if (typeof ev === 'number' && Number.isFinite(ev)) return num(ev, base, toExcelNumFmt(col.format))
1097
+ if (ev instanceof Date) return { t: 'd', value: ev, numFmt: toExcelNumFmt(col.format) ?? 'yyyy-mm-dd', ...base }
1098
+ return { t: 's', value: ev == null ? '' : String(ev), ...base }
1099
+ }
1100
+ const raw = (row as Record<string, unknown>)[col.field]
1101
+ // An icon-set glyph forces a string cell (icon + formatted value).
1102
+ if (v?.icon) {
1103
+ const disp = rawValues ? String(raw ?? '') : formatValueForExport(raw, col.format)
1104
+ return { t: 's', value: `${v.icon} ${disp}`, ...base }
1105
+ }
1106
+ if (rawValues) {
1107
+ if (typeof raw === 'number' && Number.isFinite(raw)) return num(raw, base)
1108
+ return { t: 's', value: raw == null ? '' : String(raw), ...base }
1109
+ }
1110
+ const typed = valueForExcel(raw, col.format)
1111
+ if (typed.ok) {
1112
+ if (typed.value instanceof Date) return { t: 'd', value: typed.value, numFmt: toExcelNumFmt(col.format) ?? 'yyyy-mm-dd', ...base }
1113
+ return num(typed.value, base, toExcelNumFmt(col.format))
1114
+ }
1115
+ return { t: 's', value: formatValueForExport(raw, col.format), ...base }
1116
+ }
1117
+
1118
+ // Build in chunks, yielding to the event loop so a huge export doesn't freeze
1119
+ // the tab (the per-cell Intl formatting is the expensive part).
1120
+ const cells: XlsxCell[][] = []
1121
+ const total = rows.length
1122
+ const CHUNK = 2000
1123
+ for (let r = 0; r < total; r++) {
1124
+ if (opts?.signal?.aborted) throw new DOMException('Export aborted', 'AbortError')
1125
+ const row = rows[r]!
1126
+ cells.push(cols.map((_, c) => cellFor(row, r, c)))
1127
+ if (r > 0 && r % CHUNK === 0) {
1128
+ opts?.onProgress?.({ phase: 'project', ratio: r / total, row: r, total })
1129
+ await new Promise((resolve) => setTimeout(resolve, 0))
1130
+ }
1131
+ }
1132
+ return { header, cells }
1133
+ }
1134
+
1135
+ /**
1136
+ * Native single-sheet xlsx export (our own OOXML writer). Typed numbers +
1137
+ * number formats, true per-cell conditional-format fills, freeze header,
1138
+ * auto-fit widths, and real hyperlinks - none of which the vendored writer can
1139
+ * do. Needs only `jszip`.
1140
+ */
1141
+ async function exportXlsxNative<
1142
+ TFeatures extends TableFeatures,
1143
+ TData extends RowData,
1144
+ >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<ExportResult> {
1145
+ await ensureGlobals('xlsx') // loads jszip onto globalThis
1146
+ const JSZip = (globalThis as unknown as { JSZip?: new () => never }).JSZip as unknown as
1147
+ | (new () => { file(p: string, d: string): void; generateAsync(o: { type: 'blob'; mimeType?: string }): Promise<Blob> })
1148
+ | undefined
1149
+ if (!JSZip) throw new Error('@svgrid/enterprise: xlsx export requires the "jszip" peer dependency.')
1150
+
1151
+ const src = resolveRowSource(api, opts.rows)
1152
+ if (src.length === 0) {
1153
+ throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
1154
+ }
1155
+ const cols = resolveColumns(api, opts, src)
1156
+ const rawValues = opts.rawValues === true
1157
+
1158
+ // Color scales + data bars become NATIVE Excel conditional formatting (Excel
1159
+ // computes them, so they're interactive). Predicate rules + icon sets stay
1160
+ // client-computed per cell. So the per-cell resolver only sees rule/iconSet.
1161
+ const cf = opts.conditionalFormats ?? []
1162
+ const cellCf = cf.filter((f) => f.type === 'rule' || f.type === 'iconSet')
1163
+ const visual = buildConditionalResolver(cols, src, cellCf)
1164
+ const link = buildLinkResolver(cols, src)
1165
+
1166
+ const condFormats: XlsxCondFormat[] = []
1167
+ for (const f of cf) {
1168
+ if (f.type !== 'colorScale' && f.type !== 'dataBar') continue
1169
+ const targetIdxs =
1170
+ f.columns && f.columns.length > 0
1171
+ ? cols.map((c, i) => (f.columns!.includes(c.field) ? i : -1)).filter((i) => i >= 0)
1172
+ : cols.map((_, i) => i)
1173
+ for (const ci of targetIdxs) {
1174
+ if (f.type === 'dataBar') condFormats.push({ kind: 'dataBar', colIdx: ci, color: f.color })
1175
+ else condFormats.push({ kind: 'colorScale', colIdx: ci, colors: f.mid ? [f.min, f.mid, f.max] : [f.min, f.max] })
1176
+ }
1177
+ }
1178
+
1179
+ if (opts.signal?.aborted) throw new DOMException('Export aborted', 'AbortError')
1180
+ opts.onProgress?.({ phase: 'write', ratio: 0, total: src.length })
1181
+ const { header, cells } = await buildXlsxCellMatrix(cols, src, rawValues, visual, link, {
1182
+ onProgress: opts.onProgress,
1183
+ signal: opts.signal,
1184
+ precisionSafe: opts.precisionSafe,
1185
+ })
1186
+ const widths = opts.autoFitColumns !== false ? autoFitWidths(cols, src, rawValues) : undefined
1187
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
1188
+
1189
+ // Excel Table (ListObject): filter dropdowns + banded rows, and optionally a
1190
+ // totals row with SUBTOTAL formulas over the summable columns.
1191
+ let table: XlsxTableSpec | undefined
1192
+ if (opts.excelTable) {
1193
+ const totalsRow = typeof opts.excelTable === 'object' ? opts.excelTable.totalsRow === true : false
1194
+ const style = typeof opts.excelTable === 'object' ? opts.excelTable.style : undefined
1195
+ table = {
1196
+ name: 'Table1',
1197
+ totalsRow,
1198
+ style,
1199
+ totalsFns: cols.map((c) => (isSummableColumn(c) ? 'sum' : undefined)),
1200
+ }
1201
+ }
1202
+
1203
+ const parts = buildXlsxParts({
1204
+ sheetName: filename,
1205
+ header,
1206
+ rows: cells,
1207
+ widths,
1208
+ freezeHeader: opts.freezeHeader !== false,
1209
+ freezeColumns: resolveFreezeColumns(api, opts, cols),
1210
+ condFormats,
1211
+ table,
1212
+ })
1213
+ const blob = await packageXlsx(parts, JSZip)
1214
+ opts.onProgress?.({ phase: 'write', ratio: 1, total: src.length })
1215
+ return deliver(blob, ensureExt(filename, 'xlsx'), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', src.length, opts.download)
1216
+ }
1217
+
1218
+ export async function exportGrid<
1219
+ TFeatures extends TableFeatures,
1220
+ TData extends RowData,
1221
+ >(api: SvGridApi<TFeatures, TData>, opts: ExportOptions<TData>): Promise<ExportResult | undefined> {
1222
+ assertEnterpriseLicensed('Export')
1223
+
1224
+ // Native text formats have their own dependency-free, streaming path.
1225
+ if (NATIVE_TEXT_FORMATS.includes(opts.format)) {
1226
+ return exportTextFormat(api, opts)
1227
+ }
1228
+ // Legacy xls (SpreadsheetML) is also dependency-free.
1229
+ if (opts.format === 'xls') {
1230
+ return exportXls(api, opts)
1231
+ }
1232
+ // PDF via our own pdfmake docDefinition.
1233
+ if (opts.format === 'pdf') {
1234
+ return exportPdf(api, opts)
1235
+ }
1236
+
1237
+ // Native single-sheet xlsx (our OOXML writer): typed numbers + number
1238
+ // formats, real per-cell conditional-format fills, freeze, widths, and
1239
+ // hyperlinks. Grouping (Excel outline), multi-sheet, images, merges, and
1240
+ // header/footer lines keep the vendored writer below.
1241
+ if (opts.format === 'xlsx') {
1242
+ const gridGrouping =
1243
+ opts.autoGroup !== false ? (api.getState().grouping ?? []).filter((id) => !!id) : []
1244
+ const grouped = (opts.groupBy?.length ?? 0) > 0 || gridGrouping.length > 0
1245
+ // Blanket `styles` (header fill / zebra) are applied by the vendored
1246
+ // writer, not our OOXML one - so a styled export (without conditional
1247
+ // formatting, which the native writer does render) keeps the Smart path.
1248
+ const wantsBlanketStyles = !!opts.styles && !opts.conditionalFormats?.length
1249
+ if (
1250
+ !opts.sheets?.length &&
1251
+ !grouped &&
1252
+ !opts.hierarchical &&
1253
+ !opts.merges?.length &&
1254
+ !opts.header?.length &&
1255
+ !opts.footer?.length &&
1256
+ !opts.imageFields?.length &&
1257
+ !wantsBlanketStyles
1258
+ ) {
1259
+ return exportXlsxNative(api, opts)
1260
+ }
1261
+ }
1262
+
1263
+ await ensureGlobals(opts.format)
1264
+ const Ctor = await getDataExporter()
1265
+
1266
+ // Multi-sheet path. Smart's `spreadsheets` option takes an array of
1267
+ // `{ label, dataSource, columns, dataFields? }`. Each entry becomes one
1268
+ // tab; the exporter prepends its own header row built from `columns`.
1269
+ if (opts.sheets && opts.sheets.length > 0) {
1270
+ if (opts.format !== 'xlsx') {
1271
+ throw new Error(`@svgrid/enterprise: multi-sheet export requires format 'xlsx', got '${opts.format}'`)
1272
+ }
1273
+ const rawValues = opts.rawValues === true
1274
+ const sheets = opts.sheets.map((sheet) => {
1275
+ // Reuse the single-sheet column derivation (explicit → grid columns →
1276
+ // row keys), so per-sheet columns pick up `format` for formatting too.
1277
+ const cols = resolveColumns(
1278
+ api,
1279
+ { columns: sheet.columns ?? opts.columns },
1280
+ sheet.rows,
1281
+ )
1282
+ // Project each row to only the included fields (in column order),
1283
+ // applying per-column formatting / exportValue.
1284
+ const dataSource = sheet.rows.map((r) => {
1285
+ const out: Record<string, unknown> = {}
1286
+ for (const c of cols) out[c.field] = cellValueFor(c, r, rawValues)
1287
+ return out
1288
+ })
1289
+ const widths = opts.autoFitColumns !== false ? autoFitWidths(cols, sheet.rows, rawValues) : null
1290
+ return {
1291
+ label: sheet.label,
1292
+ dataSource,
1293
+ columns: cols.map((c, i) =>
1294
+ widths
1295
+ ? { dataField: c.field, label: c.header ?? c.field, width: widths[i] }
1296
+ : { dataField: c.field, label: c.header ?? c.field },
1297
+ ),
1298
+ dataFields: cols.map((c) => c.field),
1299
+ style: translateStyles(sheet.styles ?? opts.styles),
1300
+ }
1301
+ })
1302
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
1303
+ const ctorOpts = buildExporterOptions(opts)
1304
+ // Smart needs at least one main sheet to operate. Use the first sheet
1305
+ // as the "main" exportData payload AND keep the full set on the
1306
+ // `spreadsheets` property so the workbook ends up with one tab per
1307
+ // entry.
1308
+ const firstSheet = sheets[0]!
1309
+ const exporter = new Ctor(ctorOpts)
1310
+ exporter.spreadsheets = sheets
1311
+ applyInstanceOptions(exporter, opts, firstSheet.dataFields)
1312
+ // First sheet's data goes through the standard exportData path with
1313
+ // its own header row.
1314
+ const headerRow: Record<string, unknown> = {}
1315
+ for (const c of firstSheet.columns) headerRow[c.dataField] = c.label
1316
+ const firstSheetRows = [headerRow, ...firstSheet.dataSource]
1317
+ if (opts.download === false) {
1318
+ throw new Error('@svgrid/enterprise: download:false is not supported for multi-sheet xlsx.')
1319
+ }
1320
+ exporter.exportData(firstSheetRows, opts.format, filename)
1321
+ return undefined
1322
+ }
1323
+
1324
+ // Single-sheet xlsx (grouped / merges / header / footer) path.
1325
+ const sourceRows = resolveRowSource(api, opts.rows)
1326
+ if (sourceRows.length === 0) {
1327
+ throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows')
1328
+ }
1329
+ const cols = resolveColumns(api, opts, sourceRows)
1330
+ const rows = await projectRows(sourceRows, cols, opts)
1331
+ const filename = (opts.filename ?? 'grid').trim() || 'grid'
1332
+ const datafields = cols.map((c) => c.field)
1333
+
1334
+ // Auto-carry the grid's active row grouping (unless the caller opted out,
1335
+ // passed their own groupBy, or is exporting a hierarchy). Grouping column
1336
+ // ids line up with field names, which are the projected row keys.
1337
+ const autoGrouping =
1338
+ opts.autoGroup !== false && !opts.groupBy?.length && !opts.hierarchical
1339
+ ? (api.getState().grouping ?? []).filter((id) => !!id).map((id) => String(id))
1340
+ : []
1341
+ const effectiveGroupBy = opts.groupBy?.length
1342
+ ? opts.groupBy
1343
+ : autoGrouping.length > 0
1344
+ ? autoGrouping
1345
+ : undefined
1346
+ // The constructor's SECOND arg is `groupBy`: when non-empty AND
1347
+ // `hierarchical` is NOT set, Smart wraps each group of rows in an
1348
+ // Excel outline row with an expand/collapse button at the group key.
1349
+ const groupBy = !opts.hierarchical && effectiveGroupBy?.length ? effectiveGroupBy : undefined
1350
+
1351
+ // Conditional formatting -> a per-cell style map (A1 refs) that Smart's xlsx
1352
+ // writer applies, plus icon-set glyphs prepended to the cell text.
1353
+ const ctorOpts = buildExporterOptions(opts)
1354
+ if (opts.conditionalFormats?.length) {
1355
+ const visual = buildConditionalResolver(cols, sourceRows, opts.conditionalFormats)
1356
+ const cfCells: Record<string, ExportCellStyle> = {}
1357
+ for (let r = 0; r < sourceRows.length; r++) {
1358
+ for (let c = 0; c < cols.length; c++) {
1359
+ const v = visual(r, c)
1360
+ if (!v) continue
1361
+ const st: ExportCellStyle = {}
1362
+ if (v.fill) st.backgroundColor = v.fill
1363
+ if (v.color) st.color = v.color
1364
+ if (v.bold) st.fontWeight = 'bold'
1365
+ if (Object.keys(st).length) cfCells[colLetter(c) + (r + 2)] = st // header is row 1
1366
+ if (v.icon) {
1367
+ const dataRow = rows[r + 1] // header at index 0
1368
+ const field = cols[c]!.field
1369
+ if (dataRow) dataRow[field] = `${v.icon} ${dataRow[field] ?? ''}`
1370
+ }
1371
+ }
1372
+ }
1373
+ if (Object.keys(cfCells).length) {
1374
+ const style = (ctorOpts.style as Record<string, unknown> | undefined) ?? {}
1375
+ ctorOpts.style = {
1376
+ ...style,
1377
+ cells: { ...((style.cells as Record<string, unknown> | undefined) ?? {}), ...cfCells },
1378
+ }
1379
+ }
1380
+ }
1381
+
1382
+ if (opts.download === false) {
1383
+ throw new Error(
1384
+ '@svgrid/enterprise: download:false is not supported for this xlsx export (grouped / merges / header / footer / images). Use a plain xlsx, xls, pdf, or a text format.',
1385
+ )
1386
+ }
1387
+ if (opts.signal?.aborted) throw new DOMException('Export aborted', 'AbortError')
1388
+ opts.onProgress?.({ phase: 'write', ratio: 0, total: rows.length - 1 })
1389
+ const exporter = new Ctor(ctorOpts, groupBy)
1390
+ applyInstanceOptions(exporter, opts, datafields)
1391
+ exporter.exportData(rows, opts.format, filename)
1392
+ opts.onProgress?.({ phase: 'write', ratio: 1, total: rows.length - 1 })
1393
+ return undefined
1394
+ }
1395
+
1396
+ /** Native formats that can be placed on the clipboard. */
1397
+ export type ClipboardFormat = 'csv' | 'tsv' | 'html' | 'json' | 'xml' | 'md'
1398
+
1399
+ export type ClipboardExportOptions<TData> = {
1400
+ /** Clipboard payload format. Default 'tsv' (pastes cleanly into Excel / Sheets). */
1401
+ format?: ClipboardFormat
1402
+ columns?: ReadonlyArray<ExportColumn<TData>>
1403
+ rows?: ExportRowSource<TData>
1404
+ rawValues?: boolean
1405
+ csv?: CsvOptions
1406
+ onProgress?: (progress: ExportProgress) => void
1407
+ signal?: AbortSignal
1408
+ }
1409
+
1410
+ /** Write text (and, for html, rich text/html) to the clipboard. */
1411
+ async function writeClipboard(text: string, format: ClipboardFormat): Promise<void> {
1412
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined
1413
+ if (!nav?.clipboard) {
1414
+ throw new Error('@svgrid/enterprise: clipboard is unavailable (needs a secure context + user gesture)')
1415
+ }
1416
+ // For html, offer BOTH text/html (rich paste into Excel / Sheets / Docs) and
1417
+ // a text/plain fallback. ClipboardItem may be missing in older browsers.
1418
+ const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
1419
+ if (format === 'html' && CI && nav.clipboard.write) {
1420
+ const item = new CI({
1421
+ 'text/html': new Blob([text], { type: 'text/html' }),
1422
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
1423
+ })
1424
+ await nav.clipboard.write([item])
1425
+ return
1426
+ }
1427
+ await nav.clipboard.writeText(text)
1428
+ }
1429
+
1430
+ /**
1431
+ * Copy the grid (current view / selection / all) to the clipboard in a native
1432
+ * text format instead of downloading a file. Defaults to `tsv`, which pastes
1433
+ * straight into Excel / Google Sheets as real columns. `html` writes rich
1434
+ * `text/html` so pasted cells keep their table structure.
1435
+ */
1436
+ export async function copyExportToClipboard<
1437
+ TFeatures extends TableFeatures,
1438
+ TData extends RowData,
1439
+ >(api: SvGridApi<TFeatures, TData>, opts: ClipboardExportOptions<TData> = {}): Promise<void> {
1440
+ assertEnterpriseLicensed('Export')
1441
+ const format: ClipboardFormat = opts.format ?? 'tsv'
1442
+ const sourceRows = resolveRowSource(api, opts.rows)
1443
+ if (sourceRows.length === 0) {
1444
+ throw new Error('@svgrid/enterprise: nothing to copy - the grid has no rows')
1445
+ }
1446
+ const cols = resolveColumns(api, opts, sourceRows)
1447
+ const projectOpts =
1448
+ format === 'json' ? { ...opts, rawValues: opts.rawValues ?? true } : opts
1449
+ const projected = await projectRows(sourceRows, cols, projectOpts)
1450
+ const { text } = await serializeNative(format, projected, cols, opts)
1451
+ await writeClipboard(text, format)
1452
+ }