@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
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildPdfDocDefinition } from './export-pdf'
3
+
4
+ const columns = [
5
+ { header: 'Company', align: 'left' as const },
6
+ { header: 'Price', align: 'right' as const },
7
+ ]
8
+ const rows = [
9
+ ['ACME', '$19.95'],
10
+ ['Globex', '$1,000.00'],
11
+ ['Initech', '$42.00'],
12
+ ]
13
+ const now = new Date('2026-07-10T12:00:00Z')
14
+
15
+ describe('buildPdfDocDefinition', () => {
16
+ it('builds a table with a bold, filled, repeated header row', () => {
17
+ const def = buildPdfDocDefinition({ columns, rows, now })
18
+ const table = (def.content.at(-1) as any).table
19
+ expect(table.headerRows).toBe(1)
20
+ expect(table.body).toHaveLength(4) // header + 3 rows
21
+ const header = table.body[0]
22
+ expect(header[0]).toMatchObject({ text: 'Company', bold: true, fillColor: '#334155' })
23
+ expect(header[1].alignment).toBe('right')
24
+ })
25
+
26
+ it('carries per-column alignment into body cells', () => {
27
+ const def = buildPdfDocDefinition({ columns, rows, now })
28
+ const table = (def.content.at(-1) as any).table
29
+ expect(table.body[1][0]).toMatchObject({ text: 'ACME', alignment: 'left' })
30
+ expect(table.body[1][1]).toMatchObject({ text: '$19.95', alignment: 'right' })
31
+ })
32
+
33
+ it('zebra-stripes even body rows but not the header', () => {
34
+ const def = buildPdfDocDefinition({ columns, rows, now })
35
+ const layout = (def.content.at(-1) as any).layout
36
+ expect(layout.fillColor(0)).toBeNull() // header
37
+ expect(layout.fillColor(1)).toBeNull() // odd body row
38
+ expect(layout.fillColor(2)).toBe('#f1f5f9') // even body row
39
+ })
40
+
41
+ it('defaults A4 portrait, auto-landscape for wide grids', () => {
42
+ const narrow = buildPdfDocDefinition({ columns, rows, now })
43
+ expect(narrow.pageSize).toBe('A4')
44
+ expect(narrow.pageOrientation).toBe('portrait')
45
+
46
+ const wideCols = Array.from({ length: 10 }, (_, i) => ({ header: `C${i}` }))
47
+ const wide = buildPdfDocDefinition({ columns: wideCols, rows: [], now })
48
+ expect(wide.pageOrientation).toBe('landscape')
49
+
50
+ const forced = buildPdfDocDefinition({ columns: wideCols, rows: [], opts: { pageOrientation: 'portrait' }, now })
51
+ expect(forced.pageOrientation).toBe('portrait')
52
+ })
53
+
54
+ it('renders title / subtitle / logo before the table', () => {
55
+ const def = buildPdfDocDefinition({
56
+ columns,
57
+ rows,
58
+ opts: { title: 'Orders', subtitle: 'Q3', logo: 'data:image/png;base64,AAAA' },
59
+ now,
60
+ })
61
+ expect((def.content[0] as any).image).toBe('data:image/png;base64,AAAA')
62
+ expect((def.content[1] as any).text).toBe('Orders')
63
+ expect((def.content[2] as any).text).toBe('Q3')
64
+ })
65
+
66
+ it('emits a "Page X of Y" + date footer by default, suppressible', () => {
67
+ const def = buildPdfDocDefinition({ columns, rows, now })
68
+ expect(def.footer).toBeTypeOf('function')
69
+ const footer = def.footer!(2, 5) as any
70
+ expect(footer.columns[0].text).toBe('2026-07-10')
71
+ expect(footer.columns[1].text).toBe('Page 2 of 5')
72
+
73
+ const noFooter = buildPdfDocDefinition({ columns, rows, opts: { showPageNumbers: false }, now })
74
+ expect(noFooter.footer).toBeUndefined()
75
+ })
76
+
77
+ it('renders a structured body with group + subtotal rows', () => {
78
+ const def = buildPdfDocDefinition({
79
+ columns,
80
+ body: [
81
+ { kind: 'group', label: 'Region: North (2)', level: 0 },
82
+ { kind: 'data', cells: ['ACME', '$19.95'] },
83
+ { kind: 'data', cells: ['Globex', '$1,000.00'] },
84
+ { kind: 'subtotal', cells: ['Subtotal', '$1,019.95'] },
85
+ ],
86
+ now,
87
+ })
88
+ const table = (def.content.at(-1) as any).table
89
+ expect(table.body).toHaveLength(5) // header + group + 2 data + subtotal
90
+ // Group header spans all columns, bold + filled.
91
+ expect(table.body[1][0]).toMatchObject({ text: 'Region: North (2)', colSpan: 2, bold: true })
92
+ // Subtotal row is bold + filled.
93
+ expect(table.body[4][1]).toMatchObject({ text: '$1,019.95', bold: true, fillColor: '#f8fafc' })
94
+ // Zebra only stripes plain data rows, never group / subtotal.
95
+ const layout = (def.content.at(-1) as any).layout
96
+ expect(layout.fillColor(1)).toBeNull() // group row
97
+ expect(layout.fillColor(4)).toBeNull() // subtotal row
98
+ })
99
+
100
+ it('applies dataCellStyle (conditional formatting) to data cells only', () => {
101
+ const def = buildPdfDocDefinition({
102
+ columns,
103
+ rows,
104
+ dataCellStyle: (r, c) =>
105
+ r === 0 && c === 1 ? { fill: '#fee2e2', color: '#991b1b', bold: true } : undefined,
106
+ now,
107
+ })
108
+ const table = (def.content.at(-1) as any).table
109
+ expect(table.body[1][1]).toMatchObject({ fillColor: '#fee2e2', color: '#991b1b', bold: true })
110
+ expect(table.body[2][1].fillColor).toBeUndefined() // other cells untouched
111
+ })
112
+
113
+ it('adds a hyperlink to a data cell', () => {
114
+ const def = buildPdfDocDefinition({
115
+ columns,
116
+ rows,
117
+ dataCellLink: (r, c) => (r === 0 && c === 0 ? 'https://x.test' : undefined),
118
+ now,
119
+ })
120
+ const table = (def.content.at(-1) as any).table
121
+ expect(table.body[1][0]).toMatchObject({ link: 'https://x.test', decoration: 'underline' })
122
+ expect(table.body[2][0].link).toBeUndefined()
123
+ })
124
+
125
+ it('honors explicit column widths + margins + theme colors', () => {
126
+ const def = buildPdfDocDefinition({
127
+ columns,
128
+ rows,
129
+ opts: { columnWidths: [80, '*'], margins: [10, 10, 10, 10], headerColor: '#111827', zebra: false },
130
+ now,
131
+ })
132
+ const table = (def.content[0] as any).table
133
+ expect(table.widths).toEqual([80, '*'])
134
+ expect(def.pageMargins).toEqual([10, 10, 10, 10])
135
+ expect(table.body[0][0].fillColor).toBe('#111827')
136
+ expect((def.content[0] as any).layout.fillColor(2)).toBeNull() // zebra off
137
+ })
138
+ })
@@ -0,0 +1,245 @@
1
+ /**
2
+ * PDF export via our own pdfmake document definition, instead of the vendored
3
+ * Smart exporter's basic table dump. This gives real control over the page:
4
+ * size / orientation / margins, a repeated header row on every page, automatic
5
+ * "Page X of Y" + generated-date footer, per-column alignment and widths, an
6
+ * optional title / subtitle / logo, and theme colors (header fill + zebra).
7
+ *
8
+ * `buildPdfDocDefinition` is a PURE function (no pdfmake, no DOM) returning the
9
+ * docDefinition object, so it's fully unit-testable; `exportGrid` feeds the
10
+ * result to `pdfMake.createPdf(def).download(...)`.
11
+ */
12
+
13
+ export type PdfPageSize = 'A4' | 'A3' | 'A5' | 'LETTER' | 'LEGAL'
14
+
15
+ export type PdfExportOptions = {
16
+ pageSize?: PdfPageSize
17
+ pageOrientation?: 'portrait' | 'landscape'
18
+ /** [left, top, right, bottom] in pt. Default [28, 34, 28, 34]. */
19
+ margins?: [number, number, number, number]
20
+ /** Document title rendered above the table. */
21
+ title?: string
22
+ /** Smaller line under the title. */
23
+ subtitle?: string
24
+ /** Logo as a data URL, rendered top-left above the title. */
25
+ logo?: string
26
+ /** Logo width in pt (height auto). Default 90. */
27
+ logoWidth?: number
28
+ /** Base font size in pt. Default 8. */
29
+ fontSize?: number
30
+ /** Header row fill color. Default '#334155'. */
31
+ headerColor?: string
32
+ /** Header row text color. Default '#ffffff'. */
33
+ headerTextColor?: string
34
+ /** Zebra-stripe alternate rows. Default true. */
35
+ zebra?: boolean
36
+ /** Zebra fill color. Default '#f1f5f9'. */
37
+ zebraColor?: string
38
+ /** Repeat the header row on every page. Default true. */
39
+ repeatHeader?: boolean
40
+ /** Show the "Page X of Y" + date footer. Default true. */
41
+ showPageNumbers?: boolean
42
+ /**
43
+ * Column widths: '*' (fill, default), 'auto', or an explicit array of
44
+ * pt numbers / 'auto' / '*' per column.
45
+ */
46
+ columnWidths?: '*' | 'auto' | Array<number | 'auto' | '*'>
47
+ /** Switch to landscape automatically when column count exceeds this (and no
48
+ * explicit orientation was set). Default 8. */
49
+ autoLandscapeThreshold?: number
50
+ /** Group header row fill color. Default '#e2e8f0'. */
51
+ groupColor?: string
52
+ /** Group header row text color. Default '#0f172a'. */
53
+ groupTextColor?: string
54
+ /** Subtotal row fill color. Default '#f8fafc'. */
55
+ subtotalColor?: string
56
+ }
57
+
58
+ /**
59
+ * A body row for the PDF table. `data` is a normal row; `group` is a bold,
60
+ * full-width group header (from grid grouping); `subtotal` is a per-group
61
+ * summary row (bold, aligned like the data).
62
+ */
63
+ export type PdfBodyRow =
64
+ | { kind: 'data'; cells: ReadonlyArray<string> }
65
+ | { kind: 'group'; label: string; level?: number }
66
+ | { kind: 'subtotal'; cells: ReadonlyArray<string> }
67
+
68
+ type PdfCell = {
69
+ text: string
70
+ bold?: boolean
71
+ alignment?: 'left' | 'center' | 'right'
72
+ color?: string
73
+ fillColor?: string
74
+ colSpan?: number
75
+ margin?: [number, number, number, number]
76
+ link?: string
77
+ decoration?: string
78
+ }
79
+
80
+ export type PdfDocDefinition = {
81
+ pageSize: PdfPageSize
82
+ pageOrientation: 'portrait' | 'landscape'
83
+ pageMargins: [number, number, number, number]
84
+ defaultStyle: { fontSize: number }
85
+ content: unknown[]
86
+ footer?: (currentPage: number, pageCount: number) => unknown
87
+ }
88
+
89
+ /**
90
+ * Build a pdfmake docDefinition from resolved columns + formatted string rows.
91
+ * `now` is injected for a deterministic footer date in tests.
92
+ */
93
+ export function buildPdfDocDefinition(params: {
94
+ columns: ReadonlyArray<{ header: string; align?: 'left' | 'center' | 'right' }>
95
+ /** Data rows as pre-formatted strings, in column order (no header row).
96
+ * Convenience for the flat (ungrouped) case. */
97
+ rows?: ReadonlyArray<ReadonlyArray<string>>
98
+ /** Structured body with group headers + subtotals. Takes precedence over
99
+ * `rows` when provided (used for grouped exports). */
100
+ body?: ReadonlyArray<PdfBodyRow>
101
+ /** Per-data-cell visual override (conditional formatting). `dataRowIdx` is
102
+ * the 0-based index among data rows. */
103
+ dataCellStyle?: (dataRowIdx: number, colIdx: number) => { fill?: string; color?: string; bold?: boolean } | undefined
104
+ /** Per-data-cell hyperlink URL. */
105
+ dataCellLink?: (dataRowIdx: number, colIdx: number) => string | undefined
106
+ opts?: PdfExportOptions
107
+ now?: Date
108
+ }): PdfDocDefinition {
109
+ const { columns } = params
110
+ const o = params.opts ?? {}
111
+ const now = params.now ?? new Date(0)
112
+
113
+ const fontSize = o.fontSize ?? 8
114
+ const headerColor = o.headerColor ?? '#334155'
115
+ const headerTextColor = o.headerTextColor ?? '#ffffff'
116
+ const zebra = o.zebra ?? true
117
+ const zebraColor = o.zebraColor ?? '#f1f5f9'
118
+ const groupColor = o.groupColor ?? '#e2e8f0'
119
+ const groupTextColor = o.groupTextColor ?? '#0f172a'
120
+ const subtotalColor = o.subtotalColor ?? '#f8fafc'
121
+ const repeatHeader = o.repeatHeader ?? true
122
+
123
+ const orientation: 'portrait' | 'landscape' =
124
+ o.pageOrientation ??
125
+ (columns.length > (o.autoLandscapeThreshold ?? 8) ? 'landscape' : 'portrait')
126
+
127
+ const widths: Array<number | 'auto' | '*'> = Array.isArray(o.columnWidths)
128
+ ? o.columnWidths
129
+ : columns.map(() => (o.columnWidths === 'auto' ? 'auto' : '*'))
130
+
131
+ // Header row.
132
+ const headerRow: PdfCell[] = columns.map((c) => ({
133
+ text: c.header,
134
+ bold: true,
135
+ alignment: c.align ?? 'left',
136
+ color: headerTextColor,
137
+ fillColor: headerColor,
138
+ }))
139
+
140
+ // Normalize to a structured body: `body` wins, else wrap flat `rows`.
141
+ const model: ReadonlyArray<PdfBodyRow> =
142
+ params.body ?? (params.rows ?? []).map((cells) => ({ kind: 'data', cells }))
143
+
144
+ // Track which body-row indices are plain data rows, so zebra striping only
145
+ // hits those (group / subtotal rows carry their own fill).
146
+ const dataCellStyle = params.dataCellStyle
147
+ const dataCellLink = params.dataCellLink
148
+ let dataOrdinal = 0
149
+ const dataRowIdx = new Set<number>()
150
+ const bodyCells: PdfCell[][] = [headerRow]
151
+ model.forEach((row, i) => {
152
+ const bi = i + 1 // +1 for the header row
153
+ if (row.kind === 'group') {
154
+ const first: PdfCell = {
155
+ text: row.label,
156
+ bold: true,
157
+ color: groupTextColor,
158
+ fillColor: groupColor,
159
+ colSpan: columns.length,
160
+ margin: [(row.level ?? 0) * 12, 2, 0, 2],
161
+ }
162
+ const rest: PdfCell[] = Array.from({ length: Math.max(0, columns.length - 1) }, () => ({ text: '' }))
163
+ bodyCells.push([first, ...rest])
164
+ } else if (row.kind === 'subtotal') {
165
+ bodyCells.push(
166
+ columns.map((c, ci) => ({
167
+ text: row.cells[ci] ?? '',
168
+ bold: true,
169
+ alignment: c.align ?? 'left',
170
+ fillColor: subtotalColor,
171
+ })),
172
+ )
173
+ } else {
174
+ dataRowIdx.add(bi)
175
+ const ord = dataOrdinal++
176
+ bodyCells.push(
177
+ columns.map((c, ci) => {
178
+ const cell: PdfCell = { text: row.cells[ci] ?? '', alignment: c.align ?? 'left' }
179
+ const s = dataCellStyle?.(ord, ci)
180
+ if (s) {
181
+ if (s.fill) cell.fillColor = s.fill
182
+ if (s.color) cell.color = s.color
183
+ if (s.bold) cell.bold = true
184
+ }
185
+ const href = dataCellLink?.(ord, ci)
186
+ if (href) {
187
+ cell.link = href
188
+ cell.decoration = 'underline'
189
+ if (!s?.color) cell.color = '#2563eb'
190
+ }
191
+ return cell
192
+ }),
193
+ )
194
+ }
195
+ })
196
+
197
+ const table = {
198
+ table: {
199
+ headerRows: repeatHeader ? 1 : 0,
200
+ dontBreakRows: true,
201
+ widths,
202
+ body: bodyCells,
203
+ },
204
+ layout: {
205
+ // Zebra fill: only plain data rows (group / subtotal have their own).
206
+ fillColor: (rowIndex: number) =>
207
+ zebra && rowIndex > 0 && rowIndex % 2 === 0 && dataRowIdx.has(rowIndex) ? zebraColor : null,
208
+ hLineWidth: () => 0.5,
209
+ vLineWidth: () => 0.5,
210
+ hLineColor: () => '#e2e8f0',
211
+ vLineColor: () => '#e2e8f0',
212
+ paddingTop: () => 3,
213
+ paddingBottom: () => 3,
214
+ paddingLeft: () => 5,
215
+ paddingRight: () => 5,
216
+ },
217
+ }
218
+
219
+ const content: unknown[] = []
220
+ if (o.logo) content.push({ image: o.logo, width: o.logoWidth ?? 90, margin: [0, 0, 0, 8] })
221
+ if (o.title) content.push({ text: o.title, fontSize: fontSize + 8, bold: true, margin: [0, 0, 0, 2] })
222
+ if (o.subtitle) content.push({ text: o.subtitle, fontSize: fontSize + 1, color: '#64748b', margin: [0, 0, 0, 8] })
223
+ content.push(table)
224
+
225
+ const def: PdfDocDefinition = {
226
+ pageSize: o.pageSize ?? 'A4',
227
+ pageOrientation: orientation,
228
+ pageMargins: o.margins ?? [28, 34, 28, 34],
229
+ defaultStyle: { fontSize },
230
+ content,
231
+ }
232
+
233
+ if (o.showPageNumbers ?? true) {
234
+ const dateStr = now.toISOString().slice(0, 10)
235
+ def.footer = (currentPage: number, pageCount: number) => ({
236
+ margin: [28, 6, 28, 0],
237
+ columns: [
238
+ { text: dateStr, alignment: 'left', fontSize: 7, color: '#94a3b8' },
239
+ { text: `Page ${currentPage} of ${pageCount}`, alignment: 'right', fontSize: 7, color: '#94a3b8' },
240
+ ],
241
+ })
242
+ }
243
+
244
+ return def
245
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildPrintDocument } from './export-print'
3
+
4
+ const columns = [
5
+ { header: 'Company', align: 'left' as const },
6
+ { header: 'Price', align: 'right' as const },
7
+ ]
8
+ const rows = [
9
+ ['ACME', '$19.95'],
10
+ ['Globex', '$1,000.00'],
11
+ ]
12
+
13
+ describe('buildPrintDocument', () => {
14
+ it('repeats the header, aligns columns, sets page size, and zebra-stripes', () => {
15
+ const html = buildPrintDocument({
16
+ columns,
17
+ rows,
18
+ opts: { title: 'Orders', orientation: 'landscape', pageSize: 'A4' },
19
+ })
20
+ expect(html).toContain('display: table-header-group') // repeated header
21
+ expect(html).toContain('@page { size: A4 landscape;')
22
+ expect(html).toContain('<th style="text-align:right">Price</th>')
23
+ expect(html).toContain('<td style="text-align:right">$19.95</td>')
24
+ expect(html).toContain('<h1>Orders</h1>')
25
+ expect(html).toContain('nth-child(even)')
26
+ })
27
+
28
+ it('escapes HTML and supports subtitle / logo / no-zebra', () => {
29
+ const html = buildPrintDocument({
30
+ columns,
31
+ rows: [['<b>x</b>', '1']],
32
+ opts: { zebra: false, subtitle: 'Q3', logo: 'data:image/png;base64,AA' },
33
+ })
34
+ expect(html).toContain('&lt;b&gt;x&lt;/b&gt;')
35
+ expect(html).not.toContain('nth-child(even)')
36
+ expect(html).toContain('<p class="subtitle">Q3</p>')
37
+ expect(html).toContain('src="data:image/png;base64,AA"')
38
+ })
39
+
40
+ it('defaults to portrait with a 14mm margin', () => {
41
+ const html = buildPrintDocument({ columns, rows })
42
+ expect(html).toContain('@page { size: portrait; margin: 14mm;')
43
+ })
44
+
45
+ it('renders hyperlinks from a cellLink hook', () => {
46
+ const html = buildPrintDocument({
47
+ columns,
48
+ rows,
49
+ cellLink: (r, c) => (r === 0 && c === 0 ? 'https://x.test' : undefined),
50
+ })
51
+ expect(html).toContain('<a href="https://x.test">ACME</a>')
52
+ })
53
+
54
+ it('applies conditional-format cell styles + icon prefix', () => {
55
+ const html = buildPrintDocument({
56
+ columns,
57
+ rows,
58
+ cellStyle: (r, c) =>
59
+ r === 0 && c === 1 ? { fill: '#dcfce7', color: '#166534', bold: true, icon: '↑' } : undefined,
60
+ })
61
+ expect(html).toContain('background:#dcfce7')
62
+ expect(html).toContain('color:#166534')
63
+ expect(html).toContain('font-weight:700')
64
+ expect(html).toContain('↑ $19.95')
65
+ })
66
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pure HTML builder for the print view. `printGrid` opens this document in a
3
+ * new window and calls `window.print()`, so the user can print or "Save as
4
+ * PDF" using the browser's own engine (great font + CSS fidelity, CJK / RTL,
5
+ * zero dependency). Kept pure (no DOM) so it's unit-testable.
6
+ *
7
+ * The upgrades over a plain table dump: page size / orientation / margins via
8
+ * `@page`, a header row that repeats on every printed page
9
+ * (`display: table-header-group`), per-column alignment, zebra striping, and an
10
+ * optional title / subtitle / logo band. Browsers add their own page-number
11
+ * header/footer through the print dialog.
12
+ */
13
+
14
+ export type PrintDocOptions = {
15
+ title?: string
16
+ subtitle?: string
17
+ /** Logo data URL, shown above the title. */
18
+ logo?: string
19
+ /** Logo width in px. Default 120. */
20
+ logoWidth?: number
21
+ orientation?: 'portrait' | 'landscape'
22
+ /** `@page size` keyword, e.g. 'A4' | 'Letter'. Combined with orientation. */
23
+ pageSize?: string
24
+ /** `@page margin`, e.g. '14mm'. Default '14mm'. */
25
+ margin?: string
26
+ /** Zebra-stripe rows. Default true. */
27
+ zebra?: boolean
28
+ zebraColor?: string
29
+ /** Header row background. Default '#f1f5f9'. */
30
+ headerColor?: string
31
+ headerTextColor?: string
32
+ /** Body font size in pt. Default 9. */
33
+ fontSize?: number
34
+ }
35
+
36
+ const HTML_ESCAPE: Record<string, string> = {
37
+ '&': '&amp;',
38
+ '<': '&lt;',
39
+ '>': '&gt;',
40
+ '"': '&quot;',
41
+ "'": '&#39;',
42
+ }
43
+ function esc(value: unknown): string {
44
+ const s = value == null ? '' : String(value)
45
+ return s.replace(/[&<>"']/g, (c) => HTML_ESCAPE[c]!)
46
+ }
47
+
48
+ /**
49
+ * Build a standalone, print-ready HTML document from resolved columns +
50
+ * pre-formatted string rows.
51
+ */
52
+ export type PrintCellVisual = { fill?: string; color?: string; bold?: boolean; icon?: string }
53
+
54
+ export function buildPrintDocument(params: {
55
+ columns: ReadonlyArray<{ header: string; align?: 'left' | 'center' | 'right' }>
56
+ rows: ReadonlyArray<ReadonlyArray<string>>
57
+ /** Per-data-cell conditional-format visual. */
58
+ cellStyle?: (rowIdx: number, colIdx: number) => PrintCellVisual | undefined
59
+ /** Per-data-cell hyperlink URL. */
60
+ cellLink?: (rowIdx: number, colIdx: number) => string | undefined
61
+ opts?: PrintDocOptions
62
+ }): string {
63
+ const { columns, rows } = params
64
+ const cellStyle = params.cellStyle
65
+ const cellLink = params.cellLink
66
+ const o = params.opts ?? {}
67
+ const orientation = o.orientation ?? 'portrait'
68
+ const size = o.pageSize ? `${o.pageSize} ${orientation}` : orientation
69
+ const margin = o.margin ?? '14mm'
70
+ const zebra = o.zebra ?? true
71
+ const zebraColor = o.zebraColor ?? '#f6f8fa'
72
+ const headerColor = o.headerColor ?? '#f1f5f9'
73
+ const headerTextColor = o.headerTextColor ?? '#0f172a'
74
+ const fontSize = o.fontSize ?? 9
75
+
76
+ const alignStyle = (a?: 'left' | 'center' | 'right') =>
77
+ a && a !== 'left' ? ` style="text-align:${a}"` : ''
78
+
79
+ const thead =
80
+ '<thead><tr>' +
81
+ columns.map((c) => `<th${alignStyle(c.align)}>${esc(c.header)}</th>`).join('') +
82
+ '</tr></thead>'
83
+
84
+ const cellHtml = (c: { align?: 'left' | 'center' | 'right' }, value: string, ri: number, ci: number) => {
85
+ const styles: string[] = []
86
+ if (c.align && c.align !== 'left') styles.push(`text-align:${c.align}`)
87
+ const cf = cellStyle?.(ri, ci)
88
+ if (cf?.fill) styles.push(`background:${cf.fill}`)
89
+ if (cf?.color) styles.push(`color:${cf.color}`)
90
+ if (cf?.bold) styles.push('font-weight:700')
91
+ const style = styles.length ? ` style="${styles.join(';')}"` : ''
92
+ let text = cf?.icon ? `${esc(cf.icon)} ${esc(value)}` : esc(value)
93
+ const href = cellLink?.(ri, ci)
94
+ if (href) text = `<a href="${esc(href)}">${text}</a>`
95
+ return `<td${style}>${text}</td>`
96
+ }
97
+ const tbody =
98
+ '<tbody>' +
99
+ rows
100
+ .map(
101
+ (r, ri) =>
102
+ '<tr>' + columns.map((c, ci) => cellHtml(c, r[ci] ?? '', ri, ci)).join('') + '</tr>',
103
+ )
104
+ .join('') +
105
+ '</tbody>'
106
+
107
+ const band: string[] = []
108
+ if (o.logo) band.push(`<img class="logo" src="${esc(o.logo)}" alt="" />`)
109
+ if (o.title) band.push(`<h1>${esc(o.title)}</h1>`)
110
+ if (o.subtitle) band.push(`<p class="subtitle">${esc(o.subtitle)}</p>`)
111
+
112
+ return `<!DOCTYPE html>
113
+ <html><head><meta charset="utf-8"><title>${esc(o.title ?? 'Grid')}</title>
114
+ <style>
115
+ @page { size: ${size}; margin: ${margin}; }
116
+ @media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
117
+ body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; margin: 0; }
118
+ .logo { max-height: ${o.logoWidth ? '' : '48px'}; width: ${o.logoWidth ? `${o.logoWidth}px` : 'auto'}; display: block; margin-bottom: 8px; }
119
+ h1 { font-size: 16pt; margin: 0 0 2px 0; }
120
+ .subtitle { font-size: 10pt; color: #64748b; margin: 0 0 10px 0; }
121
+ table { border-collapse: collapse; width: 100%; font-size: ${fontSize}pt; }
122
+ thead { display: table-header-group; }
123
+ tr { break-inside: avoid; }
124
+ th, td { border: 1px solid #cbd5e1; padding: 4px 7px; text-align: left; }
125
+ th { background: ${headerColor}; color: ${headerTextColor}; font-weight: 700; }
126
+ ${zebra ? `tbody tr:nth-child(even) { background: ${zebraColor}; }` : ''}
127
+ </style></head>
128
+ <body>
129
+ ${band.join('\n ')}
130
+ <table>${thead}${tbody}</table>
131
+ </body></html>`
132
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { serializeDelimited, serializeHtml } from './export-serialize'
3
+
4
+ const header = { name: 'Name', note: 'Note', amount: 'Amount' }
5
+ const fields = ['name', 'note', 'amount']
6
+
7
+ describe('serializeDelimited (CSV)', () => {
8
+ it('writes a BOM + CRLF rows by default', async () => {
9
+ const out = await serializeDelimited([header, { name: 'A', note: 'x', amount: '1' }], fields)
10
+ expect(out.charCodeAt(0)).toBe(0xfeff) // BOM
11
+ expect(out).toContain('Name,Note,Amount\r\nA,x,1')
12
+ })
13
+
14
+ it('RFC-4180 quotes values with commas, quotes, and newlines', async () => {
15
+ const rows = [
16
+ header,
17
+ { name: 'Doe, John', note: 'say "hi"', amount: 'line1\nline2' },
18
+ ]
19
+ const out = await serializeDelimited(rows, fields, { csv: { bom: false } })
20
+ expect(out).toContain('"Doe, John"')
21
+ expect(out).toContain('"say ""hi"""')
22
+ expect(out).toContain('"line1\nline2"')
23
+ })
24
+
25
+ it('honors a custom delimiter and no-BOM (TSV-style)', async () => {
26
+ const out = await serializeDelimited([header, { name: 'A', note: 'b', amount: 'c' }], fields, {
27
+ csv: { delimiter: '\t', bom: false },
28
+ })
29
+ expect(out.startsWith('Name\tNote\tAmount')).toBe(true)
30
+ })
31
+
32
+ it('reports progress and can be aborted', async () => {
33
+ const many = [header, ...Array.from({ length: 20 }, (_, i) => ({ name: `n${i}`, note: '', amount: `${i}` }))]
34
+ const onProgress = vi.fn()
35
+ await serializeDelimited(many, fields, { onProgress, chunkRows: 5 })
36
+ expect(onProgress).toHaveBeenCalled()
37
+ expect(onProgress.mock.calls.at(-1)![0].ratio).toBe(1)
38
+
39
+ const ctrl = new AbortController()
40
+ ctrl.abort()
41
+ await expect(serializeDelimited(many, fields, { signal: ctrl.signal })).rejects.toMatchObject({
42
+ name: 'AbortError',
43
+ })
44
+ })
45
+ })
46
+
47
+ describe('serializeHtml', () => {
48
+ it('escapes HTML and applies column alignment', async () => {
49
+ const rows = [header, { name: '<b>A</b>', note: 'x & y', amount: '10' }]
50
+ const out = await serializeHtml(rows, fields, { align: { amount: 'right' } })
51
+ expect(out).toContain('&lt;b&gt;A&lt;/b&gt;')
52
+ expect(out).toContain('x &amp; y')
53
+ expect(out).toContain('<td style="text-align:right">10</td>')
54
+ expect(out).toContain('<th>Name</th>')
55
+ })
56
+ })
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The text serializers + delivery helpers moved into the FREE grid package
3
+ * (CSV / TSV / JSON export + clipboard now ship in @svgrid/grid). Enterprise
4
+ * re-uses that one implementation for its richer paths (styled HTML, xls, the
5
+ * xlsx/pdf blob download) instead of maintaining a second copy.
6
+ *
7
+ * Kept as a thin re-export from the Svelte-free `@svgrid/grid/format` subpath
8
+ * so existing imports (and the test mocks that target this module path) keep
9
+ * working unchanged.
10
+ */
11
+ export {
12
+ serializeDelimited,
13
+ serializeJson,
14
+ serializeHtml,
15
+ serializeMarkdown,
16
+ serializeXml,
17
+ downloadBlobFile,
18
+ downloadTextFile,
19
+ copyTextToClipboard,
20
+ type CsvOptions,
21
+ type SerializeOptions,
22
+ type SerializeProgress,
23
+ type ExportCellVisual,
24
+ } from '@svgrid/grid/format'