@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
package/dist/export.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { applyGroupAggregate } from '@svgrid/grid/core';
1
2
  // Import the pure formatters from the grid's Svelte-free `/format` subpath so
2
3
  // this module (and the enterprise unit tests) don't drag in SvGrid.svelte.
3
4
  import { formatValueForExport, toExcelNumFmt, valueForExcel } from '@svgrid/grid/format';
@@ -6,7 +7,10 @@ import { installSmartShim } from './smart-shim.js';
6
7
  import { downloadBlobFile, downloadTextFile, serializeDelimited, serializeHtml, serializeJson, serializeMarkdown, serializeXml, } from './export-serialize.js';
7
8
  import { serializeSpreadsheetML } from './export-xls.js';
8
9
  import { buildXlsxParts, packageXlsx } from './export-ooxml.js';
9
- import { buildPdfDocDefinition, pdfBlob, readGridPdfTheme, registerPdfFonts, resolvePdfCharts } from './export-pdf.js';
10
+ import { buildPdfDocData, buildPdfDocDefinition, pdfBlob, PDF_ROBOTO_MISSING, readGridPdfTheme, registerPdfFont, registerPdfFonts, resolvePdfCharts } from './export-pdf.js';
11
+ import { renderPdfInWorker } from './pdf-worker.js';
12
+ /** Rows past which a PDF rendered on the page earns a warning: about a second of blocked page per thousand. */
13
+ const LARGE_PDF_ROWS = 2000;
10
14
  import { buildConditionalResolver } from './export-conditional.js';
11
15
  /** Formats served by the native, dependency-free, streaming serializers. */
12
16
  const NATIVE_TEXT_FORMATS = [
@@ -102,6 +106,7 @@ export function resolveColumns(api, opts, sourceRows) {
102
106
  header: c.header,
103
107
  format: c.format,
104
108
  align: c.align,
109
+ aggregate: c.aggregate,
105
110
  }));
106
111
  }
107
112
  if (sourceRows.length > 0) {
@@ -550,27 +555,58 @@ function isSummableColumn(col) {
550
555
  }
551
556
  /**
552
557
  * Build a grouped PDF body: a bold group header per cluster (nested for
553
- * multi-level grouping), the cluster's data rows, then a subtotal row summing
554
- * the numeric / currency columns. Values use the same formatting as the grid.
558
+ * multi-level grouping), the cluster's data rows, a subtotal row, and a grand
559
+ * total at the end. A column totals with its own `aggregate` (avg, min, max,
560
+ * count, ...), as the grid's group rows do; without one, number and currency
561
+ * columns sum. Values and group names use the same formatting as the grid.
562
+ * Each data row carries its index in `rows`, so conditional formatting and
563
+ * links can find their row.
555
564
  */
556
- export function buildGroupedPdfBody(cols, rows, groupBy) {
565
+ export function buildGroupedPdfBody(cols, rows, groupBy, opts = {}) {
557
566
  const out = [];
558
- const headerOf = (field) => cols.find((c) => c.field === field)?.header ?? field;
559
- // Put the "Subtotal" label in the first NON-numeric column; if every column
560
- // is numeric, -1 means "don't overwrite a sum with the label".
561
- const labelColIdx = cols.findIndex((c) => !isSummableColumn(c));
567
+ const colOf = (field) => cols.find((c) => c.field === field);
568
+ const aggOf = (c) => c.aggregate ?? (isSummableColumn(c) ? 'sum' : undefined);
569
+ const hasTotals = cols.some((c) => aggOf(c) !== undefined);
570
+ // The label goes in the first column that has no total of its own; if
571
+ // every column has one, -1 means "don't overwrite a total with the label".
572
+ const labelColIdx = cols.findIndex((c) => aggOf(c) === undefined);
573
+ const index = new Map();
574
+ rows.forEach((r, i) => index.set(r, i));
575
+ const field = (r, f) => r[f];
576
+ const total = (c, subset) => {
577
+ const agg = aggOf(c);
578
+ if (agg === undefined)
579
+ return '';
580
+ // The grid's own aggregator, over the same values the column shows.
581
+ const v = applyGroupAggregate(agg, c.field, subset.map((r) => ({ original: r, getCellValueByColumnId: () => field(r, c.field) })));
582
+ if (v == null || (typeof v === 'number' && !Number.isFinite(v)))
583
+ return '';
584
+ // A count is a number of rows, not an amount: no currency or percent.
585
+ if (agg === 'count' || agg === 'countDistinct')
586
+ return String(v);
587
+ if (Array.isArray(v))
588
+ return v.map((x) => formatValueForExport(x, c.format)).join(' - ');
589
+ return formatValueForExport(v, c.format);
590
+ };
591
+ const totalRow = (subset, label) => {
592
+ const cells = cols.map((c) => total(c, subset));
593
+ if (labelColIdx >= 0)
594
+ cells[labelColIdx] = label;
595
+ return { kind: 'subtotal', cells };
596
+ };
562
597
  const recurse = (subset, level) => {
563
598
  if (level >= groupBy.length) {
564
599
  for (const r of subset) {
565
- out.push({ kind: 'data', cells: cols.map((c) => String(cellValueFor(c, r, false) ?? '')) });
600
+ out.push({ kind: 'data', cells: cols.map((c) => String(cellValueFor(c, r, false) ?? '')), source: index.get(r) });
566
601
  }
567
602
  return;
568
603
  }
569
- const field = groupBy[level];
604
+ const groupField = groupBy[level];
605
+ const groupCol = colOf(groupField);
570
606
  // Preserve first-seen order of group keys.
571
607
  const buckets = new Map();
572
608
  for (const r of subset) {
573
- const key = String(r[field] ?? '');
609
+ const key = String(field(r, groupField) ?? '');
574
610
  const bucket = buckets.get(key);
575
611
  if (bucket)
576
612
  bucket.push(r);
@@ -578,26 +614,31 @@ export function buildGroupedPdfBody(cols, rows, groupBy) {
578
614
  buckets.set(key, [r]);
579
615
  }
580
616
  for (const [key, groupRows] of buckets) {
581
- out.push({ kind: 'group', label: `${headerOf(field)}: ${key} (${groupRows.length})`, level });
617
+ // The name as the grid shows it: a date group reads "Oct 3, 2026", not
618
+ // the timestamp it is keyed by.
619
+ const shown = groupCol ? String(cellValueFor(groupCol, groupRows[0], false) ?? key) : key;
620
+ out.push({ kind: 'group', label: `${groupCol?.header ?? groupField}: ${shown} (${groupRows.length})`, level });
582
621
  recurse(groupRows, level + 1);
583
- // Subtotal row for the numeric columns of this cluster.
584
- const hasSummable = cols.some(isSummableColumn);
585
- if (hasSummable) {
586
- const cells = cols.map((c) => {
587
- if (!isSummableColumn(c))
588
- return '';
589
- const sum = groupRows.reduce((a, r) => a + (Number(r[c.field]) || 0), 0);
590
- return formatValueForExport(sum, c.format);
591
- });
592
- if (labelColIdx >= 0)
593
- cells[labelColIdx] = `Subtotal (${key})`;
594
- out.push({ kind: 'subtotal', cells });
595
- }
622
+ if (hasTotals)
623
+ out.push(totalRow(groupRows, opts.labels?.subtotal?.(shown) ?? `Subtotal (${shown})`));
596
624
  }
597
625
  };
598
626
  recurse(rows, 0);
627
+ if (hasTotals && opts.grandTotal !== false && rows.length)
628
+ out.push(totalRow(rows, opts.labels?.total ?? 'Total'));
599
629
  return out;
600
630
  }
631
+ /**
632
+ * Say so when the PDF holds text Roboto cannot draw. Without a `pdf.font` it
633
+ * prints as empty boxes, and nothing else would tell the developer why.
634
+ */
635
+ function warnMissingGlyphs(texts) {
636
+ const hit = texts.find((t) => PDF_ROBOTO_MISSING.test(t));
637
+ if (hit === undefined)
638
+ return;
639
+ console.warn(`@svgrid/enterprise: the PDF has text its default font (Roboto) cannot draw, such as "${hit.slice(0, 40)}"; ` +
640
+ 'it prints as empty boxes. Pass pdf.font with a font that covers it (Noto Sans CJK, Noto Sans Arabic, ...).');
641
+ }
601
642
  /**
602
643
  * PDF export via our own pdfmake docDefinition (page setup, repeated header,
603
644
  * page numbers, per-column alignment/widths, title/logo, theme colors, and
@@ -605,18 +646,17 @@ export function buildGroupedPdfBody(cols, rows, groupBy) {
605
646
  * formatted display strings, so the PDF matches the grid.
606
647
  */
607
648
  async function exportPdf(api, opts) {
608
- await ensureGlobals('pdf');
609
- const pdfMake = globalThis.pdfMake;
610
- if (!pdfMake) {
611
- throw new Error('@svgrid/enterprise: pdf export requires the "pdfmake" peer dependency.');
612
- }
613
649
  const sourceRows = resolveRowSource(api, opts.rows);
614
650
  if (sourceRows.length === 0) {
615
651
  throw new Error('@svgrid/enterprise: nothing to export - the grid has no rows');
616
652
  }
617
653
  const cols = resolveColumns(api, opts, sourceRows);
618
654
  const fields = cols.map((c) => c.field);
619
- const columns = cols.map((c) => ({ header: c.header ?? c.field, align: c.align }));
655
+ // The grid's columns arrive with the alignment the screen uses; columns an
656
+ // app passes itself may not say, so a number or date format right-aligns,
657
+ // as it does on screen.
658
+ const alignOf = (c) => c.align ?? (c.format && ['number', 'currency', 'percent', 'date', 'datetime', 'time'].includes(String(c.format.type)) ? 'right' : undefined);
659
+ const columns = cols.map((c) => ({ header: c.header ?? c.field, align: alignOf(c) }));
620
660
  // Carry the grid's active grouping into group + subtotal rows (unless the
621
661
  // caller opted out or passed hierarchical), matching the xlsx outline path.
622
662
  const autoGrouping = opts.autoGroup !== false && !opts.groupBy?.length && !opts.hierarchical
@@ -648,33 +688,67 @@ async function exportPdf(api, opts) {
648
688
  return chartToPngBlob(el, { scale: 2 });
649
689
  });
650
690
  const now = new Date();
651
- let def;
691
+ let params;
692
+ // A boolean prints as the grid's checkbox does: a tick for true and nothing
693
+ // for false, rather than the words. Only plain fields: a column with a
694
+ // format or an `exportValue` prints what it asks for.
695
+ const boolAt = (rowIdx, colIdx) => {
696
+ const col = cols[colIdx];
697
+ if (!col || col.exportValue || col.format)
698
+ return undefined;
699
+ const v = sourceRows[rowIdx]?.[col.field];
700
+ return typeof v === 'boolean' ? v : undefined;
701
+ };
702
+ const withTick = (v, b) => {
703
+ const icon = v?.icon ?? (b ? '✓' : undefined);
704
+ return v || icon ? { fill: v?.fill, color: v?.color, bold: v?.bold, icon, iconOnly: v?.iconOnly } : undefined;
705
+ };
652
706
  if (effGroupBy?.length) {
653
- // Conditional formatting is skipped for grouped PDFs (group / subtotal
654
- // rows own the layout); the grid colors still show on screen.
655
- const body = buildGroupedPdfBody(cols, sourceRows, effGroupBy);
656
- def = buildPdfDocDefinition({ columns, body, opts: pdfOpts, styles: opts.styles, theme, ...lines, charts, now });
707
+ const grouped = buildGroupedPdfBody(cols, sourceRows, effGroupBy, { grandTotal: pdfOpts.grandTotal, labels: pdfOpts.labels });
708
+ const body = grouped.map((row) => row.kind === 'data' && row.source !== undefined
709
+ ? { ...row, cells: row.cells.map((text, c) => (boolAt(row.source, c) === undefined ? text : '')) }
710
+ : row);
711
+ if (!pdfOpts.font)
712
+ warnMissingGlyphs(body.flatMap((r) => (r.kind === 'group' ? [r.label] : r.cells)));
713
+ // Data rows come out in group order; conditional formatting and links are
714
+ // looked up by each row's place in the exported rows.
715
+ const sources = body.flatMap((r) => (r.kind === 'data' ? [r.source ?? -1] : []));
716
+ const visual = buildConditionalResolver(cols, sourceRows, opts.conditionalFormats);
717
+ const link = buildLinkResolver(cols, sourceRows);
718
+ params = {
719
+ columns,
720
+ body,
721
+ dataCellStyle: (r, c) => {
722
+ const at = sources[r] ?? -1;
723
+ return at >= 0 ? withTick(visual(at, c), boolAt(at, c)) : undefined;
724
+ },
725
+ dataCellLink: link
726
+ ? (r, c) => {
727
+ const at = sources[r] ?? -1;
728
+ return at >= 0 ? link(at, c) : undefined;
729
+ }
730
+ : undefined,
731
+ opts: pdfOpts,
732
+ styles: opts.styles,
733
+ theme,
734
+ ...lines,
735
+ charts,
736
+ now,
737
+ };
657
738
  }
658
739
  else {
659
740
  const projected = await projectRows(sourceRows, cols, opts);
660
- const dataRows = projected.slice(1).map((r) => fields.map((f) => String(r[f] ?? '')));
661
- // Conditional formatting -> per-cell fill / color / bold + icon prefix.
741
+ const dataRows = projected.slice(1).map((r, ri) => fields.map((f, c) => (boolAt(ri, c) === undefined ? String(r[f] ?? '') : '')));
742
+ // Conditional formatting -> per-cell fill / color / bold, and the icon,
743
+ // which the builder draws (the icon sets' glyphs are not in the font).
662
744
  const visual = buildConditionalResolver(cols, sourceRows, opts.conditionalFormats);
663
- for (let r = 0; r < dataRows.length; r++) {
664
- for (let c = 0; c < cols.length; c++) {
665
- const icon = visual(r, c)?.icon;
666
- if (icon)
667
- dataRows[r][c] = `${icon} ${dataRows[r][c] ?? ''}`;
668
- }
669
- }
670
745
  const link = buildLinkResolver(cols, sourceRows);
671
- def = buildPdfDocDefinition({
746
+ if (!pdfOpts.font)
747
+ warnMissingGlyphs([...columns.map((c) => c.header), ...dataRows.flat()]);
748
+ params = {
672
749
  columns,
673
750
  rows: dataRows,
674
- dataCellStyle: (r, c) => {
675
- const v = visual(r, c);
676
- return v ? { fill: v.fill, color: v.color, bold: v.bold } : undefined;
677
- },
751
+ dataCellStyle: (r, c) => withTick(visual(r, c), boolAt(r, c)),
678
752
  dataCellLink: link,
679
753
  opts: pdfOpts,
680
754
  styles: opts.styles,
@@ -683,11 +757,34 @@ async function exportPdf(api, opts) {
683
757
  merges: opts.merges,
684
758
  charts,
685
759
  now,
686
- });
760
+ };
687
761
  }
688
762
  const filename = (opts.filename ?? 'grid').trim() || 'grid';
689
- const blob = await pdfBlob(pdfMake.createPdf(def));
690
- opts.onProgress?.({ phase: 'write', ratio: 1, total: sourceRows.length });
763
+ const total = sourceRows.length;
764
+ let blob;
765
+ if (pdfOpts.worker) {
766
+ // The document crosses to the worker as data; pdfmake is loaded there.
767
+ blob = await renderPdfInWorker(pdfOpts.worker, buildPdfDocData(params), {
768
+ font: pdfOpts.font,
769
+ signal: opts.signal,
770
+ onProgress: (ratio) => opts.onProgress?.({ phase: 'write', ratio, total }),
771
+ });
772
+ }
773
+ else {
774
+ if (total > LARGE_PDF_ROWS) {
775
+ console.warn(`@svgrid/enterprise: rendering a ${total.toLocaleString('en-US')}-row PDF on the page; pdfmake blocks it until done ` +
776
+ '(about a second per thousand rows). Pass pdf.worker to render in a worker instead.');
777
+ }
778
+ await ensureGlobals('pdf');
779
+ const pdfMake = globalThis.pdfMake;
780
+ if (!pdfMake) {
781
+ throw new Error('@svgrid/enterprise: pdf export requires the "pdfmake" peer dependency.');
782
+ }
783
+ if (pdfOpts.font)
784
+ registerPdfFont(pdfMake, pdfOpts.font);
785
+ blob = await pdfBlob(pdfMake.createPdf(buildPdfDocDefinition(params)));
786
+ }
787
+ opts.onProgress?.({ phase: 'write', ratio: 1, total });
691
788
  return deliver(blob, ensureExt(filename, 'pdf'), 'application/pdf', sourceRows.length, opts.download);
692
789
  }
693
790
  /**
package/dist/import.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * `print`, so unlicensed evaluation works but emits a one-time nudge.
25
25
  */
26
26
  import { assertEnterpriseLicensed } from './license.js';
27
- import { XLSX_MAX_COLS, XLSX_MAX_FILL, xlsxFillError, xlsxRangeError } from './xlsx-limits.js';
27
+ import { createUnzipBudget, readZipEntry, XLSX_MAX_COLS, XLSX_MAX_FILL, xlsxFillError, xlsxRangeError } from './xlsx-limits.js';
28
28
  // ---------------------------------------------------------------------------
29
29
  // Lazy peer dependency: jszip (xlsx only)
30
30
  // ---------------------------------------------------------------------------
@@ -183,11 +183,12 @@ async function parseXlsx(file) {
183
183
  const J = await getJSZip();
184
184
  const buf = await file.arrayBuffer();
185
185
  const zip = await J.loadAsync(buf);
186
+ const budget = createUnzipBudget();
186
187
  // Read sharedStrings.xml if it exists.
187
188
  let sharedStrings = [];
188
189
  const ssEntry = zip.file('xl/sharedStrings.xml');
189
190
  if (ssEntry) {
190
- const ssText = await ssEntry.async('string');
191
+ const ssText = await readZipEntry(ssEntry, 'string', budget);
191
192
  sharedStrings = parseSharedStrings(ssText);
192
193
  }
193
194
  // Read the FIRST sheet. Workbook.xml -> rels mapping is involved; for the
@@ -197,7 +198,7 @@ async function parseXlsx(file) {
197
198
  if (!sheetEntry) {
198
199
  throw new Error('@svgrid/enterprise: could not locate sheet1.xml in the .xlsx archive');
199
200
  }
200
- const sheetText = await sheetEntry.async('string');
201
+ const sheetText = await readZipEntry(sheetEntry, 'string', budget);
201
202
  return parseSheetXml(sheetText, sharedStrings);
202
203
  }
203
204
  /** Pull every `<t>...</t>` out of sharedStrings.xml in document order. */
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { setLicenseKey, clearLicenseKey, getLicenseKey, isLicenseKeySet, hasVali
2
2
  export { parseLicenseExpiry } from './license-core.js';
3
3
  export { exportGrid, copyExportToClipboard, type ExportFormat, type ExportOptions, type ExportColumn, type ExportCellStyle, type ExportStyles, type ExportHeaderFooterLine, type ExportSheet, type ExportRowSource, type ExportProgress, type ExportResult, type ClipboardFormat, type ClipboardExportOptions, } from './export.js';
4
4
  export { default as SvExportMenu } from './SvExportMenu.svelte';
5
- export { buildPdfDocDefinition, resolvePdfCharts, resolvePdfVfs, registerPdfFonts, type PdfExportOptions, type PdfPageSize, type PdfChart, type PdfKpi, type PdfChartImage, type PdfVirtualFileSystem, type PdfMakeLike } from './export-pdf.js';
5
+ export { buildPdfDocDefinition, buildPdfDocData, hydratePdfDoc, resolvePdfCharts, resolvePdfVfs, registerPdfFonts, registerPdfFont, type PdfExportOptions, type PdfDocData, type PdfDocParams, type PdfWorkerLike, type PdfPageSize, type PdfChart, type PdfKpi, type PdfChartImage, type PdfVirtualFileSystem, type PdfMakeLike, type PdfFont } from './export-pdf.js';
6
6
  export { printGrid, type PrintOptions } from './print.js';
7
7
  export { importData, readImportMatrix, mapImportMatrix, mapImportMatrixAsync, autoMapColumns, inferImportColumnTypes, type ImportFormat, type ImportFieldType, type ImportOptions, type ImportResult, type ImportProgress, type ImportColumnMap, type ImportColumnTypes, type ImportGridColumn, type ImportRowError, type ImportValidator, } from './import.js';
8
8
  export { default as SvImportDialog } from './SvImportDialog.svelte';
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ licenseCovers, getLicenseEdition, nudgeEnterpriseFor, editionCovers, } from './l
8
8
  export { parseLicenseExpiry } from './license-core.js';
9
9
  export { exportGrid, copyExportToClipboard, } from './export.js';
10
10
  export { default as SvExportMenu } from './SvExportMenu.svelte';
11
- export { buildPdfDocDefinition, resolvePdfCharts, resolvePdfVfs, registerPdfFonts } from './export-pdf.js';
11
+ export { buildPdfDocDefinition, buildPdfDocData, hydratePdfDoc, resolvePdfCharts, resolvePdfVfs, registerPdfFonts, registerPdfFont } from './export-pdf.js';
12
12
  export { printGrid } from './print.js';
13
13
  export { importData, readImportMatrix, mapImportMatrix, mapImportMatrixAsync, autoMapColumns, inferImportColumnTypes, } from './import.js';
14
14
  export { default as SvImportDialog } from './SvImportDialog.svelte';
@@ -16514,7 +16514,7 @@ function hasFieldConditions(schema) {
16514
16514
  *
16515
16515
  * `version.test.ts` asserts this matches `package.json`, so the two cannot drift.
16516
16516
  */
16517
- var SVGRID_VERSION = "3.1.26";
16517
+ var SVGRID_VERSION = "3.1.28";
16518
16518
  /**
16519
16519
  * The version of `@svgrid/grid`, which releases on its own line: enterprise is
16520
16520
  * on 3.1.x while the grid is on 3.0.x.
@@ -16527,7 +16527,7 @@ var SVGRID_VERSION = "3.1.26";
16527
16527
  *
16528
16528
  * `version.test.ts` asserts this matches packages/grid/package.json.
16529
16529
  */
16530
- var GRID_VERSION = "3.0.23";
16530
+ var GRID_VERSION = "3.0.25";
16531
16531
  //#endregion
16532
16532
  //#region src/studio/emit-project.ts
16533
16533
  var has = (blocks, kind) => blocks.some((b) => b.config.kind === kind);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Render a PDF export in a worker, so a large report does not freeze the
3
+ * page. pdfmake lays out and writes the whole document in one synchronous
4
+ * run, about a millisecond per row of eight columns on a desktop machine:
5
+ * a 20,000-row export held the main thread for over twenty seconds.
6
+ *
7
+ * The app owns the worker file, so any bundler builds it:
8
+ *
9
+ * ```ts
10
+ * // pdf.worker.ts
11
+ * import { servePdfWorker } from '@svgrid/enterprise/pdf-worker'
12
+ * servePdfWorker()
13
+ * ```
14
+ *
15
+ * ```ts
16
+ * await api.exportData({
17
+ * format: 'pdf',
18
+ * pdf: { worker: () => new Worker(new URL('./pdf.worker.ts', import.meta.url), { type: 'module' }) },
19
+ * })
20
+ * ```
21
+ *
22
+ * The main thread builds the document as plain data (`buildPdfDocData`) and
23
+ * posts it here with the font, if any; the worker loads pdfmake, attaches
24
+ * the layout and footer functions (`hydratePdfDoc`), renders, and posts the
25
+ * bytes back with progress along the way.
26
+ */
27
+ import { type PdfDocData, type PdfFont, type PdfMakeLike, type PdfWorkerLike } from './export-pdf.js';
28
+ export type { PdfWorkerLike };
29
+ /** Tags this channel's messages, so a worker shared with other traffic ignores the rest. */
30
+ export declare const PDF_WORKER_CHANNEL = "svgrid-pdf";
31
+ export type PdfWorkerRequest = {
32
+ channel: typeof PDF_WORKER_CHANNEL;
33
+ id: number;
34
+ doc: PdfDocData;
35
+ font?: PdfFont;
36
+ };
37
+ export type PdfWorkerReply = {
38
+ channel: typeof PDF_WORKER_CHANNEL;
39
+ id: number;
40
+ progress: number;
41
+ } | {
42
+ channel: typeof PDF_WORKER_CHANNEL;
43
+ id: number;
44
+ pdf: ArrayBuffer;
45
+ } | {
46
+ channel: typeof PDF_WORKER_CHANNEL;
47
+ id: number;
48
+ error: string;
49
+ };
50
+ /** The pdfmake calls rendering needs, in either the 0.2 or the 0.3 shape. */
51
+ export type PdfMakeRenderer = PdfMakeLike & {
52
+ fonts?: Record<string, unknown>;
53
+ addFonts?: (fonts: Record<string, unknown>) => void;
54
+ createPdf(def: unknown): {
55
+ getBuffer(cb?: (bytes: Uint8Array) => void, options?: {
56
+ progressCallback?: (progress: number) => void;
57
+ }): unknown;
58
+ };
59
+ };
60
+ /** The part of a worker's global scope (or a `MessagePort`) this needs. */
61
+ export type PdfWorkerScope = {
62
+ postMessage(message: unknown, transfer?: Transferable[]): void;
63
+ addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
64
+ removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
65
+ };
66
+ /**
67
+ * Render a document from `buildPdfDocData` with the pdfmake given. The
68
+ * worker's half of the export, exported so it can run anywhere pdfmake does.
69
+ */
70
+ export declare function renderPdfDoc(doc: PdfDocData, pdfMake: PdfMakeRenderer, opts?: {
71
+ font?: PdfFont;
72
+ onProgress?: (progress: number) => void;
73
+ }): Promise<Uint8Array>;
74
+ export type ServePdfWorkerOptions = {
75
+ /** Where to listen. Default: the worker's global scope. */
76
+ scope?: PdfWorkerScope;
77
+ /** How to get pdfmake. Default: import the installed `pdfmake` peer. */
78
+ loadPdfMake?: () => Promise<PdfMakeRenderer>;
79
+ };
80
+ /** Answer PDF export requests from the main thread. Call it once, in the worker. */
81
+ export declare function servePdfWorker(options?: ServePdfWorkerOptions): {
82
+ dispose(): void;
83
+ };
84
+ /**
85
+ * The main thread's half: post the document to a fresh worker, report its
86
+ * progress, and resolve with the PDF. An abort terminates the worker.
87
+ */
88
+ export declare function renderPdfInWorker(makeWorker: () => PdfWorkerLike, doc: PdfDocData, opts?: {
89
+ font?: PdfFont;
90
+ signal?: AbortSignal;
91
+ onProgress?: (progress: number) => void;
92
+ }): Promise<Blob>;
@@ -0,0 +1,147 @@
1
+ /// <reference path="./pdfmake-shims.d.ts" />
2
+ /**
3
+ * Render a PDF export in a worker, so a large report does not freeze the
4
+ * page. pdfmake lays out and writes the whole document in one synchronous
5
+ * run, about a millisecond per row of eight columns on a desktop machine:
6
+ * a 20,000-row export held the main thread for over twenty seconds.
7
+ *
8
+ * The app owns the worker file, so any bundler builds it:
9
+ *
10
+ * ```ts
11
+ * // pdf.worker.ts
12
+ * import { servePdfWorker } from '@svgrid/enterprise/pdf-worker'
13
+ * servePdfWorker()
14
+ * ```
15
+ *
16
+ * ```ts
17
+ * await api.exportData({
18
+ * format: 'pdf',
19
+ * pdf: { worker: () => new Worker(new URL('./pdf.worker.ts', import.meta.url), { type: 'module' }) },
20
+ * })
21
+ * ```
22
+ *
23
+ * The main thread builds the document as plain data (`buildPdfDocData`) and
24
+ * posts it here with the font, if any; the worker loads pdfmake, attaches
25
+ * the layout and footer functions (`hydratePdfDoc`), renders, and posts the
26
+ * bytes back with progress along the way.
27
+ */
28
+ import { hydratePdfDoc, registerPdfFont, registerPdfFonts } from './export-pdf.js';
29
+ /** Tags this channel's messages, so a worker shared with other traffic ignores the rest. */
30
+ export const PDF_WORKER_CHANNEL = 'svgrid-pdf';
31
+ /**
32
+ * Render a document from `buildPdfDocData` with the pdfmake given. The
33
+ * worker's half of the export, exported so it can run anywhere pdfmake does.
34
+ */
35
+ export function renderPdfDoc(doc, pdfMake, opts = {}) {
36
+ if (opts.font)
37
+ registerPdfFont(pdfMake, opts.font);
38
+ const def = hydratePdfDoc(doc);
39
+ return new Promise((resolve, reject) => {
40
+ const done = (bytes) => resolve(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
41
+ try {
42
+ // 0.2 calls back; 0.3 returns a promise and ignores the callback.
43
+ const returned = pdfMake.createPdf(def).getBuffer(done, { progressCallback: opts.onProgress });
44
+ if (returned && typeof returned.then === 'function') {
45
+ ;
46
+ returned.then(done, reject);
47
+ }
48
+ }
49
+ catch (err) {
50
+ reject(err);
51
+ }
52
+ });
53
+ }
54
+ async function loadPdfMake() {
55
+ // @ts-ignore - "pdfmake" is an optional peerDependency
56
+ const mod = (await import('pdfmake/build/pdfmake'));
57
+ // @ts-ignore - "pdfmake" is an optional peerDependency
58
+ const vfs = await import('pdfmake/build/vfs_fonts');
59
+ const pdfMake = (mod.default ?? mod);
60
+ if (!registerPdfFonts(pdfMake, vfs)) {
61
+ throw new Error('"pdfmake/build/vfs_fonts" did not contain a font map; the installed pdfmake is not one this export knows.');
62
+ }
63
+ // createPdf reads `pdfMake.tableLayouts` off the global, and a bundled
64
+ // import never sets that global, so without this every render failed with
65
+ // "Cannot read properties of undefined (reading 'tableLayouts')". The page
66
+ // side does the same in ensureGlobals.
67
+ ;
68
+ globalThis.pdfMake = pdfMake;
69
+ return pdfMake;
70
+ }
71
+ /** Answer PDF export requests from the main thread. Call it once, in the worker. */
72
+ export function servePdfWorker(options = {}) {
73
+ const scope = options.scope ?? globalThis;
74
+ const load = options.loadPdfMake ?? loadPdfMake;
75
+ let pdfMake = null;
76
+ const reply = (message, transfer) => scope.postMessage(message, transfer);
77
+ const listener = async (event) => {
78
+ const msg = event.data;
79
+ if (!msg || msg.channel !== PDF_WORKER_CHANNEL || typeof msg.id !== 'number' || !msg.doc)
80
+ return;
81
+ const { id, doc, font } = msg;
82
+ try {
83
+ pdfMake ?? (pdfMake = load());
84
+ // pdfmake reports progress per item drawn: a 5,000-row table sent
85
+ // 79,129 messages. One per percent is enough for a progress bar.
86
+ let sent = -1;
87
+ const bytes = await renderPdfDoc(doc, await pdfMake, {
88
+ font,
89
+ onProgress: (progress) => {
90
+ if (progress < 1 && progress - sent < 0.01)
91
+ return;
92
+ sent = progress;
93
+ reply({ channel: PDF_WORKER_CHANNEL, id, progress });
94
+ },
95
+ });
96
+ // A copy of exactly these bytes, handed over rather than cloned.
97
+ const pdf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
98
+ reply({ channel: PDF_WORKER_CHANNEL, id, pdf }, [pdf]);
99
+ }
100
+ catch (err) {
101
+ pdfMake = null;
102
+ reply({ channel: PDF_WORKER_CHANNEL, id, error: err instanceof Error ? err.message : String(err) });
103
+ }
104
+ };
105
+ scope.addEventListener('message', listener);
106
+ return { dispose: () => scope.removeEventListener('message', listener) };
107
+ }
108
+ let nextJob = 1;
109
+ /**
110
+ * The main thread's half: post the document to a fresh worker, report its
111
+ * progress, and resolve with the PDF. An abort terminates the worker.
112
+ */
113
+ export function renderPdfInWorker(makeWorker, doc, opts = {}) {
114
+ const worker = makeWorker();
115
+ const id = nextJob++;
116
+ return new Promise((resolve, reject) => {
117
+ const finish = () => {
118
+ opts.signal?.removeEventListener('abort', onAbort);
119
+ worker.terminate();
120
+ };
121
+ const onAbort = () => {
122
+ finish();
123
+ reject(new DOMException('Export aborted', 'AbortError'));
124
+ };
125
+ if (opts.signal?.aborted)
126
+ return onAbort();
127
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
128
+ worker.addEventListener('message', (event) => {
129
+ const msg = event.data;
130
+ if (!msg || msg.channel !== PDF_WORKER_CHANNEL || msg.id !== id)
131
+ return;
132
+ if ('progress' in msg)
133
+ return opts.onProgress?.(msg.progress);
134
+ finish();
135
+ if ('pdf' in msg)
136
+ resolve(new Blob([msg.pdf], { type: 'application/pdf' }));
137
+ else
138
+ reject(new Error(`@svgrid/enterprise: the PDF worker failed: ${msg.error}`));
139
+ });
140
+ worker.addEventListener('error', (event) => {
141
+ finish();
142
+ reject(new Error(`@svgrid/enterprise: the PDF worker failed to start or crashed: ${event.message || 'no message'}. Check that it calls servePdfWorker().`));
143
+ });
144
+ const request = { channel: PDF_WORKER_CHANNEL, id, doc, ...(opts.font ? { font: opts.font } : {}) };
145
+ worker.postMessage(request);
146
+ });
147
+ }
@@ -17,6 +17,7 @@ import { colToLetters, lettersToCol } from './address.js';
17
17
  import { tokenize } from './tokenize.js';
18
18
  import { formatKeyAt } from './format-store.js';
19
19
  import { linkAt } from './links.js';
20
+ import { createUnzipBudget, readZipEntry } from '../xlsx-limits.js';
20
21
  import { threadAt } from './comments.js';
21
22
  import { loadZip } from './xlsx-document.js';
22
23
  import { accountingParts, accountingPattern } from './number-format.js';
@@ -747,6 +748,7 @@ function durationSeconds(text) {
747
748
  export async function documentFromOds(file, JSZip) {
748
749
  const Zip = await loadZip(JSZip);
749
750
  const zip = await Zip.loadAsync(file);
751
+ const budget = createUnzipBudget();
750
752
  const parts = {};
751
753
  const pending = [];
752
754
  zip.forEach((path, entry) => {
@@ -755,7 +757,7 @@ export async function documentFromOds(file, JSZip) {
755
757
  if (!/\.(xml)$/i.test(path))
756
758
  return;
757
759
  const clean = path.replace(/^\//, '');
758
- pending.push(entry.async('string').then((text) => { parts[clean] = text; }));
760
+ pending.push(readZipEntry(entry, 'string', budget).then((text) => { parts[clean] = text; }));
759
761
  });
760
762
  await Promise.all(pending);
761
763
  return sheetStateFromOds(parts);
@@ -14,6 +14,7 @@ import { documentFromXlsxParts, loadZip } from './xlsx-document.js';
14
14
  import { sheetStateFromOds } from './ods-document.js';
15
15
  import { sheetStateFromCsv } from './csv.js';
16
16
  import { isXlsFile, sheetStateFromXls } from './xls-document.js';
17
+ import { createUnzipBudget, readZipEntry } from '../xlsx-limits.js';
17
18
  /** A picture travels as the data URL the document holds, as in the xlsx reader. */
18
19
  const MEDIA_PREFIX = 'xl/media/';
19
20
  const mediaTypeOf = (path) => {
@@ -31,6 +32,7 @@ export async function readSpreadsheetFile(file, JSZip) {
31
32
  }
32
33
  const Zip = await loadZip(JSZip);
33
34
  const zip = await Zip.loadAsync(file);
35
+ const budget = createUnzipBudget();
34
36
  const parts = {};
35
37
  const pending = [];
36
38
  zip.forEach((path, entry) => {
@@ -38,12 +40,12 @@ export async function readSpreadsheetFile(file, JSZip) {
38
40
  return;
39
41
  const clean = path.replace(/^\//, '');
40
42
  if (clean.startsWith(MEDIA_PREFIX)) {
41
- pending.push(entry.async('base64').then((base64) => { parts[clean] = `data:${mediaTypeOf(clean)};base64,${base64}`; }));
43
+ pending.push(readZipEntry(entry, 'base64', budget).then((base64) => { parts[clean] = `data:${mediaTypeOf(clean)};base64,${base64}`; }));
42
44
  return;
43
45
  }
44
46
  if (!/\.(xml|rels|vml)$/i.test(clean))
45
47
  return;
46
- pending.push(entry.async('string').then((text) => { parts[clean] = text; }));
48
+ pending.push(readZipEntry(entry, 'string', budget).then((text) => { parts[clean] = text; }));
47
49
  });
48
50
  await Promise.all(pending);
49
51
  if (parts['content.xml'] && !parts['xl/workbook.xml'])