@svgrid/enterprise 3.1.25 → 3.1.27
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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +18048 -17567
- package/dist/designer/assets/{GridFooter-DKMfZpL4.js → GridFooter-EyLg77jG.js} +1 -1
- package/dist/designer/assets/{GridMenus-DWDghydL.js → GridMenus-BAgfpDd-.js} +3 -3
- package/dist/designer/assets/{SvDateRangeInput-DbfmXqJj.js → SvDateRangeInput-y8PkioQk.js} +1 -1
- package/dist/designer/assets/SvGrid.helpers-DHkH3mHW.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-BHxH4b5h.js +4 -0
- package/dist/designer/assets/{SvGridChart-DYtGHM3F.js → SvGridChart-DzCeEdlb.js} +3 -3
- package/dist/designer/assets/{SvGridChartBuilder-BjdQrnz5.js → SvGridChartBuilder-CsXVVKvA.js} +1 -1
- package/dist/designer/assets/SvGridChartPanel-Dj3IxTS2.js +4 -0
- package/dist/designer/assets/{SvGridChartView-Bfs0bvDb.js → SvGridChartView-Dwm4hxbW.js} +1 -1
- package/dist/designer/assets/{chart-export-B6bkQ3X8.js → chart-export-Dqq_eHRq.js} +4 -4
- package/dist/designer/assets/{chart-export-pdf-BbA28-gt.js → chart-export-pdf-Gw22jkxa.js} +1 -1
- package/dist/designer/assets/column-types-Bl5R-97G.js +1 -0
- package/dist/designer/assets/csv-injection-C4a0uulO.js +1 -0
- package/dist/designer/assets/export-format-NYxEY-0Y.js +6 -0
- package/dist/designer/assets/index-BU3ieI05.css +1 -0
- package/dist/designer/assets/{index-UhFdGGXv.js → index-Cim1_diz.js} +340 -330
- package/dist/designer/assets/src-D23PPsK1.js +1 -0
- package/dist/designer/assets/src-sFRH_up7.js +60 -0
- package/dist/designer/index.html +13 -11
- package/dist/export-conditional.d.ts +2 -0
- package/dist/export-conditional.js +2 -0
- package/dist/export-pdf.d.ts +142 -7
- package/dist/export-pdf.js +368 -44
- package/dist/export.d.ts +22 -4
- package/dist/export.js +150 -53
- package/dist/import.js +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node/studio.js +20 -12
- package/dist/pdf-worker.d.ts +92 -0
- package/dist/pdf-worker.js +147 -0
- package/dist/sheet/ods-document.js +3 -1
- package/dist/sheet/open-file.js +4 -2
- package/dist/sheet/xlsx-document.d.ts +2 -2
- package/dist/sheet/xlsx-document.js +18 -2
- package/dist/studio/emit-schema.js +18 -10
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/wc/{GridMenus-h7RsvcmI.js → GridMenus-BIQXB6a7.js} +1 -1
- package/dist/wc/{SvDateRangeInput-CJZohgBe.js → SvDateRangeInput-DHYuAJeY.js} +1 -0
- package/dist/wc/SvGridCellEditor-CZwhQx0a.js +584 -0
- package/dist/wc/{SvGridChart-DWRqlhCV.js → SvGridChart-DcfmVvzB.js} +3 -3
- package/dist/wc/{SvGridChartBuilder-BXW3yUR0.js → SvGridChartBuilder-1Z69mxbM.js} +2 -2
- package/dist/wc/{SvGridChartPanel-28n4tomx.js → SvGridChartPanel-BaVsa-T-.js} +5 -5
- package/dist/wc/{SvGridChartView-CbG_WrU-.js → SvGridChartView-LEC8ZNeU.js} +1 -1
- package/dist/wc/{chart-export-CeSADGfi.js → chart-export-C1KSVdMU.js} +29 -28
- package/dist/wc/{chart-export-pdf-BGko7MER.js → chart-export-pdf-CmEd365D.js} +1 -1
- package/dist/wc/csv-injection-Ce26_TOb.js +8 -0
- package/dist/wc/sv-sheet-element.js +6275 -6215
- package/dist/xlsx-limits.d.ts +51 -0
- package/dist/xlsx-limits.js +89 -0
- package/package.json +7 -3
- package/dist/designer/assets/SvGrid.helpers-BF2QRcNt.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-od4VbndV.js +0 -4
- package/dist/designer/assets/SvGridChartPanel-Cm9_K7BV.js +0 -4
- package/dist/designer/assets/export-format-BCuqcexA.js +0 -6
- package/dist/designer/assets/index-CHx3F4Ij.css +0 -1
- package/dist/designer/assets/src-CITpcm6b.js +0 -1
- package/dist/designer/assets/src-fddEt37f.js +0 -58
- package/dist/wc/SvGridCellEditor-C7ios4i_.js +0 -582
package/dist/export-pdf.js
CHANGED
|
@@ -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
|
-
*
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
780
|
+
margin: footer.margin,
|
|
482
781
|
columns: [
|
|
483
|
-
{ text:
|
|
484
|
-
{ text:
|
|
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,
|
|
304
|
-
* the
|
|
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
|
|
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
|