@svgrid/enterprise 3.1.26 → 3.1.28

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 (33) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +20195 -19732
  2. package/dist/designer/assets/{GridFooter-DKMfZpL4.js → GridFooter-EyLg77jG.js} +1 -1
  3. package/dist/designer/assets/{GridMenus-C8WqB-To.js → GridMenus-BAgfpDd-.js} +2 -2
  4. package/dist/designer/assets/SvGrid.helpers-DHkH3mHW.js +1 -0
  5. package/dist/designer/assets/{SvGridCellEditor-CC9UMdVq.js → SvGridCellEditor-BHxH4b5h.js} +1 -1
  6. package/dist/designer/assets/column-types-Bl5R-97G.js +1 -0
  7. package/dist/designer/assets/{index-PEFkd-5C.js → index-DPk7cZrl.js} +317 -317
  8. package/dist/designer/assets/src-sFRH_up7.js +60 -0
  9. package/dist/designer/index.html +8 -7
  10. package/dist/export-conditional.d.ts +2 -0
  11. package/dist/export-conditional.js +2 -0
  12. package/dist/export-pdf.d.ts +142 -7
  13. package/dist/export-pdf.js +368 -44
  14. package/dist/export.d.ts +22 -4
  15. package/dist/export.js +150 -53
  16. package/dist/import.js +4 -3
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/node/studio.js +2 -2
  20. package/dist/pdf-worker.d.ts +92 -0
  21. package/dist/pdf-worker.js +147 -0
  22. package/dist/sheet/ods-document.js +3 -1
  23. package/dist/sheet/open-file.js +4 -2
  24. package/dist/sheet/xlsx-document.d.ts +2 -2
  25. package/dist/sheet/xlsx-document.js +4 -3
  26. package/dist/version.d.ts +2 -2
  27. package/dist/version.js +2 -2
  28. package/dist/wc/sv-sheet-element.js +4768 -4712
  29. package/dist/xlsx-limits.d.ts +34 -0
  30. package/dist/xlsx-limits.js +68 -0
  31. package/package.json +7 -3
  32. package/dist/designer/assets/SvGrid.helpers-BF2QRcNt.js +0 -1
  33. package/dist/designer/assets/src-DC5nN4Q7.js +0 -58
@@ -73,6 +73,50 @@ export function registerPdfFonts(pdfMake, vfsModule) {
73
73
  pdfMake.vfs = vfs;
74
74
  return true;
75
75
  }
76
+ const ROBOTO = { normal: 'Roboto-Regular.ttf', bold: 'Roboto-Medium.ttf', italics: 'Roboto-Italic.ttf', bolditalics: 'Roboto-MediumItalic.ttf' };
77
+ /**
78
+ * Register `font` with pdfmake next to Roboto. Base64 files go into the
79
+ * virtual file system, merged with what is there (`addVirtualFileSystem`
80
+ * replaces the map rather than adding to it); URLs are passed through for
81
+ * pdfmake to fetch.
82
+ */
83
+ export function registerPdfFont(pdfMake, font) {
84
+ const files = {};
85
+ const isUrl = (s) => /^(https?:|blob:|data:|\/|\.\.?\/)/i.test(s);
86
+ const file = (style, src) => {
87
+ if (!src)
88
+ return undefined;
89
+ if (isUrl(src))
90
+ return src;
91
+ const key = `svgrid-${font.name}-${style}.ttf`;
92
+ files[key] = src;
93
+ return key;
94
+ };
95
+ const normal = file('normal', font.normal);
96
+ const bold = file('bold', font.bold) ?? normal;
97
+ const entry = {
98
+ normal,
99
+ bold,
100
+ italics: file('italics', font.italics) ?? normal,
101
+ bolditalics: file('bolditalics', font.bolditalics) ?? bold,
102
+ };
103
+ if (Object.keys(files).length) {
104
+ const vfs = { ...(pdfMake.vfs ?? {}), ...files };
105
+ if (typeof pdfMake.addVirtualFileSystem === 'function')
106
+ pdfMake.addVirtualFileSystem(vfs);
107
+ pdfMake.vfs = vfs;
108
+ }
109
+ const fonts = { Roboto: ROBOTO, ...(pdfMake.fonts ?? {}), [font.name]: entry };
110
+ if (typeof pdfMake.addFonts === 'function')
111
+ pdfMake.addFonts(fonts);
112
+ pdfMake.fonts = fonts;
113
+ }
114
+ /**
115
+ * Text Roboto has no glyphs for: CJK, Hangul, Arabic, Hebrew, Thai, the Indic
116
+ * scripts, dingbats (check marks, stars) and emoji. Exported so an export can
117
+ * warn that it needs `pdf.font`.
118
+ */
119
+ export const PDF_ROBOTO_MISSING = /[֐-ࣿऀ-෿฀-๿ᄀ-ᇿ☀-➿⺀-鿿가-힯豈-﫿＀-￯\u{1f000}-\u{1faff}]/u;
76
120
  /** `rgb(...)` / `rgba(...)` / `#hex` -> `#rrggbb`, or null for transparent and anything else. */
77
121
  export function cssColorToHex(value) {
78
122
  if (!value)
@@ -193,10 +237,39 @@ export async function resolvePdfCharts(charts, rasterize) {
193
237
  return out;
194
238
  }
195
239
  /**
196
- * Build a pdfmake docDefinition from resolved columns + formatted string rows.
197
- * `now` is injected for a deterministic footer date in tests.
240
+ * The conditional-format icon sets as shapes. Their glyphs (arrows,
241
+ * triangles, coloured circles) are not in Roboto and printed as empty boxes,
242
+ * so they are drawn instead, in the red / amber / green the sets mean.
198
243
  */
199
- export function buildPdfDocDefinition(params) {
244
+ const ICON_RED = '#dc2626';
245
+ const ICON_AMBER = '#d97706';
246
+ const ICON_YELLOW = '#eab308';
247
+ const ICON_GREEN = '#16a34a';
248
+ const poly = (points, color) => ({ type: 'polyline', points, closePath: true, lineWidth: 0.1, lineColor: color, color });
249
+ const arrowUp = (s) => [
250
+ { x: s / 2, y: 0 }, { x: s, y: s * 0.5 }, { x: s * 0.68, y: s * 0.5 }, { x: s * 0.68, y: s },
251
+ { x: s * 0.32, y: s }, { x: s * 0.32, y: s * 0.5 }, { x: 0, y: s * 0.5 },
252
+ ];
253
+ const PDF_ICON_SHAPES = {
254
+ '↑': (s) => [poly(arrowUp(s), ICON_GREEN)],
255
+ '↓': (s) => [poly(arrowUp(s).map((p) => ({ x: p.x, y: s - p.y })), ICON_RED)],
256
+ '→': (s) => [poly(arrowUp(s).map((p) => ({ x: s - p.y, y: p.x })), ICON_AMBER)],
257
+ '▲': (s) => [poly([{ x: 0, y: s }, { x: s / 2, y: 0 }, { x: s, y: s }], ICON_GREEN)],
258
+ '▼': (s) => [poly([{ x: 0, y: 0 }, { x: s, y: 0 }, { x: s / 2, y: s }], ICON_RED)],
259
+ '◆': (s) => [poly([{ x: s / 2, y: 0 }, { x: s, y: s / 2 }, { x: s / 2, y: s }, { x: 0, y: s / 2 }], ICON_AMBER)],
260
+ '🔴': (s) => [{ type: 'ellipse', x: s / 2, y: s / 2, r1: s / 2, r2: s / 2, color: ICON_RED }],
261
+ '🟡': (s) => [{ type: 'ellipse', x: s / 2, y: s / 2, r1: s / 2, r2: s / 2, color: ICON_YELLOW }],
262
+ '🟢': (s) => [{ type: 'ellipse', x: s / 2, y: s / 2, r1: s / 2, r2: s / 2, color: ICON_GREEN }],
263
+ // A true boolean, where the grid draws a ticked checkbox.
264
+ '✓': (s) => [{ type: 'polyline', points: [{ x: 0, y: s * 0.55 }, { x: s * 0.38, y: s * 0.92 }, { x: s, y: s * 0.08 }], lineWidth: Math.max(1, s * 0.16), lineColor: '#334155' }],
265
+ };
266
+ /**
267
+ * Build the pdfmake document from resolved columns + formatted string rows,
268
+ * as plain data: no functions, so it can be posted to a worker. Hand it to
269
+ * {@link hydratePdfDoc} before pdfmake sees it, or use
270
+ * {@link buildPdfDocDefinition}, which does both.
271
+ */
272
+ export function buildPdfDocData(params) {
200
273
  const { columns } = params;
201
274
  const o = params.opts ?? {};
202
275
  const st = params.styles ?? {};
@@ -255,9 +328,10 @@ export function buildPdfDocDefinition(params) {
255
328
  }
256
329
  const orientation = o.pageOrientation ??
257
330
  (columns.length > (o.autoLandscapeThreshold ?? 8) ? 'landscape' : 'portrait');
258
- const widths = Array.isArray(o.columnWidths)
259
- ? o.columnWidths
260
- : columns.map(() => (o.columnWidths === 'auto' ? 'auto' : '*'));
331
+ const margins = o.margins ?? [28, 34, 28, 34];
332
+ const [pw, ph] = PAGE_PT[o.pageSize ?? 'A4'];
333
+ const contentWidth = (orientation === 'landscape' ? ph : pw) - margins[0] - margins[2];
334
+ const contentHeight = (orientation === 'landscape' ? pw : ph) - margins[1] - margins[3];
261
335
  // Header row.
262
336
  const headerRow = columns.map((c, ci) => {
263
337
  const cell = {
@@ -280,6 +354,9 @@ export function buildPdfDocDefinition(params) {
280
354
  const dataCellLink = params.dataCellLink;
281
355
  let dataOrdinal = 0;
282
356
  const dataRowIdx = new Set();
357
+ const groupRowIdx = new Set();
358
+ /** Cells with a drawn icon: [body row, column, icon]. Drawn after the layout is measured. */
359
+ const icons = [];
283
360
  const bodyCells = [headerRow];
284
361
  model.forEach((row, i) => {
285
362
  const bi = i + 1; // +1 for the header row
@@ -293,6 +370,7 @@ export function buildPdfDocDefinition(params) {
293
370
  margin: [(row.level ?? 0) * 12, 2, 0, 2],
294
371
  };
295
372
  const rest = Array.from({ length: Math.max(0, columns.length - 1) }, () => ({ text: '' }));
373
+ groupRowIdx.add(bi);
296
374
  bodyCells.push([first, ...rest]);
297
375
  }
298
376
  else if (row.kind === 'subtotal') {
@@ -323,6 +401,12 @@ export function buildPdfDocDefinition(params) {
323
401
  cell.color = s.color;
324
402
  if (s.bold)
325
403
  cell.bold = true;
404
+ if (s.icon && s.iconOnly)
405
+ cell.text = '';
406
+ if (s.icon && PDF_ICON_SHAPES[s.icon])
407
+ icons.push([bi, ci, s.icon]);
408
+ else if (s.icon)
409
+ cell.text = s.iconOnly ? s.icon : `${s.icon} ${cell.text ?? ''}`;
326
410
  }
327
411
  const href = dataCellLink?.(ord, ci);
328
412
  if (href) {
@@ -366,26 +450,188 @@ export function buildPdfDocDefinition(params) {
366
450
  }
367
451
  }
368
452
  }
369
- const table = {
370
- table: {
371
- headerRows: repeatHeader ? 1 : 0,
372
- dontBreakRows: true,
373
- widths,
374
- body: bodyCells,
375
- },
376
- layout: {
377
- // Zebra fill: only plain data rows (group / subtotal have their own).
378
- fillColor: (rowIndex) => zebra && rowIndex > 0 && rowIndex % 2 === 0 && dataRowIdx.has(rowIndex) ? zebraColor : null,
379
- hLineWidth: () => 0.5,
380
- vLineWidth: () => 0.5,
381
- hLineColor: () => borderColor,
382
- vLineColor: () => borderColor,
383
- paddingTop: () => 3,
384
- paddingBottom: () => 3,
385
- paddingLeft: () => 5,
386
- paddingRight: () => 5,
387
- },
453
+ // ---- Layout -------------------------------------------------------------
454
+ // pdfmake sizes a '*' column no narrower than its longest word, so a wide
455
+ // grid ran off the right edge and its last columns were never printed. The
456
+ // widths are worked out here instead, from the text, against the page:
457
+ // shrink the type, or split the columns over pages, so every column prints.
458
+ // Measured with Roboto's average advance, on the wide side so a column is
459
+ // never narrower than its text.
460
+ const n = columns.length;
461
+ const PAD = 10.5; // padding left + right, and one vertical line
462
+ const emOf = (bold) => (bold ? 0.62 : 0.56);
463
+ const iconAt = new Set(icons.map(([bi, ci]) => `${bi}:${ci}`));
464
+ // Widths at a 1 pt font: the longest word (a column cannot be narrower) and
465
+ // the longest line (what it would like). Spanning cells are left out.
466
+ const minAt1 = new Array(n).fill(0);
467
+ const natAt1 = new Array(n).fill(0);
468
+ bodyCells.forEach((row, bi) => {
469
+ if (groupRowIdx.has(bi))
470
+ return;
471
+ row.forEach((cell, ci) => {
472
+ if ((cell.colSpan ?? 1) > 1 || ci >= n)
473
+ return;
474
+ const k = emOf(cell.bold) * (cell.fontSize ?? fontSize) / fontSize;
475
+ const extra = iconAt.has(`${bi}:${ci}`) ? 2 : 0;
476
+ let word = 0;
477
+ let line = 0;
478
+ for (const l of (cell.text ?? '').split('\n')) {
479
+ line = Math.max(line, l.length);
480
+ for (const w of l.split(/\s+/))
481
+ word = Math.max(word, w.length);
482
+ }
483
+ // A 60-character URL should not claim a third of the page; past 28
484
+ // characters a word is left to wrap or overflow its cell.
485
+ minAt1[ci] = Math.max(minAt1[ci], (Math.min(word, 28) + extra) * k);
486
+ natAt1[ci] = Math.max(natAt1[ci], (line + extra) * k);
487
+ });
488
+ });
489
+ const budget = (count) => contentWidth - count * PAD - 2;
490
+ const sumMin = (cols, f) => cols.reduce((a, ci) => a + Math.max(minAt1[ci] * f, f), 0);
491
+ const fit = o.fit ?? 'auto';
492
+ const explicitWidths = Array.isArray(o.columnWidths) ? o.columnWidths : null;
493
+ const all = columns.map((_, i) => i);
494
+ let tableFont = fontSize;
495
+ let chunks = [all];
496
+ if (fit !== 'none' && !explicitWidths && n > 0 && sumMin(all, fontSize) > budget(n)) {
497
+ const room = budget(n);
498
+ const fitFont = room > 0 ? (fontSize * room) / sumMin(all, fontSize) : 0;
499
+ const floor = fit === 'shrink' ? 4 : Math.max(5, fontSize * 0.75);
500
+ if (fit !== 'split' && fitFont >= floor)
501
+ tableFont = Math.floor(fitFont * 10) / 10;
502
+ else if (fit === 'shrink')
503
+ tableFont = floor;
504
+ else {
505
+ // Split: the leading columns on every page, the rest packed in order.
506
+ const keep = Math.max(0, Math.min(o.repeatColumns ?? 1, n - 1));
507
+ const lead = all.slice(0, keep);
508
+ chunks = [];
509
+ let cur = [];
510
+ for (let ci = keep; ci < n; ci++) {
511
+ if (cur.length && sumMin([...lead, ...cur, ci], fontSize) > budget(lead.length + cur.length + 1)) {
512
+ chunks.push([...lead, ...cur]);
513
+ cur = [];
514
+ }
515
+ cur.push(ci);
516
+ }
517
+ if (cur.length)
518
+ chunks.push([...lead, ...cur]);
519
+ }
520
+ }
521
+ const widthsFor = (cols) => {
522
+ if (explicitWidths) {
523
+ // Explicit numbers are kept unless together they run off the page.
524
+ const nums = explicitWidths.filter((w) => typeof w === 'number');
525
+ const total = nums.reduce((a, b) => a + b, 0);
526
+ const room = budget(explicitWidths.length);
527
+ return total > room && fit !== 'none'
528
+ ? explicitWidths.map((w) => (typeof w === 'number' ? Math.floor((w * room) / total) : w))
529
+ : explicitWidths;
530
+ }
531
+ if (fit === 'none')
532
+ return cols.map(() => (o.columnWidths === 'auto' ? 'auto' : '*'));
533
+ const min = cols.map((ci) => Math.max(minAt1[ci] * tableFont, tableFont));
534
+ const nat = cols.map((ci, i) => Math.max(min[i], natAt1[ci] * tableFont));
535
+ const room = budget(cols.length);
536
+ const sumNat = nat.reduce((a, b) => a + b, 0);
537
+ let out;
538
+ if (o.columnWidths === 'auto' && sumNat <= room)
539
+ out = nat;
540
+ else if (sumNat <= room)
541
+ out = nat.map((w) => w + (room - sumNat) / cols.length);
542
+ else {
543
+ // Every column gets its longest word; what is left goes to the
544
+ // columns that want more, in proportion, so a notes column takes the
545
+ // room an id column does not need.
546
+ const sumMinW = min.reduce((a, b) => a + b, 0);
547
+ const spare = Math.max(0, room - sumMinW);
548
+ const want = nat.reduce((a, w, i) => a + (w - min[i]), 0);
549
+ out = min.map((m, i) => m + (want > 0 ? (spare * (nat[i] - m)) / want : spare / cols.length));
550
+ }
551
+ return out.map((w) => Math.floor(w * 100) / 100);
552
+ };
553
+ // A row taller than the page cannot be kept whole, and pdfmake drops it
554
+ // without a word when told not to break rows. So rows stay whole unless one
555
+ // would come near a page's height; then rows may break.
556
+ const lineHeight = tableFont * 1.25;
557
+ const rowHeight = (row, cols, widths) => {
558
+ let h = 0;
559
+ cols.forEach((ci, i) => {
560
+ const cell = row[ci];
561
+ const w = widths[i];
562
+ if (!cell || (cell.rowSpan ?? 1) > 1 || typeof w !== 'number')
563
+ return;
564
+ const k = emOf(cell.bold) * (cell.fontSize ?? tableFont);
565
+ let lines = 0;
566
+ for (const l of (cell.text ?? '').split('\n'))
567
+ lines += Math.max(1, Math.ceil((l.length * k) / Math.max(w, 1)));
568
+ h = Math.max(h, lines * lineHeight * ((cell.fontSize ?? tableFont) / tableFont));
569
+ });
570
+ return h + 6;
388
571
  };
572
+ // The icons, drawn next to the text now that the widths are known.
573
+ for (const [bi, ci, icon] of icons) {
574
+ const cell = bodyCells[bi]?.[ci];
575
+ if (!cell)
576
+ continue;
577
+ const size = tableFont * 0.8;
578
+ const { text, alignment, color, bold, italics, fontSize: fs, link, decoration } = cell;
579
+ delete cell.text;
580
+ const shape = { width: size + 3, canvas: PDF_ICON_SHAPES[icon](size), margin: [0, tableFont * 0.2, 0, 0] };
581
+ const gap = { width: '*', text: '' };
582
+ // An icon with text beside it leads the cell; one on its own (a ticked
583
+ // boolean) sits where the column aligns its values.
584
+ cell.columns = text
585
+ ? [shape, { width: '*', text, alignment, color, bold, italics, fontSize: fs, link, decoration }]
586
+ : alignment === 'center' ? [gap, shape, gap] : alignment === 'right' ? [gap, shape] : [shape, gap];
587
+ cell.columnGap = 0;
588
+ for (const key of ['alignment', 'color', 'bold', 'italics', 'fontSize', 'link', 'decoration'])
589
+ delete cell[key];
590
+ }
591
+ // One table per chunk of columns. Group rows span the chunk; a merge is
592
+ // cut to the columns the chunk holds.
593
+ const sliceRow = (row, bi, cols) => {
594
+ if (cols.length === n && cols.every((c, i) => c === i))
595
+ return row;
596
+ if (groupRowIdx.has(bi))
597
+ return [{ ...row[0], colSpan: cols.length }, ...cols.slice(1).map(() => ({ text: '' }))];
598
+ return cols.map((ci, k) => {
599
+ const cell = row[ci];
600
+ const span = cell.colSpan ?? 1;
601
+ if (span <= 1)
602
+ return cell;
603
+ let run = 1;
604
+ while (run < span && cols[k + run] === ci + run)
605
+ run += 1;
606
+ return { ...cell, colSpan: run };
607
+ });
608
+ };
609
+ // The table's look as data (see hydratePdfDoc): zebra fill on every other
610
+ // plain data row (group / subtotal rows carry their own fill).
611
+ const svgridLayout = {
612
+ kind: 'table',
613
+ stripes: zebra ? [...dataRowIdx].filter((r) => r > 0 && r % 2 === 0) : [],
614
+ zebraColor,
615
+ lineColor: borderColor,
616
+ };
617
+ const tables = chunks.map((cols, i) => {
618
+ const widths = widthsFor(cols);
619
+ const body = bodyCells.map((row, bi) => sliceRow(row, bi, cols));
620
+ const headerHeight = repeatHeader ? rowHeight(body[0], cols.map((_, k) => k), widths) : 0;
621
+ const room = contentHeight - headerHeight;
622
+ const tall = body.some((row, bi) => bi > 0 && !groupRowIdx.has(bi) && rowHeight(row, cols.map((_, k) => k), widths) > room * 0.7);
623
+ return {
624
+ ...(i > 0 ? { pageBreak: 'before' } : {}),
625
+ ...(tableFont !== fontSize ? { fontSize: tableFont } : {}),
626
+ table: {
627
+ headerRows: repeatHeader ? 1 : 0,
628
+ dontBreakRows: !tall,
629
+ widths,
630
+ body,
631
+ },
632
+ svgridLayout,
633
+ };
634
+ });
389
635
  // The lines `ExportOptions.header` / `footer` carry: text with a style, an
390
636
  // image, or a left / centre / right triple, above and below the table the
391
637
  // way the xlsx writer puts them in rows around it.
@@ -433,22 +679,11 @@ export function buildPdfDocDefinition(params) {
433
679
  })),
434
680
  ],
435
681
  },
436
- layout: {
437
- hLineWidth: () => 0,
438
- vLineWidth: () => 4,
439
- vLineColor: () => '#ffffff',
440
- paddingLeft: () => 0,
441
- paddingRight: () => 0,
442
- paddingTop: () => 0,
443
- paddingBottom: () => 0,
444
- },
682
+ svgridLayout: { kind: 'kpi' },
445
683
  margin: [0, 0, 0, 10],
446
684
  });
447
685
  }
448
686
  // Charts: the resolved images, at the content width unless told otherwise.
449
- const margins = o.margins ?? [28, 34, 28, 34];
450
- const [pw, ph] = PAGE_PT[o.pageSize ?? 'A4'];
451
- const contentWidth = (orientation === 'landscape' ? ph : pw) - margins[0] - margins[2];
452
687
  const chartImages = params.charts ?? (o.charts ?? []).filter((c) => 'image' in c);
453
688
  const chartBlocks = chartImages.map((c) => ({
454
689
  stack: [
@@ -461,7 +696,7 @@ export function buildPdfDocDefinition(params) {
461
696
  }));
462
697
  if (o.chartsPosition !== 'below')
463
698
  content.push(...chartBlocks);
464
- content.push(table);
699
+ content.push(...tables);
465
700
  if (o.chartsPosition === 'below') {
466
701
  if (chartBlocks.length)
467
702
  content.push({ text: '', margin: [0, 0, 0, 12] });
@@ -471,19 +706,108 @@ export function buildPdfDocDefinition(params) {
471
706
  const def = {
472
707
  pageSize: o.pageSize ?? 'A4',
473
708
  pageOrientation: orientation,
474
- pageMargins: o.margins ?? [28, 34, 28, 34],
475
- defaultStyle: { fontSize },
709
+ pageMargins: margins,
710
+ defaultStyle: o.font ? { fontSize, font: o.font.name } : { fontSize },
476
711
  content,
712
+ info: {
713
+ ...(o.title ? { title: o.title } : {}),
714
+ ...(o.author ? { author: o.author } : {}),
715
+ ...(o.subtitle ? { subject: o.subtitle } : {}),
716
+ creator: 'SvGrid',
717
+ creationDate: now,
718
+ },
477
719
  };
720
+ if (o.watermark) {
721
+ const w = typeof o.watermark === 'string' ? { text: o.watermark } : o.watermark;
722
+ def.watermark = { text: w.text, color: w.color ?? '#94a3b8', opacity: w.opacity ?? 0.15, bold: true };
723
+ }
478
724
  if (o.showPageNumbers ?? true) {
479
- const dateStr = now.toISOString().slice(0, 10);
725
+ // The date where the export ran, not in UTC: toISOString() printed
726
+ // yesterday for anyone east of Greenwich exporting after midnight.
727
+ const pad = (v) => String(v).padStart(2, '0');
728
+ const dateStr = o.labels?.date?.(now) ?? `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
729
+ // The page label travels as a template, for a worker that cannot be
730
+ // handed the function: it is called once with stand-in numbers, which are
731
+ // then swapped for placeholders. A label that formats its numbers (a
732
+ // thousands separator) loses them, and the default text is used instead.
733
+ let page = 'Page {page} of {pages}';
734
+ if (o.labels?.page) {
735
+ const sample = o.labels.page(PAGE_STAND_IN, PAGES_STAND_IN);
736
+ if (sample.includes(String(PAGE_STAND_IN)) && sample.includes(String(PAGES_STAND_IN))) {
737
+ page = sample.split(String(PAGE_STAND_IN)).join('{page}').split(String(PAGES_STAND_IN)).join('{pages}');
738
+ }
739
+ }
740
+ def.svgridFooter = { margin: [margins[0], 6, margins[2], 0], date: dateStr, page };
741
+ }
742
+ return def;
743
+ }
744
+ const PAGE_STAND_IN = 86421;
745
+ const PAGES_STAND_IN = 97531;
746
+ /**
747
+ * Build the pdfmake document for a table and hand it over ready to render.
748
+ * Same as {@link buildPdfDocData}, with its layouts and footer turned into
749
+ * the functions pdfmake calls.
750
+ */
751
+ export function buildPdfDocDefinition(params) {
752
+ return hydratePdfDoc(buildPdfDocData(params), { page: params.opts?.labels?.page });
753
+ }
754
+ /**
755
+ * Give a document from {@link buildPdfDocData} the functions pdfmake calls:
756
+ * each table's fill and line callbacks and the page footer. Runs where the
757
+ * PDF is rendered, which may be a worker the functions could not be sent to.
758
+ * `page` overrides the footer's page template with the caller's own function.
759
+ */
760
+ export function hydratePdfDoc(doc, opts = {}) {
761
+ const visit = (node) => {
762
+ if (Array.isArray(node)) {
763
+ node.forEach(visit);
764
+ return;
765
+ }
766
+ if (!node || typeof node !== 'object')
767
+ return;
768
+ const n = node;
769
+ if (n.svgridLayout)
770
+ n.layout = layoutFor(n.svgridLayout);
771
+ if (n.stack)
772
+ visit(n.stack);
773
+ };
774
+ visit(doc.content);
775
+ const def = doc;
776
+ const footer = doc.svgridFooter;
777
+ if (footer) {
778
+ const page = opts.page ?? ((p, n) => footer.page.split('{page}').join(String(p)).split('{pages}').join(String(n)));
480
779
  def.footer = (currentPage, pageCount) => ({
481
- margin: [28, 6, 28, 0],
780
+ margin: footer.margin,
482
781
  columns: [
483
- { text: dateStr, alignment: 'left', fontSize: 7, color: '#94a3b8' },
484
- { text: `Page ${currentPage} of ${pageCount}`, alignment: 'right', fontSize: 7, color: '#94a3b8' },
782
+ { text: footer.date, alignment: 'left', fontSize: 7, color: '#94a3b8' },
783
+ { text: page(currentPage, pageCount), alignment: 'right', fontSize: 7, color: '#94a3b8' },
485
784
  ],
486
785
  });
487
786
  }
488
787
  return def;
489
788
  }
789
+ function layoutFor(spec) {
790
+ if (spec.kind === 'kpi') {
791
+ return {
792
+ hLineWidth: () => 0,
793
+ vLineWidth: () => 4,
794
+ vLineColor: () => '#ffffff',
795
+ paddingLeft: () => 0,
796
+ paddingRight: () => 0,
797
+ paddingTop: () => 0,
798
+ paddingBottom: () => 0,
799
+ };
800
+ }
801
+ const stripes = new Set(spec.stripes);
802
+ return {
803
+ fillColor: (rowIndex) => (stripes.has(rowIndex) ? spec.zebraColor : null),
804
+ hLineWidth: () => 0.5,
805
+ vLineWidth: () => 0.5,
806
+ hLineColor: () => spec.lineColor,
807
+ vLineColor: () => spec.lineColor,
808
+ paddingTop: () => 3,
809
+ paddingBottom: () => 3,
810
+ paddingLeft: () => 5,
811
+ paddingRight: () => 5,
812
+ };
813
+ }
package/dist/export.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CellFormatConfig, RowData, TableFeatures } from '@svgrid/grid';
1
+ import type { CellFormatConfig, GroupAggregator, RowData, TableFeatures } from '@svgrid/grid';
2
2
  import type { SvGridApi } from '@svgrid/grid';
3
3
  import { type CsvOptions, type SerializeProgress } from './export-serialize.js';
4
4
  import { type PdfBodyRow, type PdfExportOptions } from './export-pdf.js';
@@ -41,6 +41,12 @@ export type ExportColumn<TData = RowData> = {
41
41
  * `format`. Return a number / Date to keep it typed in xlsx.
42
42
  */
43
43
  exportValue?: (row: TData) => string | number | Date | null | undefined;
44
+ /**
45
+ * How the column totals in a grouped PDF's subtotal and total rows: the
46
+ * grid's `GroupAggregator`. Taken from the grid's column when you don't pass
47
+ * `columns`. Without one, number and currency columns sum.
48
+ */
49
+ aggregate?: GroupAggregator<TData>;
44
50
  };
45
51
  /** Progress reported during an export. `phase` is 'project' while building
46
52
  * rows, 'serialize' while writing text formats, 'write' around the xlsx/pdf
@@ -298,12 +304,24 @@ export declare function projectRows<TData extends RowData>(sourceRows: ReadonlyA
298
304
  /** A `(rowIdx, colIdx) => url` resolver from column `link` hooks, or undefined
299
305
  * when no column defines links. */
300
306
  export declare function buildLinkResolver<TData extends RowData>(cols: ReadonlyArray<ExportColumn<TData>>, rows: ReadonlyArray<TData>): ((rowIdx: number, colIdx: number) => string | undefined) | undefined;
307
+ /** The words a grouped PDF prints around its totals; see `PdfExportOptions.labels`. */
308
+ export type GroupedPdfLabels = {
309
+ subtotal?: (group: string) => string;
310
+ total?: string;
311
+ };
301
312
  /**
302
313
  * Build a grouped PDF body: a bold group header per cluster (nested for
303
- * multi-level grouping), the cluster's data rows, then a subtotal row summing
304
- * the numeric / currency columns. Values use the same formatting as the grid.
314
+ * multi-level grouping), the cluster's data rows, a subtotal row, and a grand
315
+ * total at the end. A column totals with its own `aggregate` (avg, min, max,
316
+ * count, ...), as the grid's group rows do; without one, number and currency
317
+ * columns sum. Values and group names use the same formatting as the grid.
318
+ * Each data row carries its index in `rows`, so conditional formatting and
319
+ * links can find their row.
305
320
  */
306
- export declare function buildGroupedPdfBody<TData extends RowData>(cols: ReadonlyArray<ExportColumn<TData>>, rows: ReadonlyArray<TData>, groupBy: ReadonlyArray<string>): PdfBodyRow[];
321
+ export declare function buildGroupedPdfBody<TData extends RowData>(cols: ReadonlyArray<ExportColumn<TData>>, rows: ReadonlyArray<TData>, groupBy: ReadonlyArray<string>, opts?: {
322
+ grandTotal?: boolean;
323
+ labels?: GroupedPdfLabels;
324
+ }): PdfBodyRow[];
307
325
  /**
308
326
  * Export a grid's rows to a file: xlsx, xls, pdf, csv, tsv, html, json, xml
309
327
  * or md, picked by `opts.format`. It reads the columns and rows through the