From 7c1418efc5d7d69b1d1d98e2f622b87e6853db1c Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 30 Jul 2026 11:31:52 -0400 Subject: [PATCH 1/2] perf(export): optimize Excel/PDF export variable row height handling --- demos/vanilla/src/examples/example44.ts | 3 +- .../src/excelExport.service.spec.ts | 50 ++++++---- .../excel-export/src/excelExport.service.ts | 42 +++++---- .../pdf-export/src/pdfExport.service.spec.ts | 92 ++++++++++++------- packages/pdf-export/src/pdfExport.service.ts | 30 +++--- 5 files changed, 134 insertions(+), 83 deletions(-) diff --git a/demos/vanilla/src/examples/example44.ts b/demos/vanilla/src/examples/example44.ts index 048782236..a25eae897 100644 --- a/demos/vanilla/src/examples/example44.ts +++ b/demos/vanilla/src/examples/example44.ts @@ -1,5 +1,6 @@ import type { Column, GridOption } from '@slickgrid-universal/common'; import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options.js'; import './example44.scss'; @@ -52,7 +53,7 @@ export default class Example44 { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false diff --git a/packages/excel-export/src/excelExport.service.spec.ts b/packages/excel-export/src/excelExport.service.spec.ts index 67c58c4b5..7a586a6d7 100644 --- a/packages/excel-export/src/excelExport.service.spec.ts +++ b/packages/excel-export/src/excelExport.service.spec.ts @@ -2646,7 +2646,7 @@ describe('ExcelExportService', () => { }); describe('Variable Row Height', () => { - it('exportToExcel should call applyVariableRowHeights when variable row height is enabled', async () => { + it('exportToExcel should call applyVariableRowHeight when variable row height is enabled', async () => { const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true } as GridOption; vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(40); @@ -2655,16 +2655,16 @@ describe('ExcelExportService', () => { vi.spyOn(dataViewStub, 'getLength').mockReturnValue(1); vi.spyOn(dataViewStub, 'getItem').mockReturnValueOnce({ id: 1 }); - const applyVariableRowHeightsSpy = vi.spyOn(service as any, 'applyVariableRowHeights'); + const applyVariableRowHeightSpy = vi.spyOn(service as any, 'applyVariableRowHeight'); service.init(gridStub, container); const result = await service.exportToExcel({ filename: 'export', useStreamingExport: false, includeVariableRowHeight: true }); expect(result).toBe(true); - expect(applyVariableRowHeightsSpy).toHaveBeenCalledTimes(1); + expect(applyVariableRowHeightSpy).toHaveBeenCalledTimes(1); }); - it('exportToExcel should not call applyVariableRowHeights when includeVariableRowHeight is false', async () => { + it('exportToExcel should not call applyVariableRowHeight when includeVariableRowHeight is false', async () => { const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true } as GridOption; vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(40); @@ -2673,30 +2673,29 @@ describe('ExcelExportService', () => { vi.spyOn(dataViewStub, 'getLength').mockReturnValue(1); vi.spyOn(dataViewStub, 'getItem').mockReturnValueOnce({ id: 1 }); - const applyVariableRowHeightsSpy = vi.spyOn(service as any, 'applyVariableRowHeights'); + const applyVariableRowHeightSpy = vi.spyOn(service as any, 'applyVariableRowHeight'); service.init(gridStub, container); const result = await service.exportToExcel({ filename: 'export', useStreamingExport: false, includeVariableRowHeight: false }); expect(result).toBe(true); - expect(applyVariableRowHeightsSpy).not.toHaveBeenCalled(); + expect(applyVariableRowHeightSpy).not.toHaveBeenCalled(); }); - it('applyVariableRowHeights should set row heights when enableVariableRowHeight is true', () => { + it('applyVariableRowHeight should set row heights when enableVariableRowHeight is true', () => { const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true } as GridOption; vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); vi.spyOn(gridStub, 'getRowHeight').mockReturnValueOnce(40).mockReturnValueOnce(50).mockReturnValueOnce(60); - vi.spyOn(dataViewStub, 'getLength').mockReturnValue(3); const setRowInstructionsSpy = vi.fn(); (service as any)._sheet = { setRowInstructions: setRowInstructionsSpy }; (service as any)._excelExportOptions = { includeVariableRowHeight: true }; - (service as any)._hasColumnTitlePreHeader = false; service.init(gridStub, container); - (service as any).applyVariableRowHeights(); + (service as any).applyVariableRowHeight(0, 2); + (service as any).applyVariableRowHeight(1, 3); + (service as any).applyVariableRowHeight(2, 4); - // Excel row starts at 2 (1 for header, +1 for 0-based index) // 40px * 0.75 = 30pt, 50px * 0.75 = 37.5pt, 60px * 0.75 = 45pt expect(setRowInstructionsSpy).toHaveBeenCalledWith(2, { height: 30 }); expect(setRowInstructionsSpy).toHaveBeenCalledWith(3, { height: 37.5 }); @@ -2704,21 +2703,18 @@ describe('ExcelExportService', () => { expect(setRowInstructionsSpy).toHaveBeenCalledTimes(3); }); - it('applyVariableRowHeights should offset row numbers when hasColumnTitlePreHeader is true', () => { + it('applyVariableRowHeight should use the provided Excel row number', () => { const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true } as GridOption; vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(40); - vi.spyOn(dataViewStub, 'getLength').mockReturnValue(1); const setRowInstructionsSpy = vi.fn(); (service as any)._sheet = { setRowInstructions: setRowInstructionsSpy }; (service as any)._excelExportOptions = { includeVariableRowHeight: true }; - (service as any)._hasColumnTitlePreHeader = true; service.init(gridStub, container); - (service as any).applyVariableRowHeights(); + (service as any).applyVariableRowHeight(0, 3); - // Excel row starts at 3 (1 for pre-header, 1 for header, +1 for 0-based index) expect(setRowInstructionsSpy).toHaveBeenCalledWith(3, { height: 30 }); }); @@ -2726,19 +2722,35 @@ describe('ExcelExportService', () => { const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true } as GridOption; vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); vi.spyOn(gridStub, 'getRowHeight').mockReturnValue(40); - vi.spyOn(dataViewStub, 'getLength').mockReturnValue(1); const setRowInstructionsSpy = vi.fn(); (service as any)._sheet = { setRowInstructions: setRowInstructionsSpy }; (service as any)._excelExportOptions = { includeVariableRowHeight: true }; - (service as any)._hasColumnTitlePreHeader = false; service.init(gridStub, container); - (service as any).applyVariableRowHeights(); + (service as any).applyVariableRowHeight(0, 2); // 40px * 0.75 = 30pt expect(setRowInstructionsSpy).toHaveBeenCalledWith(2, { height: 30 }); }); + + it('applyVariableRowHeight should skip rows that use the default grid row height', () => { + const mockGridOptionWithVarHeight = { ...mockGridOptions, enableVariableRowHeight: true, rowHeight: 25 } as GridOption; + vi.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptionWithVarHeight); + vi.spyOn(gridStub, 'getRowHeight').mockReturnValueOnce(25).mockReturnValueOnce(40).mockReturnValueOnce(25); + + const setRowInstructionsSpy = vi.fn(); + (service as any)._sheet = { setRowInstructions: setRowInstructionsSpy }; + (service as any)._excelExportOptions = { includeVariableRowHeight: true }; + + service.init(gridStub, container); + (service as any).applyVariableRowHeight(0, 2); + (service as any).applyVariableRowHeight(1, 3); + (service as any).applyVariableRowHeight(2, 4); + + expect(setRowInstructionsSpy).toHaveBeenCalledTimes(1); + expect(setRowInstructionsSpy).toHaveBeenCalledWith(3, { height: 30 }); + }); }); }); diff --git a/packages/excel-export/src/excelExport.service.ts b/packages/excel-export/src/excelExport.service.ts index f6f102638..964ea0636 100644 --- a/packages/excel-export/src/excelExport.service.ts +++ b/packages/excel-export/src/excelExport.service.ts @@ -98,6 +98,10 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ return this._grid?.getOptions() || ({} as GridOption); } + protected get _shouldExportVariableRowHeights(): boolean { + return !!(this._gridOptions.enableVariableRowHeight && this._excelExportOptions.includeVariableRowHeight !== false); + } + get stylesheet(): StyleSheet { return this._stylesheet; } @@ -211,11 +215,6 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ this._sheet.setData(finalOutput); - // Apply variable row heights if enabled - if (this._gridOptions.enableVariableRowHeight && this._excelExportOptions.includeVariableRowHeight !== false) { - this.applyVariableRowHeights(); - } - this._workbook.addWorksheet(this._sheet); // MIME type could be undefined, if that's the case we'll detect the type by its file extension @@ -524,6 +523,7 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ // Read rows directly from DataView for (let rowNumber = 0; rowNumber < lineCount; rowNumber++) { const itemObj = dataView.getItem(rowNumber); + let rowWasExported = false; // make sure we have a filled object AND that the item doesn't include the "getItem" method // this happen could happen with an opened Row Detail as it seems to include an empty Slick DataView (we'll just skip those lines) @@ -532,12 +532,19 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ if (itemObj[this._datasetIdPropName] !== null && itemObj[this._datasetIdPropName] !== undefined) { // Read a regular row originalDaraArray.push(this.readRegularRowData(columns, rowNumber, itemObj, rowNumber, cachedColumnMetadata)); + rowWasExported = true; } else if (this._hasGroupedItems && itemObj.__groupTotals === undefined) { // get the group row originalDaraArray.push([this.readGroupedRowTitle(itemObj)]); + rowWasExported = true; } else if (itemObj.__groupTotals) { // else if the row is a Group By and we have aggregators, then a property of '__groupTotals' would exist under that object originalDaraArray.push(this.readGroupedTotalRows(columns, itemObj, rowNumber)); + rowWasExported = true; + } + + if (rowWasExported && this._shouldExportVariableRowHeights) { + this.applyVariableRowHeight(rowNumber, originalDaraArray.length); } } @@ -656,21 +663,18 @@ export class ExcelExportService implements ExternalResource, BaseExcelExportServ }); } - /** - * Apply variable row heights from grid to Excel sheet when enableVariableRowHeight is enabled. - * Converts pixel heights to Excel points (72 DPI) using formula: pixels * (72/96) = pixels * 0.75 - */ - protected applyVariableRowHeights(): void { - const lineCount = this._dataView.getLength(); - const headerRowOffset = this._hasColumnTitlePreHeader ? 3 : 2; // Offset for pre-header + header rows - - for (let row = 0; row < lineCount; row++) { - const pixelHeight = this._grid.getRowHeight(row); - const excelRowNumber = row + headerRowOffset; - // Convert pixels to Excel points: 72 DPI / 96 DPI = 0.75 - const excelHeight = Math.round(pixelHeight * 0.75 * 100) / 100; // Round to 2 decimal places - this._sheet.setRowInstructions(excelRowNumber, { height: excelHeight }); + /** Apply one variable row height only when it differs from the grid default row height. */ + protected applyVariableRowHeight(row: number, excelRowNumber: number): void { + const pixelHeight = this._grid.getRowHeight(row); + const defaultRowHeight = this._gridOptions.rowHeight; + + if (pixelHeight == null || pixelHeight <= 0 || pixelHeight === defaultRowHeight) { + return; } + + // Convert pixels to Excel points: 72 DPI / 96 DPI = 0.75 + const excelHeight = Math.round(pixelHeight * 0.75 * 100) / 100; // Round to 2 decimal places + this._sheet.setRowInstructions(excelRowNumber, { height: excelHeight }); } /** diff --git a/packages/pdf-export/src/pdfExport.service.spec.ts b/packages/pdf-export/src/pdfExport.service.spec.ts index a7ba7c538..506aff67a 100644 --- a/packages/pdf-export/src/pdfExport.service.spec.ts +++ b/packages/pdf-export/src/pdfExport.service.spec.ts @@ -29,8 +29,8 @@ const pubSubServiceStub = { // URL object is not supported in JSDOM, we can simply mock it const createObjectMock = vi.fn(); -(global as any).URL.createObjectURL = createObjectMock; -(global as any).URL.revokeObjectURL = vi.fn(); +(globalThis as any).URL.createObjectURL = createObjectMock; +(globalThis as any).URL.revokeObjectURL = vi.fn(); const myBoldHtmlFormatter: Formatter = (_row, _cell, value) => (value !== null ? { text: `${value}` } : (null as any)); const myUppercaseFormatter: Formatter = (_row, _cell, value) => (value ? { text: value.toUpperCase() } : (null as any)); @@ -161,7 +161,7 @@ describe('PdfExportService', () => { delete mockGridOptions.backendServiceApi; service?.dispose(); vi.clearAllMocks(); - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should create the service', () => { @@ -724,13 +724,13 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); let result; result = await service.exportToPdf({ filename: 'preheader-multipage', documentTitle: 'Test PDF' }); expect(result).toBe(true); - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should call drawHeaders with grouped pre-header and without column headers', async () => { @@ -748,7 +748,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'no-col-header', includeColumnHeaders: false }); @@ -759,7 +759,7 @@ describe('PdfExportService', () => { // Setup to hit both firstPageMaxRows and subsequentPageMaxRows logic const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const rows = Array.from({ length: 60 }, (_, i) => ({ id: i, name: `Name${i}` })); dataViewStub.getLength.mockReturnValue(rows.length); dataViewStub.getItem.mockImplementation((i: number) => rows[i]); @@ -795,7 +795,7 @@ describe('PdfExportService', () => { // Set global override for pdf doc const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); // Always succeed const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const rows = Array.from({ length: 50 }, (_, i) => ({ id: i, name: `Name${i}` })); dataViewStub.getLength.mockReturnValue(rows.length); dataViewStub.getItem.mockImplementation((i: number) => rows[i]); @@ -809,7 +809,7 @@ describe('PdfExportService', () => { } expect(result).toBe(true); // Clean up override - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should handle grouped header spanning (pre-header)', async () => { @@ -868,7 +868,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); // Always succeed const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); let result; @@ -905,7 +905,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'no-group-title' }); expect(result).toBe(true); @@ -925,7 +925,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'long-titles' }); expect(result).toBe(true); @@ -934,7 +934,7 @@ describe('PdfExportService', () => { it('should split rows into multiple pages with documentTitle and verify first/subsequent page logic', async () => { const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const rows = Array.from({ length: 70 }, (_, i) => ({ id: i, name: `Name${i}` })); dataViewStub.getLength.mockReturnValue(rows.length); dataViewStub.getItem.mockImplementation((i: number) => rows[i]); @@ -969,7 +969,7 @@ describe('PdfExportService', () => { }); describe('Variable Row Height', () => { - async function createVarHeightService(getRowHeightFn: (row: number) => number) { + async function createVarHeightService(getRowHeightFn: (row: number) => number, columnCount = 1) { vi.resetModules(); let capturedDidParseCell: ((data: any) => void) | undefined; const autoTableSpy = vi.fn((opts: any) => { @@ -995,8 +995,14 @@ describe('PdfExportService', () => { getItem: (idx: number) => ({ id: idx, title: `Task ${idx}` }), getItemMetadata: vi.fn().mockReturnValue({}), }; + const visibleColumns = Array.from({ length: columnCount }, (_value, idx) => ({ + id: `title${idx}`, + field: 'title', + name: `Title ${idx}`, + width: 100, + })); const gridStub = { - getVisibleColumns: () => [{ id: 'title', field: 'title', name: 'Title', width: 100 }], + getVisibleColumns: () => visibleColumns, getOptions: () => ({ enableVariableRowHeight: true }), getData: () => dataViewStub, getRowHeight: getRowHeightFn, @@ -1063,6 +1069,26 @@ describe('PdfExportService', () => { expect(cell0.styles.minCellHeight).toBe(30); expect(cell1.styles.minCellHeight).toBe(45); }); + + it('should cache row heights in autoTable and not re-read the same row per cell', async () => { + const getRowHeightSpy = vi.fn((row: number) => (row === 0 ? 40 : 60)); + const { Svc, gridStub, container, getDidParseCell } = await createVarHeightService(getRowHeightSpy, 2); + const service = new Svc(); + service.init(gridStub as any, container as any); + await service.exportToPdf({ filename: 'var-height-cached' }); + + const didParseCell = getDidParseCell()!; + const firstCell: any = { styles: {} }; + const secondCell: any = { styles: {} }; + + didParseCell({ section: 'body', row: { index: 0 }, column: { index: 0 }, cell: firstCell }); + didParseCell({ section: 'body', row: { index: 0 }, column: { index: 1 }, cell: secondCell }); + + expect(firstCell.styles.minCellHeight).toBe(30); + expect(secondCell.styles.minCellHeight).toBe(30); + expect(getRowHeightSpy).toHaveBeenCalledTimes(1); + expect(getRowHeightSpy).toHaveBeenCalledWith(0); + }); }); describe('without Translater Service', () => { @@ -1903,7 +1929,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'no-headers', includeColumnHeaders: false }); expect(result).toBe(true); @@ -1957,11 +1983,11 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'normal-path' }); expect(result).toBe(true); - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should resolve false in exportToPdf if error thrown in setTimeout', async () => { @@ -2002,7 +2028,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'only-col-header', includeColumnHeaders: true }); expect(result).toBe(true); @@ -2028,7 +2054,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'repeat-headers', repeatHeadersOnEachPage: true }); expect(result).toBe(true); @@ -2073,7 +2099,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'drawHeaders-grouped-no-title', includeColumnHeaders: true }); expect(result).toBe(true); @@ -2093,7 +2119,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); (service as any)._groupedColumnHeaders = []; @@ -2123,7 +2149,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'multi-page-exact-one', fontSize: 10, headerFontSize: 11 }); @@ -2152,7 +2178,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'multi-page-just-over', fontSize: 10, headerFontSize: 11 }); @@ -2173,7 +2199,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'drawHeaders-none', includeColumnHeaders: false }); @@ -2201,7 +2227,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'multi-page-no-title-no-repeat', repeatHeadersOnEachPage: false }); @@ -2226,7 +2252,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); (service as any)._groupedColumnHeaders = groupedHeaders; @@ -2248,7 +2274,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); (service as any)._hasGroupedItems = true; @@ -2277,7 +2303,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new PdfExportService(); service.init(gridStub as any, container as any); const result = await service.exportToPdf({ filename: 'multi-page-repeat-title', repeatHeadersOnEachPage: true, documentTitle: 'Test Title' }); @@ -2325,7 +2351,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new TestPdfExportService(); service.init(gridStub as any, container as any); service.setMockDataView(dataViewStub); @@ -2333,7 +2359,7 @@ describe('PdfExportService', () => { (service as any)._exportOptions = { sanitizeDataExport: false }; const result = (service as any).getAllGridRowData(columns); expect(result).toBeInstanceOf(Array); - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should cover drawHeaders with pre-header enabled, grouped headers present, and includeColumnHeaders false', async () => { @@ -2663,7 +2689,7 @@ describe('PdfExportService', () => { const container = { get: () => pubSubService }; const buildSpy = vi.fn(() => new Uint8Array([1, 2, 3])); const doc = { page: vi.fn(), build: buildSpy }; - (global as any).__pdfDocOverride = doc; + (globalThis as any).__pdfDocOverride = doc; const service = new TestPdfExportService(); service.init(gridStub as any, container as any); (service as any)._exportOptions = { sanitizeDataExport: false }; @@ -2672,7 +2698,7 @@ describe('PdfExportService', () => { // Should hit both skip and non-skip paths const result = (service as any).getAllGridRowData(columns); expect(result).toBeInstanceOf(Array); - delete (global as any).__pdfDocOverride; + delete (globalThis as any).__pdfDocOverride; }); it('should cover headerX += colWidth in drawHeaders (pre-header, grouped headers, grouping, groupByColumnHeader)', async () => { diff --git a/packages/pdf-export/src/pdfExport.service.ts b/packages/pdf-export/src/pdfExport.service.ts index 4ac04fe92..218ae7b42 100644 --- a/packages/pdf-export/src/pdfExport.service.ts +++ b/packages/pdf-export/src/pdfExport.service.ts @@ -205,6 +205,22 @@ export class PdfExportService implements ExternalResource, BasePdfExportService // Prepare data const data = tableData; + const defaultRowHeight = 18; + const useVariableRowHeight = this._gridOptions.enableVariableRowHeight && this._exportOptions.includeVariableRowHeight !== false; + const rowHeightByRowIndex = new Map(); + const getPdfRowHeight = (rowIdx: number): number => { + if (!useVariableRowHeight) { + return defaultRowHeight; + } + + let cachedRowHeight = rowHeightByRowIndex.get(rowIdx); + if (cachedRowHeight === undefined) { + cachedRowHeight = Math.round(this._grid.getRowHeight(rowIdx) * 0.75 * 100) / 100; + rowHeightByRowIndex.set(rowIdx, cachedRowHeight); + } + + return cachedRowHeight; + }; // Add table (using jsPDF-AutoTable if available, else fallback to manual) if ((doc as any).autoTable) { @@ -260,12 +276,8 @@ export class PdfExportService implements ExternalResource, BasePdfExportService data.cell.styles.halign = headerAlignMap[data.column.index]; } // Apply variable row height as minCellHeight (px → pt: pixels * 0.75) - if ( - data.section === 'body' && - this._gridOptions.enableVariableRowHeight && - this._exportOptions.includeVariableRowHeight !== false - ) { - data.cell.styles.minCellHeight = this._grid.getRowHeight(data.row.index) * 0.75; + if (data.section === 'body' && useVariableRowHeight) { + data.cell.styles.minCellHeight = getPdfRowHeight(data.row.index); } }, alternateRowStyles: { @@ -292,10 +304,7 @@ export class PdfExportService implements ExternalResource, BasePdfExportService const pageHeight = doc.internal.pageSize.getHeight(); const pageWidth = doc.internal.pageSize.getWidth(); const bottomMargin = 40; - const defaultRowHeight = 18; const margin = 40; - const useVariableRowHeight = - this._gridOptions.enableVariableRowHeight && this._exportOptions.includeVariableRowHeight !== false; // Dynamically calculate table width based on page width and margins const tableWidth = pageWidth - margin * 2; const colCount = headers.length; @@ -361,8 +370,7 @@ export class PdfExportService implements ExternalResource, BasePdfExportService y = this._drawHeaderRow(doc, y, headers, colWidths, margin, headerTextOffset, headerBackgroundOffset, headerAligns); doc.setFontSize(this._exportOptions.fontSize || 10); data.forEach((row, rowIdx) => { - // px → pt: pixels * 0.75; fall back to default when not in variable-height mode - const rowHeight = useVariableRowHeight ? Math.round(this._grid.getRowHeight(rowIdx) * 0.75 * 100) / 100 : defaultRowHeight; + const rowHeight = getPdfRowHeight(rowIdx); // Check for page break before drawing row if (y + rowHeight + bottomMargin > pageHeight) { doc.addPage(); From 74435f6df9d2c011c85f4e41e0e70fda81dcf2c4 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Thu, 30 Jul 2026 11:44:07 -0400 Subject: [PATCH 2/2] chore: add PDF Export to add variable height examples --- demos/aurelia/src/examples/slickgrid/example55.ts | 6 +++++- demos/aurelia/src/examples/slickgrid/example56.ts | 6 +++++- demos/react/src/examples/slickgrid/Example55.tsx | 6 +++++- demos/react/src/examples/slickgrid/Example56.tsx | 6 +++++- demos/vanilla/src/examples/example44.ts | 3 +++ demos/vanilla/src/examples/example45.ts | 6 +++++- demos/vue/src/components/Example55.vue | 6 +++++- demos/vue/src/components/Example56.vue | 6 +++++- .../src/demos/examples/example55.component.ts | 6 +++++- .../src/demos/examples/example56.component.ts | 6 +++++- 10 files changed, 48 insertions(+), 9 deletions(-) diff --git a/demos/aurelia/src/examples/slickgrid/example55.ts b/demos/aurelia/src/examples/slickgrid/example55.ts index 863c39149..61ee68f73 100644 --- a/demos/aurelia/src/examples/slickgrid/example55.ts +++ b/demos/aurelia/src/examples/slickgrid/example55.ts @@ -1,4 +1,5 @@ import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import { type AureliaGridInstance, type Column, type GridOption } from 'aurelia-slickgrid'; import './example55.scss'; @@ -47,12 +48,15 @@ export class Example55 { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, gridHeight: 560, gridWidth: 1080, diff --git a/demos/aurelia/src/examples/slickgrid/example56.ts b/demos/aurelia/src/examples/slickgrid/example56.ts index f150136e6..0648df464 100644 --- a/demos/aurelia/src/examples/slickgrid/example56.ts +++ b/demos/aurelia/src/examples/slickgrid/example56.ts @@ -1,4 +1,5 @@ import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import { type AureliaGridInstance, type Column, type GridOption } from 'aurelia-slickgrid'; import './example56.scss'; @@ -61,12 +62,15 @@ export class Example56 { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, frozenRow: 2, gridHeight: 560, diff --git a/demos/react/src/examples/slickgrid/Example55.tsx b/demos/react/src/examples/slickgrid/Example55.tsx index 5ab7ac488..3ce1c3ccc 100644 --- a/demos/react/src/examples/slickgrid/Example55.tsx +++ b/demos/react/src/examples/slickgrid/Example55.tsx @@ -1,4 +1,5 @@ import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import React, { useEffect, useRef, useState } from 'react'; import { SlickgridReact, type Column, type GridOption, type SlickgridReactInstance } from 'slickgrid-react'; import './example55.scss'; @@ -45,12 +46,15 @@ const Example55: React.FC = () => { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, gridHeight: 560, gridWidth: 1080, diff --git a/demos/react/src/examples/slickgrid/Example56.tsx b/demos/react/src/examples/slickgrid/Example56.tsx index ea25ccc69..ddc09e28c 100644 --- a/demos/react/src/examples/slickgrid/Example56.tsx +++ b/demos/react/src/examples/slickgrid/Example56.tsx @@ -1,4 +1,5 @@ import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import React, { useEffect, useRef, useState } from 'react'; import { SlickgridReact, type Column, type GridOption, type SlickgridReactInstance } from 'slickgrid-react'; import './example56.scss'; @@ -60,12 +61,15 @@ const Example56: React.FC = () => { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, frozenRow: 2, gridHeight: 560, diff --git a/demos/vanilla/src/examples/example44.ts b/demos/vanilla/src/examples/example44.ts index a25eae897..2148ed699 100644 --- a/demos/vanilla/src/examples/example44.ts +++ b/demos/vanilla/src/examples/example44.ts @@ -59,6 +59,9 @@ export default class Example44 { // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, gridHeight: 560, gridWidth: 1080, diff --git a/demos/vanilla/src/examples/example45.ts b/demos/vanilla/src/examples/example45.ts index 55a2b030a..f04d5fea7 100644 --- a/demos/vanilla/src/examples/example45.ts +++ b/demos/vanilla/src/examples/example45.ts @@ -1,5 +1,6 @@ import type { Column, GridOption } from '@slickgrid-universal/common'; import { ExcelExportService } from '@slickgrid-universal/excel-export'; +import { PdfExportService } from '@slickgrid-universal/pdf-export'; import { Slicker, type SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; import { ExampleGridOptions } from './example-grid-options.js'; import './example45.scss'; @@ -67,12 +68,15 @@ export default class Example45 { enableCellNavigation: true, enableTextSelectionOnCells: true, enableVariableRowHeight: true, - externalResources: [new ExcelExportService()], + externalResources: [new ExcelExportService(), new PdfExportService()], excelExportOptions: { // export variable row height will also be reflected in the export // but it can be disabled by setting `includeVariableRowHeight` to false // includeVariableRowHeight: false, // export all rows at default height }, + pdfExportOptions: { + pageOrientation: 'landscape', + }, rowHeight: 40, frozenRow: 2, gridHeight: 560, diff --git a/demos/vue/src/components/Example55.vue b/demos/vue/src/components/Example55.vue index f9842fe15..2d4d861d6 100644 --- a/demos/vue/src/components/Example55.vue +++ b/demos/vue/src/components/Example55.vue @@ -1,5 +1,6 @@