diff --git a/CHANGELOG.md b/CHANGELOG.md index b10314c51ea..c0d6f90c7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,13 @@ All notable changes for each version of this project will be documented in this - Fixed remote pages changing position before their replacements arrive and redundant requests for an already loaded initial range. Changes to a positive `totalItemCount` refresh the list without rebinding data; a reduced total excludes out-of-range records before filtering and grouping. - Reduced selection-resolution work during change detection. Each combo resolves its selection once per check and validates cached primitive-key matches before reusing them. Missing or invalid matches share one fallback scan; object keys retain deep-equality matching. In-place changes that create an earlier duplicate of a cached key are not detected without rebinding data. +### Behavioral Changes + +- `IgxPdfExporterService` + - Summary rows are now shaded like the header row of the exported table. A summary closes the rows above it the way the header opens them, so it no longer reads as one more record. As with the header background, the shading follows the `showTableBorders` option. + - The row dimension cells of an `IgxPivotGrid` export are shaded the same way: they head the record they sit on rather than holding one of its values. + - A row dimension value that repeats down consecutive records of an `IgxPivotGrid` export is now drawn once, in a single cell over all of them, the way the grid merges its own row headers. A value merges only under the same parent dimension, so the same date under two different cities still gets a cell each, and a cell that would reach past the bottom of a page is cut off there and opened again under the headers of the next one. + ### Breaking Changes - `IgxButtonDirective`, `IgxIconButtonDirective` diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts index 64b931716c4..7a695934711 100644 --- a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-grid.spec.ts @@ -2,7 +2,7 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { ExportUtilities } from '../exporter-common/export-utilities'; import { IgxPdfExporterService } from './pdf-exporter'; import { IgxPdfExporterOptions } from './pdf-exporter-options'; -import { GridIDNameJobTitleComponent } from '../../../../../test-utils/grid-samples.spec'; +import { GridIDNameJobTitleComponent, GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent, MultiColumnHeadersExportComponent } from '../../../../../test-utils/grid-samples.spec'; import { first } from 'rxjs/operators'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NestedColumnGroupsGridComponent, ColumnGroupTestComponent, BlueWhaleGridComponent } from '../../../../../test-utils/grid-mch-sample.spec'; @@ -13,7 +13,27 @@ import { IgxHierarchicalGridComponent } from 'igniteui-angular/grids/hierarchica import { IgxPivotGridMultipleRowComponent, IgxPivotGridTestComplexHierarchyComponent } from '../../../../../test-utils/pivot-grid-samples.spec'; import { IgxPivotGridComponent } from 'igniteui-angular/grids/pivot-grid'; import { PivotRowLayoutType } from 'igniteui-angular/grids/core'; +import { IgxStringFilteringOperand, SortingDirection } from 'igniteui-angular/core'; import { UIInteractions, wait } from 'igniteui-angular/test-utils/ui-interactions.spec'; +import { + PAGE_SIZES, getDrawnRectangles, getHeaderCellOf, getPageCount, getPageDimensions, + getRenderedCells, getRenderedRows, getRenderedRowsByPage, getRenderedText +} from './pdf-exporter-utils.spec'; + +/** `GridIDNameJobTitleComponent` as the exporter lays it out, in the order the grid shows it. */ +const GRID_ROWS = [ + ['ID', 'Name', 'JobTitle'], + ['1', 'Casey Houston', 'Vice President'], + ['2', 'Gilberto Todd', 'Director'], + ['3', 'Tanya Bennett', 'Director'], + ['4', 'Jack Simon', 'Software Developer'], + ['5', 'Celia Martinez', 'Senior Software Developer'], + ['6', 'Erma Walsh', 'CEO'], + ['7', 'Debra Morton', 'Associate Software Developer'], + ['8', 'Erika Wells', 'Software Development Team Lead'], + ['9', 'Leslie Hansen', 'Associate Software Developer'], + ['10', 'Eduardo Ramirez', 'Manager'] +]; describe('PDF Grid Exporter', () => { let exporter: IgxPdfExporterService; @@ -56,8 +76,11 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Every column and every record of the grid, in the order the grid shows them. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); + expect(getPageCount(args.pdf)).toBe(1); done(); }); @@ -69,10 +92,13 @@ describe('PDF Grid Exporter', () => { fix.detectChanges(); const grid = fix.componentInstance.grid; - options.pageOrientation = 'landscape'; + // Portrait is the one that is not the default, so it is the one worth asking for here. + options.pageOrientation = 'portrait'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Portrait); + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -89,8 +115,30 @@ describe('PDF Grid Exporter', () => { fix.detectChanges(); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The hidden ID column is left out of the header and out of every row. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS.map(([, ...rest]) => rest)); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export a hidden column when told to ignore column visibility', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(0).hidden = true; + options.ignoreColumnsVisibility = true; + + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The column is hidden in the grid but asked for in the export, so it comes back. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -105,8 +153,11 @@ describe('PDF Grid Exporter', () => { grid.data = []; fix.detectChanges(); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The columns still head the table, there is simply nothing underneath them. + expect(getRenderedRows(args.pdf)).toEqual([GRID_ROWS[0]]); + expect(getPageCount(args.pdf)).toBe(1); done(); }); @@ -120,8 +171,10 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; options.pageOrientation = 'landscape'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Landscape); + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -135,8 +188,11 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; options.showTableBorders = false; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Not a rectangle in the document, but the table itself is untouched. + expect(getDrawnRectangles(args.pdf)).toEqual([]); + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -150,8 +206,9 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; options.fontSize = 14; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(new Set(getRenderedCells(args.pdf).map(cell => cell.fontSize))).toEqual(new Set([14])); done(); }); @@ -165,8 +222,11 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; options.pageSize = 'letter'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Landscape is the default, so the letter page comes out the other way round. + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.letterLandscape); + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -178,10 +238,37 @@ describe('PDF Grid Exporter', () => { fix.detectChanges(); const grid = fix.componentInstance.grid; + // Pin Name, which moves it to the front of the grid and so of the export. + grid.columnList.get(1).pinned = true; + fix.detectChanges(); + options.ignoreColumnsOrder = true; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Ignoring the order puts the columns back the way they were declared. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export a pinned column first when not ignoring the column order', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.columnList.get(1).pinned = true; + fix.detectChanges(); + + options.ignoreColumnsOrder = false; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Name leads, the way the grid shows it once it is pinned. + expect(getRenderedRows(args.pdf)) + .toEqual(GRID_ROWS.map(([id, name, jobTitle]) => [name, id, jobTitle])); done(); }); @@ -193,10 +280,40 @@ describe('PDF Grid Exporter', () => { fix.detectChanges(); const grid = fix.componentInstance.grid; + // Filter the grid down to the two directors, so that there is a filter to honour. + grid.filter('JobTitle', 'Director', IgxStringFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + options.ignoreFiltering = false; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Only the rows the grid is showing reach the document. + expect(getRenderedRows(args.pdf)).toEqual([ + GRID_ROWS[0], + ['2', 'Gilberto Todd', 'Director'], + ['3', 'Tanya Bennett', 'Director'] + ]); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export every record when told to ignore filtering', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.filter('JobTitle', 'Director', IgxStringFilteringOperand.instance().condition('equals')); + fix.detectChanges(); + + options.ignoreFiltering = true; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The filter is set aside and the whole grid is exported. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -208,10 +325,39 @@ describe('PDF Grid Exporter', () => { fix.detectChanges(); const grid = fix.componentInstance.grid; + // Sort by name, so that there is a sort order to honour. + grid.sort({ fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true }); + fix.detectChanges(); + options.ignoreSorting = false; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The records come out in the order the grid is showing them. + const [header, ...records] = getRenderedRows(args.pdf); + expect(header).toEqual(GRID_ROWS[0]); + expect(records.map(row => row[1])) + .toEqual(GRID_ROWS.slice(1).map(row => row[1]).sort((a, b) => a.localeCompare(b))); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export the records unsorted when told to ignore sorting', (done) => { + const fix = TestBed.createComponent(GridIDNameJobTitleComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.sort({ fieldName: 'Name', dir: SortingDirection.Asc, ignoreCase: true }); + fix.detectChanges(); + + options.ignoreSorting = true; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The sort is set aside and the records keep their original order. + expect(getRenderedRows(args.pdf)).toEqual(GRID_ROWS); done(); }); @@ -226,8 +372,15 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const cells = getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled); + // One cell per column of the header row and of each of the ten records, all the same + // width, tiling the page in three columns from a single left margin. + expect(cells.length).toBe(3 * GRID_ROWS.length); + expect(new Set(cells.map(cell => cell.width)).size).toBe(1); + expect(new Set(cells.map(cell => cell.x)).size).toBe(3); done(); }); @@ -245,6 +398,10 @@ describe('PDF Grid Exporter', () => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); const callArgs = (ExportUtilities.saveBlobToFile as jasmine.Spy).calls.mostRecent().args; expect(callArgs[1]).toBe('MyCustomGrid.pdf'); + // The exporter hands over the document itself, as a PDF blob. + expect(callArgs[0] instanceof Blob).toBeTrue(); + expect(callArgs[0].type).toBe('application/pdf'); + expect(callArgs[0].size).toBeGreaterThan(0); done(); }); @@ -264,8 +421,21 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + // Three levels of headers, repeated at the top of every page, with the groups above + // the leaf columns they span. + for (const page of pages) { + expect(page[0]).toEqual(['General Information', 'Address Information']); + expect(page[1]).toEqual(['ID', 'Person Details', 'Location', 'Contact Information']); + expect(page[3]).toEqual([ + 'ContactNa...', 'ContactTitle', 'Country', 'Region', 'City', 'Address', 'Phone', + 'Fax', 'PostalCode' + ]); + } + expect(pages.length).toBeGreaterThan(1); done(); }); @@ -285,8 +455,18 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + // A group inside a group inside a group, each level above the next. + for (const page of pages) { + expect(page[0]).toEqual(['Master']); + expect(page[1]).toEqual(['Slave 1', 'Slave 2']); + expect(page[2]).toEqual(['Address', 'Phone', 'Fax', 'City']); + } + // The first record of the grid, under those headers. + expect(pages[0][3]).toEqual(['Obere Str. 57', '030-0074321', '030-0076545', 'Berlin']); done(); }); @@ -306,8 +486,19 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.grid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + expect(rows[0]).toEqual(['Product ID', 'ProductName', 'InStock', 'UnitsInStock', 'OrderDate']); + expect(rows[1]).toEqual(['1', 'Chai', 'true', '2760', '3/21/2005']); + // The summaries follow the records, one row per summary level, each cell holding the + // label and the value the grid computed. + expect(rows.slice(11)).toEqual([ + ['Count: 10', 'Count: 10', 'Count: 10', 'Earliest: Thu May 17 1990 00:...'], + ['Sum: 39004', 'Items InStock: 1337'], + ['Avg: 3900.4'] + ]); done(); }); @@ -332,8 +523,27 @@ describe('PDF Grid Exporter', () => { }); fix.detectChanges(); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + // Every island introduces itself with its own header rows before its records, and the + // root grid's own header opens the document. + expect(rows[0]).toEqual(['Information']); + expect(rows[1]).toEqual(['ID']); + expect(rows[2]).toEqual(['ChildLevels', 'ProductName']); + expect(rows[3]).toEqual(['0', '3', 'Product: A0']); + + // Expanding everything gives 40 root records, each with 20 children and each of those + // with 20 of its own, so the export runs well past a single page. + expect(getPageCount(args.pdf)).toBeGreaterThan(1); + expect(rows.filter(row => row[0] === 'Information').length) + .toBe(rows.filter(row => row[0] === 'ChildLevels').length); + + // The islands are indented one step further in than the records above them. + const cells = getRenderedCells(args.pdf); + const xOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(xOf('0')).toBeLessThan(xOf('00')); done(); }); @@ -437,8 +647,32 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.treeGrid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + expect(getRenderedRows(args.pdf)).toEqual([ + ['ID', 'Name', 'HireDate', 'Age'], + ['147', 'John Winchester', '4/20/2008', '55'], + ['475', 'Michael Langdon', '7/3/2011', '30'], + ['957', 'Thomas Hardy', '7/19/2009', '29'], + ['317', 'Monica Reyes', '9/18/2014', '31'], + ['711', 'Roland Mendel', '10/17/2015', '35'], + ['998', 'Sven Ottlieb', '11/11/2009', '44'], + ['299', 'Peter Lewis', '4/18/2018', '25'], + ['19', 'Yang Wang', '2/1/2010', '61'], + ['847', 'Ana Sanders', '2/22/2014', '42'], + ['663', 'Elizabeth Richards', '12/9/2017', '25'] + ]); + + // The nesting shows as an indent on the first cell. 147 has 475, 957 and 317 under + // it, and 317 in turn has 711, so each level starts further to the right than the one + // above it while siblings line up with each other. + const cells = getRenderedCells(args.pdf); + const xOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(xOf('147')).toBeLessThan(xOf('475')); + expect(xOf('475')).toBeLessThan(xOf('711')); + expect(xOf('957')).toBe(xOf('475')); + expect(xOf('19')).toBe(xOf('147')); done(); }); @@ -458,8 +692,29 @@ describe('PDF Grid Exporter', () => { const grid = fix.componentInstance.treeGrid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // Self-referencing data is nested by ParentID, and comes out in the same shape a + // tree grid over hierarchical data does. + expect(getRenderedRows(args.pdf)).toEqual([ + ['ID', 'ParentID', 'Name', 'JobTitle', 'Age'], + ['1', '-1', 'Casey Houston', 'Vice President', '32'], + ['2', '1', 'Gilberto Todd', 'Director', '41'], + ['3', '2', 'Tanya Bennett', 'Director', '29'], + ['7', '2', 'Debra Morton', 'Associate Software Developer', '35'], + ['4', '1', 'Jack Simon', 'Software Developer', '33'], + ['6', '-1', 'Erma Walsh', 'CEO', '52'], + ['10', '-1', 'Eduardo Ramirez', 'Manager', '53'], + ['9', '10', 'Leslie Hansen', 'Associate Software Developer', '44'] + ]); + + // The nesting shows as an indent on the first cell of each row. + const cells = getRenderedCells(args.pdf); + const xOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(xOf('1')).toBeLessThan(xOf('2')); + expect(xOf('2')).toBeLessThan(xOf('3')); + expect(xOf('6')).toBe(xOf('1')); done(); }); @@ -481,8 +736,25 @@ describe('PDF Grid Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - // The PDF should be created successfully even with long header text - expect(args.pdf).toBeDefined(); + + const pages = getRenderedRowsByPage(args.pdf); + const columnWidth = getDrawnRectangles(args.pdf).find(rectangle => !rectangle.filled)!.width; + + expect(pages.length).toBeGreaterThan(1); + for (const [topLevel, secondLevel, ...body] of pages) { + // The two group captions are short enough to survive, and are repeated on every + // page; the groups beside them are not. + expect(topLevel).toEqual(['100 IDs', '2 col groups with 50 IDs each', '...', '...']); + expect(secondLevel).toEqual(['50 IDs', '50 IDs', '...', '...', '...', '...']); + + // A hundred columns on an A5 page leaves so little room that every leaf header + // and every value below them is cut back to the ellipsis alone. + expect(body.length).toBeGreaterThan(0); + for (const row of body) { + expect(new Set(row)).toEqual(new Set(['...'])); + } + } + expect(args.pdf!.getTextWidth('...')).toBeLessThanOrEqual(columnWidth - 10); done(); }); @@ -491,6 +763,383 @@ describe('PDF Grid Exporter', () => { exporter.export(grid, options); }); + + describe('Multi column header layout', () => { + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + NestedColumnGroupsGridComponent, + ColumnGroupTestComponent + ] + }).compileComponents(); + })); + + it('should draw each column group exactly over the columns it spans', (done) => { + const fix = TestBed.createComponent(NestedColumnGroupsGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // Master over Slave 1 and Slave 2, each of those over two leaf columns. + const master = getHeaderCellOf(args.pdf, 'Master'); + const firstSlave = getHeaderCellOf(args.pdf, 'Slave 1'); + const secondSlave = getHeaderCellOf(args.pdf, 'Slave 2'); + const leaves = ['Address', 'Phone', 'Fax', 'City'].map(field => getHeaderCellOf(args.pdf, field)); + + // The leaves tile the table: same width, each starting where the last one ended. + expect(new Set(leaves.map(leaf => leaf.width)).size).toBe(1); + leaves.slice(1).forEach((leaf, index) => { + expect(leaf.x).toBeCloseTo(leaves[index].x + leaves[index].width, 6); + }); + + // Each slave starts at its first leaf and ends at its second. + expect(firstSlave.x).toBeCloseTo(leaves[0].x, 6); + expect(firstSlave.x + firstSlave.width).toBeCloseTo(leaves[1].x + leaves[1].width, 6); + expect(secondSlave.x).toBeCloseTo(leaves[2].x, 6); + expect(secondSlave.x + secondSlave.width).toBeCloseTo(leaves[3].x + leaves[3].width, 6); + + // And master covers both slaves, which is the whole table. + expect(master.x).toBeCloseTo(firstSlave.x, 6); + expect(master.width).toBeCloseTo(firstSlave.width + secondSlave.width, 6); + + // Each level sits directly below the one above it, none of them overlapping. + expect(firstSlave.y).toBeCloseTo(master.y + master.height, 6); + expect(leaves[0].y).toBeCloseTo(firstSlave.y + firstSlave.height, 6); + + // And the header block sits over the records rather than beside them: the first + // record's cells line up with the leaf headers, column for column. Without this + // the checks above would all hold with the whole header block shifted sideways. + const drawn = getDrawnRectangles(args.pdf).filter(rectangle => rectangle.page === 1); + const headerBottom = Math.max(...drawn.filter(r => r.filled).map(r => r.y + r.height)); + const below = drawn + .filter(rectangle => !rectangle.filled && rectangle.y >= headerBottom - 0.01) + .sort((a, b) => (a.y - b.y) || (a.x - b.x)); + const firstRecord = below.filter(rectangle => rectangle.y === below[0].y); + + expect(firstRecord.length).toBe(leaves.length); + firstRecord.forEach((cell, index) => { + expect(cell.x).toBeCloseTo(leaves[index].x, 6); + expect(cell.width).toBeCloseTo(leaves[index].width, 6); + }); + done(); + }); + + exporter.export(grid, options); + }); + + it('should draw a group over its columns when the levels are uneven', (done) => { + const fix = TestBed.createComponent(ColumnGroupTestComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // This grid nests unevenly. `ID` stands on its own beside the groups, and + // `General Information` holds a plain column, `CompanyName`, next to a group of + // its own, so its children do not all sit on the same level. + const id = getHeaderCellOf(args.pdf, 'ID'); + const general = getHeaderCellOf(args.pdf, 'General Information'); + const personDetails = getHeaderCellOf(args.pdf, 'Person Details'); + const addressInfo = getHeaderCellOf(args.pdf, 'Address Information'); + const companyName = getHeaderCellOf(args.pdf, 'Company...'); + const leafWidth = getHeaderCellOf(args.pdf, 'Country').width; + + // Each top level header picks up exactly where the previous one stopped, and + // covers one column per leaf beneath it - one, three and seven of them. + expect(general.x).toBeCloseTo(id.x + id.width, 6); + expect(addressInfo.x).toBeCloseTo(general.x + general.width, 6); + expect(id.width).toBeCloseTo(leafWidth, 6); + expect(general.width).toBeCloseTo(leafWidth * 3, 6); + expect(addressInfo.width).toBeCloseTo(leafWidth * 7, 6); + + // The nested group ends where its parent does, sitting to the right of the plain + // column it shares that parent with. + expect(personDetails.x).toBeCloseTo(companyName.x + companyName.width, 6); + expect(personDetails.x + personDetails.width) + .toBeCloseTo(general.x + general.width, 6); + + // A column with no group under it is drawn tall enough to reach the bottom of the + // header block instead of leaving a gap: `ID` spans all three levels and + // `CompanyName`, one level further down, spans the two below it. + expect(id.height).toBeGreaterThan(companyName.height); + expect(companyName.height).toBeGreaterThan(personDetails.height); + done(); + }); + + exporter.export(grid, options); + }); + + it('should flatten the groups when told to ignore multi column headers', (done) => { + const fix = TestBed.createComponent(NestedColumnGroupsGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + options.ignoreMultiColumnHeaders = true; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + // Only the leaf columns are left to head the table - neither Master nor either + // Slave reaches the document - and the records are untouched. + for (const page of pages) { + expect(page[0]).toEqual(['Address', 'Phone', 'Fax', 'City']); + } + expect(getRenderedText(args.pdf)).not.toContain('Master'); + expect(getRenderedText(args.pdf)).not.toContain('Slave 1'); + expect(pages[0][1]).toEqual(['Obere Str. 57', '030-0074321', '030-0076545', 'Berlin']); + done(); + }); + + exporter.export(grid, options); + }); + + it('should narrow a group when one of its columns is hidden', (done) => { + const fix = TestBed.createComponent(NestedColumnGroupsGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + // Hide Phone, which is the second of Slave 1's two columns. + grid.columnList.find(column => column.field === 'Phone').hidden = true; + options.ignoreColumnsVisibility = false; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages[0][0]).toEqual(['Master']); + expect(pages[0][1]).toEqual(['Slave 1', 'Slave 2']); + expect(pages[0][2]).toEqual(['Address', 'Fax', 'City']); + + // Slave 1 is down to a single column while Slave 2 still has two, and the two of + // them still meet without a gap and still fill the table between them. + const master = getHeaderCellOf(args.pdf, 'Master'); + const firstSlave = getHeaderCellOf(args.pdf, 'Slave 1'); + const secondSlave = getHeaderCellOf(args.pdf, 'Slave 2'); + const leaves = ['Address', 'Fax', 'City'].map(field => getHeaderCellOf(args.pdf, field)); + + expect(firstSlave.width).toBeCloseTo(leaves[0].width, 6); + expect(secondSlave.width).toBeCloseTo(leaves[0].width * 2, 6); + expect(secondSlave.x).toBeCloseTo(firstSlave.x + firstSlave.width, 6); + expect(master.width).toBeCloseTo(firstSlave.width + secondSlave.width, 6); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export the groups of an empty grid', (done) => { + const fix = TestBed.createComponent(NestedColumnGroupsGridComponent); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + grid.data = []; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // All three header levels are drawn even with nothing to put under them. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Master'], + ['Slave 1', 'Slave 2'], + ['Address', 'Phone', 'Fax', 'City'] + ]); + expect(getPageCount(args.pdf)).toBe(1); + done(); + }); + + exporter.export(grid, options); + }); + }); + + + describe('Multi column header variations', () => { + let fix; + let grid; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + MultiColumnHeadersExportComponent, + GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(MultiColumnHeadersExportComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + }); + + it('should export the groups as the grid has them collapsed and expanded', (done) => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // The grid shows `General Information` expanded and `Location` collapsed, and the + // export follows it: `CompanyName` is marked visible only when its group is + // collapsed, so it stays out, while `Country` is hidden yet marked visible when + // collapsed, so it comes in. `Region`, `City` and `Address` are hidden without + // that marking and stay out. + expect(getRenderedRowsByPage(args.pdf)[0].slice(0, 4)).toEqual([ + ['General Information', 'Address Information'], + ['ID', 'Personal Details', 'Location', 'Contact Information'], + ['ContactName', 'ContactTitle', 'Country', 'Phone', 'Fax', 'PostalCode'], + ['ALFKI', 'Maria Anders', 'Sales Representative', 'Germany', '030-0074321', '030-0076545', '12209'] + ]); + done(); + }); + + exporter.export(grid, options); + }); + + it('should follow a group that the grid has collapsed', (done) => { + grid.columnList.find(column => column.header === 'General Information').expanded = false; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // Collapsing the group swaps what is under it: `Personal Details` is marked as + // not visible when collapsed and goes, taking its two columns with it, while + // `CompanyName` is marked as visible when collapsed and takes their place. It + // heads no group of its own, so it is drawn tall and lands on its own baseline + // between the two header rows. + expect(getRenderedRowsByPage(args.pdf)[0].slice(0, 4)).toEqual([ + ['General Information', 'Address Information'], + ['ID', 'Location', 'Contact Information'], + ['CompanyName'], + ['Country', 'Phone', 'Fax', 'PostalCode'] + ]); + expect(getRenderedText(args.pdf)).not.toContain('Personal Details'); + expect(getRenderedText(args.pdf)).not.toContain('ContactName'); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export a pinned column group ahead of the rest', (done) => { + grid.columnList.find(column => column.header === 'Address Information').pinned = true; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // The pinned group leads, with its own sub groups and columns intact, and the + // records are reordered to match. + expect(getRenderedRowsByPage(args.pdf)[0].slice(0, 4)).toEqual([ + ['Address Information', 'General Information'], + ['Location', 'Contact Information', 'ID', 'Personal Details'], + ['Country', 'Phone', 'Fax', 'PostalCode', 'ContactName', 'ContactTitle'], + ['Germany', '030-0074321', '030-0076545', '12209', 'ALFKI', 'Maria Anders', 'Sales Representative'] + ]); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export a moved column in the place the grid moved it to', (done) => { + grid.columnList.get(0).move(2); + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // Moving `ID` puts it inside `General Information`, after `Personal Details`, + // and the export places both the header and the values there. + expect(getRenderedRowsByPage(args.pdf)[0].slice(0, 4)).toEqual([ + ['General Information', 'Address Information'], + ['Personal Details', 'ID', 'Location', 'Contact Information'], + ['ContactName', 'ContactTitle', 'Country', 'Phone', 'Fax', 'PostalCode'], + ['Maria Anders', 'Sales Representative', 'ALFKI', 'Germany', '030-0074321', '030-0076545', '12209'] + ]); + done(); + }); + + exporter.export(grid, options); + }); + + it('should leave out a column group turned away during columnExporting', (done) => { + exporter.columnExporting.subscribe((args) => { + if (args.header === 'Address Information') { + args.cancel = true; + } + }); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // Turning away a group takes everything under it with it - both of its sub groups + // and all four of their columns - and what is left closes up behind it. + expect(getRenderedRowsByPage(args.pdf)[0].slice(0, 4)).toEqual([ + ['General Information'], + ['ID', 'Personal Details'], + ['ContactName', 'ContactTitle'], + ['ALFKI', 'Maria Anders', 'Sales Representative'] + ]); + expect(getRenderedText(args.pdf)).not.toContain('Address Information'); + expect(getRenderedText(args.pdf)).not.toContain('Phone'); + done(); + }); + + exporter.export(grid, options); + }); + + it('should export three levels of groups over only two records', (done) => { + const twoRows = TestBed.createComponent(GridWithThreeLevelsOfMultiColumnHeadersAndTwoRowsExportComponent); + twoRows.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // All three header levels are drawn over the two records, on one page. + expect(getRenderedRowsByPage(args.pdf)[0]).toEqual([ + ['General Information', 'Address Information'], + ['ID', 'Personal Details', 'Location', 'Contact Information'], + ['ContactName', 'ContactTitle', 'Country', 'Phone', 'Fax', 'PostalCode'], + ['ALFKI', 'Maria Anders', 'Sales Representative', 'Germany', '030-0074321', '030-0076545', '12209'], + ['ANATR', 'Ana Trujillo', 'Owner', 'Mexico', '(5) 555-4729', '(5) 555-3745', '05021'] + ]); + expect(getPageCount(args.pdf)).toBe(1); + done(); + }); + + exporter.export(twoRows.componentInstance.grid, options); + }); + + it('should draw the headers even when alwaysExportHeaders is turned off', (done) => { + grid.data = []; + options.alwaysExportHeaders = false; + fix.detectChanges(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + // The PDF exporter does not read `alwaysExportHeaders` at all - only the CSV and + // Excel exporters do - so an empty grid still comes out with its full header + // block. This pins that rather than endorsing it. + expect(getRenderedRowsByPage(args.pdf)[0]).toEqual([ + ['General Information', 'Address Information'], + ['ID', 'Personal Details', 'Location', 'Contact Information'], + ['ContactName', 'ContactTitle', 'Country', 'Phone', 'Fax', 'PostalCode'] + ]); + done(); + }); + + exporter.export(grid, options); + }); + }); + describe('Pivot Grid PDF Export', () => { let pivotGrid: IgxPivotGridComponent; let fix; @@ -502,9 +1151,32 @@ describe('PDF Grid Exporter', () => { pivotGrid = fix.componentInstance.pivotGrid; }); + /** The seven sellers the sample pivots on, which head the table. */ + const SELLER_COLUMNS = ['Stanley', 'Elisa', 'Lydia', 'David', 'John', 'Larry', 'Walter']; + it('should export basic pivot grid', (done) => { - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + // The sellers head the table, each over its own pair of aggregations. + expect(rows[0]).toEqual(SELLER_COLUMNS); + expect(rows[1].length).toBe(SELLER_COLUMNS.length * 2); + + // The three row dimensions head the seven records from the left, and a value that + // repeats down them is drawn once over the records it heads, the way the grid + // merges its own row headers: one cell for the four records of 'Clothi...', and + // one inside it for the two Bulgarian ones. A value merges under the same parent + // only - the two 'Urug...' records sit under different categories, so they keep a + // cell each - and the date, which no two records share, marks out the rows. + const cells = getRenderedCells(args.pdf); + const columnX = [...new Set(cells.map(cell => cell.x))].sort((a, b) => a - b); + const inColumn = (index: number) => + cells.filter(cell => cell.x === columnX[index]).map(cell => cell.text); + + expect(inColumn(0)).toEqual(['Clothi...', 'Bikes', 'Acce...', 'Com...']); + expect(inColumn(1)).toEqual(['Bulga...', 'USA', 'Urug...', 'Urug...', 'USA', 'USA']); + expect(inColumn(2).length).toBe(7); done(); }); @@ -514,8 +1186,15 @@ describe('PDF Grid Exporter', () => { it('should export pivot grid with row headers', (done) => { pivotGrid.pivotUI.showRowHeaders = true; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + // Showing the row headers adds a row of dimension names between the sellers and + // their aggregations - the three row dimensions the sample pivots by. + expect(rows[0]).toEqual(SELLER_COLUMNS); + expect(rows[1].length).toBe(3); + expect(rows[2].length).toBe(SELLER_COLUMNS.length * 2); done(); }); @@ -540,9 +1219,19 @@ describe('PDF Grid Exporter', () => { }]; fix.detectChanges(); - exporter.exportEnded.pipe(first()).subscribe(() => { - expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - done(); + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + // The row layout is a display choice: the export lays the dimensions out the same + // way either way round, so this comes out like the row headers case above - the + // seven records, and the two dimension cells that head several of them each drawn + // once, in the middle of the rows they cover and so on a row of their own here. + expect(rows[0]).toEqual(SELLER_COLUMNS); + expect(rows[1].length).toBe(3); + expect(rows.slice(3).filter(row => row.length > 1).length).toBe(7); + expect(rows.slice(3).filter(row => row.length === 1).length).toBe(2); + done(); }); exporter.export(pivotGrid, options); @@ -551,8 +1240,10 @@ describe('PDF Grid Exporter', () => { it('should export pivot grid with custom page size', (done) => { options.pageSize = 'letter'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.letterLandscape); + expect(getRenderedRows(args.pdf)[0]).toEqual(SELLER_COLUMNS); done(); }); @@ -562,8 +1253,10 @@ describe('PDF Grid Exporter', () => { it('should export pivot grid with landscape orientation', (done) => { options.pageOrientation = 'landscape'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Landscape); + expect(getRenderedRows(args.pdf)[0]).toEqual(SELLER_COLUMNS); done(); }); @@ -573,8 +1266,11 @@ describe('PDF Grid Exporter', () => { it('should export pivot grid without table borders', (done) => { options.showTableBorders = false; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Not even the shaded backgrounds a pivot header would otherwise get. + expect(getDrawnRectangles(args.pdf)).toEqual([]); + expect(getRenderedRows(args.pdf)[0]).toEqual(SELLER_COLUMNS); done(); }); @@ -584,8 +1280,10 @@ describe('PDF Grid Exporter', () => { it('should export pivot grid with custom font size', (done) => { options.fontSize = 14; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(new Set(getRenderedCells(args.pdf).map(cell => cell.fontSize))).toEqual(new Set([14])); + expect(getRenderedRows(args.pdf)[0]).toEqual(SELLER_COLUMNS); done(); }); @@ -598,8 +1296,37 @@ describe('PDF Grid Exporter', () => { fix.whenStable().then(() => { pivotGrid = fix.componentInstance.pivotGrid; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const rows = getRenderedRows(args.pdf); + // The countries head the table, each over the two aggregations of the sample. + expect(rows[0]).toEqual(['Bulgaria', 'US', 'Uruguay', 'UK', 'Japan']); + expect(rows[1]).toEqual(rows[0].flatMap(() => ['UnitsSold', 'Amount ...'])); + + // Two row dimensions, city over product: the totals across all cities first, + // then each city with the products it sold. A city is drawn once, over the + // records it heads, the way the grid merges its own row headers, while the + // products under it get a cell each. Every value comes from the record + // itself, so a grid with more records than row header columns still labels + // each row correctly. + const cells = getRenderedCells(args.pdf); + const columnX = [...new Set(cells.map(cell => cell.x))].sort((a, b) => a - b); + const inColumn = (index: number) => + cells.filter(cell => cell.x === columnX[index]).map(cell => cell.text); + + expect(inColumn(0)).toEqual([ + 'All Cities', 'Plovdiv', 'New York', 'Ciudad d...', 'London', 'Yokohama', 'Sofia' + ]); + expect(inColumn(1)).toEqual([ + 'AllProducts', 'Clothing', 'Bikes', 'Accessori...', 'Compone...', + 'AllProducts', 'Clothing', + 'AllProducts', 'Clothing', + 'AllProducts', 'Bikes', 'Clothing', + 'AllProducts', 'Accessori...', + 'AllProducts', 'Compone...', + 'AllProducts', 'Compone...' + ]); done(); }); diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-utils.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-utils.spec.ts new file mode 100644 index 00000000000..03d54ecbb35 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-utils.spec.ts @@ -0,0 +1,240 @@ +/* + * Helpers for reading an exported PDF back. jsPDF keeps no record of what it has drawn, so + * everything the exporter specs assert is recovered from the content streams of the produced + * pages: each `text()` call leaves a `BT ... (text) Tj ... ET` block behind and each `rect()` + * an `x y w h re` operator. + * + * Named as a spec so that the test tsconfig compiles it; it holds no tests of its own. + */ +import type { jsPDF } from 'jspdf'; + +/** A single `text()` call, as recovered from the content stream of the page it was drawn on. */ +export interface IRenderedCell { + /** The drawn text, with the PDF string escaping undone. */ + text: string; + /** Offset from the left edge of the page, in points. */ + x: number; + /** Offset from the *top* edge of the page, in points. */ + y: number; + /** One based page number. */ + page: number; + /** The internal jsPDF font reference, e.g. `F1` - it identifies both the font and its style. */ + font: string; + /** The font size the text was drawn with, in points. */ + fontSize: number; +} + +/** A rectangle drawn by `rect()`, as recovered from the content stream of its page. */ +export interface IDrawnRectangle { + x: number; + y: number; + width: number; + height: number; + page: number; + /** Whether the rectangle was filled (a cell background) rather than stroked (a border). */ + filled: boolean; +} + +/** The page dimensions jsPDF produces for the page sizes and orientations the exporter offers. */ +export const PAGE_SIZES = { + a4Portrait: { width: 595.28, height: 841.89 }, + a4Landscape: { width: 841.89, height: 595.28 }, + letterPortrait: { width: 612, height: 792 }, + letterLandscape: { width: 792, height: 612 }, + legalPortrait: { width: 612, height: 1008 }, + a3Portrait: { width: 841.89, height: 1190.55 }, + a5Portrait: { width: 419.53, height: 595.28 } +}; + + +/** Undo the escaping jsPDF applies when it writes a PDF string literal. */ +export const unescapePdfString = (value: string): string => value.replace(/\\([()\\])/g, '$1'); + +/** + * jsPDF keeps no record of what it has drawn, so everything the assertions need is read back out + * of the content streams of the produced pages, where every `text()` call leaves behind a + * `BT ... (text) Tj ... ET` block. + */ +export const getRenderedCells = (pdf: jsPDF | undefined): IRenderedCell[] => { + // `internal.pages` is one based - the element at index 0 is an unused placeholder. + const pages = (pdf?.internal.pages ?? []) as unknown as string[][]; + const pageHeight = pdf?.internal.pageSize.getHeight() ?? 0; + const cells: IRenderedCell[] = []; + + pages.slice(1).forEach((page, index) => { + const textBlocks = page.join('\n').match(/BT\n[\s\S]*?\nET/g) ?? []; + + textBlocks.forEach(block => { + const drawn = /\((?:\\.|[^()\\])*\)\s*Tj/.exec(block); + const position = /(-?[\d.]+) (-?[\d.]+) Td/.exec(block); + const font = /\/(\w+) ([\d.]+) Tf/.exec(block); + + if (!drawn) { + return; + } + + cells.push({ + text: unescapePdfString(drawn[0].replace(/\)\s*Tj$/, '').substring(1)), + x: position ? parseFloat(position[1]) : 0, + // PDF measures y from the bottom of the page - flip it so that the assertions can + // read top to bottom, the way the exported table is laid out. + y: position ? pageHeight - parseFloat(position[2]) : 0, + page: index + 1, + font: font ? font[1] : '', + fontSize: font ? parseFloat(font[2]) : 0 + }); + }); + }); + + return cells; +}; + +/** Every piece of text in the document, in the order it was drawn. */ +export const getRenderedText = (pdf: jsPDF | undefined): string[] => getRenderedCells(pdf).map(cell => cell.text); + +/** + * The document laid back out as a table: the cells grouped into the rows they share a baseline + * with, ordered down the page and then left to right. Merged header cells are centred vertically + * over the rows they span, so they form a row of their own. + * + * jsPDF writes nothing into the document for empty text, so a blank cell - a null, an undefined or + * a value the exporter could not resolve - leaves no entry in its row. A row can therefore come + * back shorter than the header above it; `getDrawnRectangles` still shows the cell was drawn. + */ +export const getRenderedRows = (pdf: jsPDF | undefined): string[][] => { + const rows = new Map(); + + for (const cell of getRenderedCells(pdf)) { + // Cells of the same row are drawn at an identical baseline, so rounding only guards + // against the floating point noise of the page height flip. + const key = `${cell.page}:${cell.y.toFixed(2)}`; + rows.set(key, [...(rows.get(key) ?? []), cell]); + } + + return [...rows.values()] + .sort((a, b) => (a[0].page - b[0].page) || (a[0].y - b[0].y)) + .map(row => [...row].sort((a, b) => a.x - b.x).map(cell => cell.text)); +}; + +/** The same as `getRenderedRows`, but kept split per page. */ +export const getRenderedRowsByPage = (pdf: jsPDF | undefined): string[][][] => { + const pageCount = (pdf?.internal.pages?.length ?? 1) - 1; + const cells = getRenderedCells(pdf); + + return Array.from({ length: pageCount }, (_, index) => { + const page = index + 1; + const rows = new Map(); + + for (const cell of cells.filter(c => c.page === page)) { + rows.set(cell.y.toFixed(2), [...(rows.get(cell.y.toFixed(2)) ?? []), cell]); + } + + return [...rows.values()] + .sort((a, b) => a[0].y - b[0].y) + .map(row => [...row].sort((a, b) => a.x - b.x).map(cell => cell.text)); + }); +}; + +/** Every rectangle in the document - the exporter draws one per cell background and per border. */ +export const getDrawnRectangles = (pdf: jsPDF | undefined): IDrawnRectangle[] => { + const pages = (pdf?.internal.pages ?? []) as unknown as string[][]; + const pageHeight = pdf?.internal.pageSize.getHeight() ?? 0; + const rectangles: IDrawnRectangle[] = []; + + pages.slice(1).forEach((page, index) => { + const content = page.join('\n'); + const operator = /(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+) re\s+([fS])/g; + let match: RegExpExecArray | null; + + while ((match = operator.exec(content)) !== null) { + const height = Math.abs(parseFloat(match[4])); + + rectangles.push({ + x: parseFloat(match[1]), + // jsPDF writes the rectangle from its top edge measured up from the bottom of the + // page, and gives it a negative height so that it extends downwards. Flip that + // back to an offset from the top, so it lines up with the `y` of the cells drawn + // inside the rectangle. + y: pageHeight - parseFloat(match[2]), + width: parseFloat(match[3]), + height, + page: index + 1, + filled: match[5] === 'f' + }); + } + }); + + return rectangles; +}; + +/** The page dimensions of the document, rounded to the two decimals jsPDF itself reports. */ +export const getPageDimensions = (pdf: jsPDF | undefined) => ({ + width: Math.round((pdf?.internal.pageSize.getWidth() ?? 0) * 100) / 100, + height: Math.round((pdf?.internal.pageSize.getHeight() ?? 0) * 100) / 100 +}); + +/** The number of pages the exporter ended up producing. */ +export const getPageCount = (pdf: jsPDF | undefined): number => (pdf?.internal.pages?.length ?? 1) - 1; + +/** The internal reference jsPDF uses for a font and style pair, e.g. `F1`. */ +export const getFontRef = (pdf: jsPDF | undefined, name: string, style: string): string => + (pdf as any)?.internal.getFont(name, style).id; + +/** + * The distinct fonts the text in the document was drawn with. Read from the content streams + * rather than from `getRenderedCells`, so that the draws jsPDF writes as hex glyph ids - which is + * what an embedded font produces - are counted as well. + */ +export const getUsedFontRefs = (pdf: jsPDF | undefined): Set => { + const pages = (pdf?.internal.pages ?? []) as unknown as string[][]; + const refs = new Set(); + + for (const page of pages.slice(1)) { + for (const block of page.join('\n').match(/BT\n[\s\S]*?\nET/g) ?? []) { + const font = /\/(\w+) [\d.]+ Tf/.exec(block); + + if (font && /(?:\)|>)\s*Tj/.test(block)) { + refs.add(font[1]); + } + } + } + + return refs; +}; + +/** + * The number of text draws in the document. With an embedded font jsPDF writes glyph ids in hex + * instead of readable text, and those draws never turn up in `getRenderedCells` - this counts + * both forms, so a document set in a custom font can still be checked for what it holds. + */ +export const getTextDrawCount = (pdf: jsPDF | undefined): number => { + const pages = (pdf?.internal.pages ?? []) as unknown as string[][]; + + return pages.slice(1) + .reduce((total, page) => total + (page.join('\n').match(/(?:\)|>)\s*Tj/g) ?? []).length, 0); +}; + +/** + * The cell a header caption was drawn in - the filled rectangle the caption sits inside. Summary + * rows are filled with the same shade, but a header caption is only ever drawn over its own + * header cell. Use it to check that a column group is drawn over the columns it spans rather than + * merely on the right level. + */ +export const getHeaderCellOf = (pdf: jsPDF | undefined, text: string, page = 1): IDrawnRectangle => { + const label = getRenderedCells(pdf).find(cell => cell.page === page && cell.text === text); + + if (!label) { + throw new Error(`No header drawn with the text '${text}' on page ${page}`); + } + + const cell = getDrawnRectangles(pdf).find(rectangle => + rectangle.filled && rectangle.page === page && + label.x >= rectangle.x && label.x <= rectangle.x + rectangle.width && + label.y >= rectangle.y && label.y <= rectangle.y + rectangle.height); + + if (!cell) { + throw new Error(`'${text}' is drawn on page ${page} but not inside a header cell`); + } + + return cell; +}; diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts index dbfc48ae10a..4ccdac3a8e0 100644 --- a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts @@ -6,22 +6,45 @@ import { first } from 'rxjs/operators'; import { ExportRecordType, ExportHeaderType, DEFAULT_OWNER, IExportRecord, IColumnInfo, IColumnList, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; import type { jsPDF } from 'jspdf'; +import { + PAGE_SIZES, getDrawnRectangles, getFontRef, getPageCount, getPageDimensions, getRenderedCells, + getRenderedRows, getRenderedRowsByPage, getRenderedText, getTextDrawCount, getUsedFontRefs +} from './pdf-exporter-utils.spec'; + +/** + * `SampleTestData.contactsData()` as the exporter lays it out. Two of its records have a blank + * cell, and jsPDF writes nothing at all into the document for empty text, so those two rows come + * back one cell short - the cell is drawn, it just has no text in it. + */ +const CONTACTS_ROWS = [ + ['name', 'phone'], + ['Terrance Orta', '770-504-2217'], + ['Richard Mahoney LongerName'], + ['Donna Price', '859-496-2817'], + ['901-747-3428'], + ['Dorothy H. Spencer', '573-394-9254'] +]; + /** - * jsPDF keeps no record of the text it has drawn, so it is read back from the content streams of - * the produced pages, where every `text()` call leaves behind a `(...) Tj` operator. + * The smallest TrueType font jsPDF will accept: one shared, outline-less glyph that every + * printable ASCII character maps to. Registering a font makes jsPDF parse it, and a font it + * cannot parse throws out of the first text draw that uses it - so covering the custom font + * path needs a real one, even though no glyph of it is ever meant to be read. */ -const getRenderedText = (pdf: jsPDF | undefined): string[] => { - // `internal.pages` is one based - the element at index 0 is an unused placeholder. - const pages = (pdf?.internal.pages ?? []) as unknown as string[][]; - const content = pages.slice(1).flat().join('\n'); - const operators = content.match(/\((?:\\.|[^()\\])*\)\s*Tj/g) ?? []; - - return operators.map(operator => operator - .replace(/\)\s*Tj$/, '') - .substring(1) - // Unescape the characters jsPDF escapes when it writes a PDF string literal. - .replace(/\\([()\\])/g, '$1')); -}; +const MINIMAL_TTF = + 'AAEAAAAKAIAAAwAgT1MvMlq2XmgAAACsAAAAamNtYXAADACxAAABGAAAACxnbHlmAAAAAAAAAUQAAAAAaGVhZGL/Qz0AAAFEAAAA' + + 'NmhoZWEHCgEvAAABfAAAACRobXR4A+gAAAAAAaAAAAAIbG9jYQAAAAAAAAGoAAAABm1heHAAAwACAAABsAAAACBuYW1lCj8icQAA' + + 'AdAAAACscG9zdAADAAAAAAJ8AAAAIAAEAfQBkAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AQAAAAAAAAAAAAAAAFRFU1QAQAAgAH4DIP84AMgDIADIAAAAAQAAAAAB9AD6AyAAAAAAAAEAAAAAAAEAAwABAAAADAAEACAAAAAE' + + 'AAQAAQAAAH7//wAAACD////hAAEAAAAAAAEAAAABAAD/pxBFXw889QADA+gAAAAAAAAAAAAAAAAAAAAAAAD/OAPoAyAAAAAIAAIA' + + 'AAAAAAAAAQAAAyD/OAAAAfQAAAAAA+gAAQAAAAAAAAAAAAAAAAAAAAIB9AAAAfQAAAAAAAAAAAAAAAEAAAACAAAAAAAAAAAAAgAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAGAE4AAwABBAkAAQAQAAAAAwABBAkAAgAOABAAAwABBAkAAwAQAB4AAwABBAkABAAQAC4AAwABBAkA' + + 'BQAQAD4AAwABBAkABgAQAE4ATQBpAG4AaQBUAGUAcwB0AFIAZQBnAHUAbABhAHIATQBpAG4AaQBUAGUAcwB0AE0AaQBuAGkAVABl' + + 'AHMAdABNAGkAbgBpAFQAZQBzAHQATQBpAG4AaQBUAGUAcwB0AAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; + +/** The warning the exporter logs when it is handed a custom font it cannot use. */ +const INCOMPLETE_FONT_WARNING = 'Custom font configuration is incomplete (missing name or data), falling back to helvetica'; + describe('PDF Exporter', () => { let exporter: IgxPdfExporterService; @@ -38,13 +61,37 @@ describe('PDF Exporter', () => { spyOn(ExportUtilities, 'saveBlobToFile'); }); + /** + * `exportData` re-wraps every element it is given as a plain `DataRecord`, so it cannot be used + * to exercise the pivot, hierarchical, tree and summary code paths - handed export records it + * would export the record wrappers themselves. These tests pass the already built export + * records straight to the exporter instead, which is what a grid does. + */ + const exportRecords = (records: IExportRecord[]) => { + (exporter as any).options = options; + (exporter as any).isPivotGridExport = records[0]?.type === ExportRecordType.PivotGridRecord; + (exporter as any).exportGridRecordsData(records); + }; + + /** + * Hands the records straight to the PDF exporter, skipping the preparation the base exporter + * does on the way in. Only needed for the few cases the base exporter refuses to prepare at + * all, such as a record whose owner is missing from the owners map. + */ + const drawRecords = (records: IExportRecord[]) => { + (exporter as any).exportDataImplementation(records, options, () => { }); + }; + it('should be created', () => { expect(exporter).toBeTruthy(); }); it('should export empty data without errors', (done) => { - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // A single, entirely blank page - there is neither a header nor a row to draw. + expect(getPageCount(args.pdf)).toBe(1); + expect(getRenderedText(args.pdf)).toEqual([]); done(); }); @@ -57,8 +104,13 @@ describe('PDF Exporter', () => { { Name: 'Jane', Age: 25 } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['John', '30'], + ['Jane', '25'] + ]); done(); }); @@ -66,8 +118,26 @@ describe('PDF Exporter', () => { }); it('should export contacts data successfully', (done) => { - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should draw the header row in bold and the data rows in the regular font', (done) => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + const cells = getRenderedCells(args.pdf); + const headerFont = cells.filter(cell => cell.y === cells[0].y).map(cell => cell.font); + const dataFonts = cells.filter(cell => cell.y !== cells[0].y).map(cell => cell.font); + + // Two distinct font references, one used for the header row and one for everything + // below it - jsPDF gives a font and style pair a reference of its own. + expect(new Set(headerFont).size).toBe(1); + expect(new Set(dataFonts).size).toBe(1); + expect(headerFont[0]).not.toEqual(dataFonts[0]); done(); }); @@ -77,8 +147,10 @@ describe('PDF Exporter', () => { it('should export with custom page orientation', (done) => { options.pageOrientation = 'landscape'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Landscape); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); done(); }); @@ -88,8 +160,10 @@ describe('PDF Exporter', () => { it('should export with custom page size', (done) => { options.pageSize = 'letter'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.letterLandscape); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); done(); }); @@ -99,8 +173,39 @@ describe('PDF Exporter', () => { it('should export without table borders', (done) => { options.showTableBorders = false; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Not a single rectangle is drawn - neither the cell borders nor the header + // background, which is the one filled rectangle a bordered export produces. + expect(getDrawnRectangles(args.pdf)).toEqual([]); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should draw a bordered cell for every header and data cell', (done) => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + const rectangles = getDrawnRectangles(args.pdf); + const cells = rectangles.filter(rectangle => !rectangle.filled); + const backgrounds = rectangles.filter(rectangle => rectangle.filled); + + const headerY = Math.min(...cells.map(cell => cell.y)); + const dataCells = cells.filter(cell => cell.y !== headerY); + + // One bordered cell per column of the header row and of each of the five data rows. + expect(cells.length).toBe(2 * 6); + expect(dataCells.length).toBe(2 * 5); + // All the same width, tiling the page in two columns from a single left margin, and + // every data row the same height as the next. + expect(new Set(cells.map(cell => cell.width)).size).toBe(1); + expect(new Set(cells.map(cell => cell.x)).size).toBe(2); + expect(new Set(dataCells.map(cell => cell.height)).size).toBe(1); + // Behind them, a single filled rectangle shading the header row across both columns. + expect(backgrounds.length).toBe(1); + expect(backgrounds[0].width).toBe(cells[0].width * 2); + expect(backgrounds[0].y).toBe(Math.min(...cells.map(cell => cell.y))); done(); }); @@ -110,8 +215,11 @@ describe('PDF Exporter', () => { it('should export with custom font size', (done) => { options.fontSize = 12; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedCells(args.pdf).map(cell => cell.fontSize)).toEqual( + getRenderedCells(args.pdf).map(() => 12)); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); done(); }); @@ -124,8 +232,16 @@ describe('PDF Exporter', () => { { Name: undefined, Age: 25 } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Both rows are laid out in full, but the null and the undefined become empty text, + // which jsPDF does not write into the document at all. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['John'], + ['25'] + ]); + expect(getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled).length).toBe(2 * 3); done(); }); @@ -138,8 +254,13 @@ describe('PDF Exporter', () => { { Name: 'Jane', BirthDate: new Date('1995-06-15') } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'BirthDate'], + ['John', new Date('1990-01-01').toLocaleDateString()], + ['Jane', new Date('1995-06-15').toLocaleDateString()] + ]); done(); }); @@ -149,8 +270,10 @@ describe('PDF Exporter', () => { it('should export with portrait orientation', (done) => { options.pageOrientation = 'portrait'; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Portrait); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); done(); }); @@ -158,7 +281,11 @@ describe('PDF Exporter', () => { }); it('should export with various page sizes', (done) => { - const pageSizes = ['a3', 'a5', 'legal']; + const pageSizes: [string, { width: number; height: number }][] = [ + ['a3', PAGE_SIZES.a3Portrait], + ['a5', PAGE_SIZES.a5Portrait], + ['legal', PAGE_SIZES.legalPortrait] + ]; let completed = 0; const exportNext = (index: number) => { @@ -168,10 +295,16 @@ describe('PDF Exporter', () => { return; } + const [pageSize, portraitDimensions] = pageSizes[index]; const opts = new IgxPdfExporterOptions('Test'); - opts.pageSize = pageSizes[index] as any; + opts.pageSize = pageSize as any; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + // The exporter defaults to landscape, so the page comes out with the two + // dimensions of the requested size the other way round. + expect(getPageDimensions(args.pdf)) + .toEqual({ width: portraitDimensions.height, height: portraitDimensions.width }); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); completed++; exportNext(completed); }); @@ -185,8 +318,10 @@ describe('PDF Exporter', () => { it('should export with different font sizes', (done) => { options.fontSize = 14; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(new Set(getRenderedCells(args.pdf).map(cell => cell.fontSize))).toEqual(new Set([14])); + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); done(); }); @@ -194,13 +329,21 @@ describe('PDF Exporter', () => { }); it('should export large dataset requiring pagination', (done) => { - const largeData = []; + const largeData: { Name: string; Age: number; City: string }[] = []; for (let i = 0; i < 100; i++) { largeData.push({ Name: `Person ${i}`, Age: 20 + (i % 50), City: `City ${i % 10}` }); } - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBe(5); + // Every page repeats the header row and then carries on where the previous one + // stopped, so no record is dropped at a page break and none is drawn twice. + expect(pages.map(page => page[0])).toEqual(pages.map(() => ['Name', 'Age', 'City'])); + expect(pages.flatMap(page => page.slice(1))).toEqual( + largeData.map(record => [record.Name, String(record.Age), record.City])); done(); }); @@ -213,8 +356,20 @@ describe('PDF Exporter', () => { { Name: 'Jane', Description: 'Another extremely long text that needs to be handled properly in the PDF export without breaking the layout' } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const [, firstRow, secondRow] = getRenderedRows(args.pdf); + const columnWidth = getDrawnRectangles(args.pdf)[0].width; + + for (const [description, original] of [firstRow[1], secondRow[1]] + .map((text, index) => [text, dataWithLongText[index].Description] as const)) { + // Cut short, marked with an ellipsis, and still a prefix of the original value. + expect(description.endsWith('...')).toBeTrue(); + expect(description.length).toBeLessThan(original.length); + expect(original.startsWith(description.slice(0, -3))).toBeTrue(); + expect(args.pdf!.getTextWidth(description)).toBeLessThanOrEqual(columnWidth - 10); + } done(); }); @@ -227,8 +382,16 @@ describe('PDF Exporter', () => { { String: 'More text', Number: 3.14, Boolean: false, Date: new Date('2023-12-31'), Null: null, Undefined: undefined } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Numbers, booleans and dates are all stringified; the null and undefined columns are + // drawn as cells but hold no text. + expect(getRenderedRows(args.pdf)).toEqual([ + ['String', 'Number', 'Boolean', 'Date', 'Null', 'Undefined'], + ['Text', '42', 'true', new Date('2023-01-01').toLocaleDateString()], + ['More text', '3.14', 'false', new Date('2023-12-31').toLocaleDateString()] + ]); + expect(getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled).length).toBe(6 * 3); done(); }); @@ -242,6 +405,10 @@ describe('PDF Exporter', () => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); const callArgs = (ExportUtilities.saveBlobToFile as jasmine.Spy).calls.mostRecent().args; expect(callArgs[1]).toBe('CustomFileName.pdf'); + // The exporter hands over the document itself, as a PDF blob. + expect(callArgs[0] instanceof Blob).toBeTrue(); + expect(callArgs[0].type).toBe('application/pdf'); + expect(callArgs[0].size).toBeGreaterThan(0); done(); }); @@ -255,8 +422,15 @@ describe('PDF Exporter', () => { { Name: 'Jane', Age: 25 } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The empty record still takes a row of its own, it simply has nothing to draw in it. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['John', '30'], + ['Jane', '25'] + ]); + expect(getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled).length).toBe(2 * 4); done(); }); @@ -267,6 +441,10 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(args).toBeDefined(); expect(args.pdf).toBeDefined(); + // The emitted document is the one that was saved, fully drawn by the time it arrives. + expect(getRenderedRows(args.pdf)).toEqual(CONTACTS_ROWS); + expect(args.pdf!.output('blob').size).toBe( + (ExportUtilities.saveBlobToFile as jasmine.Spy).calls.mostRecent().args[0].size); done(); }); @@ -274,6 +452,19 @@ describe('PDF Exporter', () => { }); describe('Custom Font Support', () => { + /** + * Every rejected font configuration has to end up in the same place: nothing registered on + * the document, both the regular and the bold font back on helvetica, and a table that is + * drawn exactly as it would have been without a custom font at all. + */ + const expectHelveticaFallback = (pdf: jsPDF | undefined) => { + expect(getUsedFontRefs(pdf)).toEqual(new Set([ + getFontRef(pdf, 'helvetica', 'normal'), + getFontRef(pdf, 'helvetica', 'bold') + ])); + expect(getRenderedRows(pdf)).toEqual(CONTACTS_ROWS); + }; + beforeEach(() => { // Guard against karma-parallel sharding leaving a stale spy from a skipped test if (jasmine.isSpy(console.warn)) { @@ -288,7 +479,9 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expectHelveticaFallback(args.pdf); + // Nothing was configured, so there is nothing to complain about either. + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -300,7 +493,9 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expectHelveticaFallback(args.pdf); + // `null` reads as "no custom font" rather than as a broken one. + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -312,7 +507,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expectHelveticaFallback(args.pdf); + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -327,8 +523,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -343,8 +539,11 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); + // A configuration turned away for being incomplete never reaches the document, + // so the font it names is not registered on it at all. + expect(args.pdf!.getFontList().TestFont).toBeUndefined(); done(); }); @@ -359,8 +558,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -376,7 +575,9 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // The rejected font does not cost the export its page setup. + expect(getPageDimensions(args.pdf)).toEqual(PAGE_SIZES.a4Portrait); + expectHelveticaFallback(args.pdf); done(); }); @@ -384,7 +585,10 @@ describe('PDF Exporter', () => { }); it('should export with incomplete custom font and different page sizes', (done) => { - const pageSizes = ['a3', 'letter']; + const pageSizes: [string, { width: number; height: number }][] = [ + ['a3', PAGE_SIZES.a3Portrait], + ['letter', PAGE_SIZES.letterPortrait] + ]; let completed = 0; const exportNext = (index: number) => { @@ -394,14 +598,18 @@ describe('PDF Exporter', () => { return; } + const [pageSize, portraitDimensions] = pageSizes[index]; const opts = new IgxPdfExporterOptions('Test'); - opts.pageSize = pageSizes[index]; + opts.pageSize = pageSize; opts.customFont = { name: '', data: '' }; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(getPageDimensions(args.pdf)) + .toEqual({ width: portraitDimensions.height, height: portraitDimensions.width }); + expectHelveticaFallback(args.pdf); completed++; exportNext(completed); }); @@ -421,7 +629,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expect(getDrawnRectangles(args.pdf)).toEqual([]); + expectHelveticaFallback(args.pdf); done(); }); @@ -435,8 +644,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -450,8 +659,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -463,8 +672,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -479,8 +688,8 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -495,24 +704,146 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); - expect(console.warn).toHaveBeenCalled(); + expectHelveticaFallback(args.pdf); + expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should fall back to helvetica when the custom font data is not a readable font', (done) => { + // Well formed base64 that is not a font. jsPDF accepts the file without complaint and + // only fails when the font is first used, which used to happen part way through + // drawing the table - inside the promise the export runs in, so the export died + // there: no document, no file and nothing raised to the caller. + options.customFont = { name: 'TestFont', data: 'bm90LWEtcmVhbC1mb250' }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Failed to load custom font 'TestFont', falling back to helvetica:`, jasmine.any(Error)); + // The export finishes, in helvetica, with the whole table in it. + expectHelveticaFallback(args.pdf); + // The font was registered before it turned out to be unusable, so it stays on the + // document - but with no glyph data to embed and nothing set in it, all it costs + // is an unused entry. + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should fall back to helvetica when only the bold variant is unreadable', (done) => { + options.customFont = { + name: 'TestFont', + data: MINIMAL_TTF, + bold: { name: 'TestFontBold', data: 'bm90LWEtcmVhbC1mb250' } + }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + `Failed to load custom font 'TestFont', falling back to helvetica:`, jasmine.any(Error)); + // A broken variant takes the whole configuration down with it rather than leaving + // the document half in one font and half in another. + expectHelveticaFallback(args.pdf); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should register the custom font and set the whole document in it', (done) => { + options.customFont = { name: 'TestFont', data: MINIMAL_TTF }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + // No bold variant was given, so the regular font is registered for both styles. + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + + // Nothing is left in helvetica: the header row uses the bold registration of the + // custom font and everything below it the regular one. + expect(getUsedFontRefs(args.pdf)).toEqual(new Set([ + getFontRef(args.pdf, 'TestFont', 'normal'), + getFontRef(args.pdf, 'TestFont', 'bold') + ])); + // An embedded font is written as glyph ids rather than as readable text, so the + // table is counted rather than read back: one draw per non-empty cell. + expect(getTextDrawCount(args.pdf)).toBe(CONTACTS_ROWS.flat().length); + expect(getRenderedText(args.pdf)).toEqual([]); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should register a separate bold variant when one is provided', (done) => { + options.customFont = { + name: 'TestFont', + data: MINIMAL_TTF, + bold: { name: 'TestFontBold', data: MINIMAL_TTF } + }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + + // Each font is registered for the one style it was given for. + expect(args.pdf!.getFontList().TestFont).toEqual(['normal']); + expect(args.pdf!.getFontList().TestFontBold).toEqual(['bold']); + + expect(getUsedFontRefs(args.pdf)).toEqual(new Set([ + getFontRef(args.pdf, 'TestFont', 'normal'), + getFontRef(args.pdf, 'TestFontBold', 'bold') + ])); done(); }); exporter.exportData(SampleTestData.contactsData(), options); }); + it('should measure cell widths with the custom font when truncating', (done) => { + const longText = 'A value far too long to fit the column it is drawn in'.repeat(4); + options.customFont = { name: 'TestFont', data: MINIMAL_TTF }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The custom font is what the truncation measures against - every glyph of it is + // half an em wide, so the cut lands where that width says it should. + const columnWidth = getDrawnRectangles(args.pdf).find(rectangle => !rectangle.filled)!.width; + const fits = Math.floor((columnWidth - 10) / (options.fontSize / 2)) - 3; + + expect(args.pdf!.getFont().fontName).toBe('TestFont'); + expect(args.pdf!.getTextWidth('AB')).toBe(options.fontSize); + expect(getTextDrawCount(args.pdf)).toBe(4); + expect(fits).toBeGreaterThan(0); + expect(fits).toBeLessThan(longText.length); + done(); + }); + + exporter.exportData([{ First: longText, Second: longText }], options); + }); + + /* + * The bold variant is only reached once the base font configuration is accepted, so these + * three check that a variant the exporter cannot use costs nothing - the regular font + * stands in for bold and no warning is raised. + */ it('should handle customFont with bold variant set to null', (done) => { options.customFont = { - name: '', - data: '', + name: 'TestFont', + data: MINIMAL_TTF, bold: null as any }; exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -521,14 +852,14 @@ describe('PDF Exporter', () => { it('should handle customFont with bold variant set to undefined', (done) => { options.customFont = { - name: '', - data: '', + name: 'TestFont', + data: MINIMAL_TTF, bold: undefined }; exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); done(); }); @@ -537,14 +868,35 @@ describe('PDF Exporter', () => { it('should handle customFont with bold as empty object', (done) => { options.customFont = { - name: '', - data: '', + name: 'TestFont', + data: MINIMAL_TTF, bold: {} as any }; exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // A variant without a name or data is treated as no variant at all. + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); + + it('should handle a bold variant that is missing its data', (done) => { + options.customFont = { + name: 'TestFont', + data: MINIMAL_TTF, + bold: { name: 'TestFontBold', data: '' } + }; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Only the variant is rejected, and silently - the regular font is registered for + // bold in its place and the export is otherwise unaffected. + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + expect(args.pdf!.getFontList().TestFontBold).toBeUndefined(); + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -554,7 +906,7 @@ describe('PDF Exporter', () => { it('should reset font configuration when exporting again without customFont', (done) => { let exportCallCount = 0; - // First export uses a custom font + // First export is given a custom font it cannot use options.customFont = { name: 'CustomFont', data: '' @@ -564,9 +916,8 @@ describe('PDF Exporter', () => { exportCallCount++; if (exportCallCount === 1) { - // After the first export with custom font expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + expectHelveticaFallback(args.pdf); // Clear customFont and export again options.customFont = undefined as any; @@ -575,9 +926,10 @@ describe('PDF Exporter', () => { } if (exportCallCount === 2) { - // Second export should succeed and not reuse previous custom font settings + // The second export carries nothing over from the first one. expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(2); - expect(args.pdf).toBeDefined(); + expectHelveticaFallback(args.pdf); + expect(args.pdf!.getFontList().CustomFont).toBeUndefined(); subscription.unsubscribe(); done(); } @@ -585,20 +937,38 @@ describe('PDF Exporter', () => { exporter.exportData(SampleTestData.contactsData(), options); }); + + it('should fall back to helvetica on a later export that configures no custom font', (done) => { + let exportCallCount = 0; + + options.customFont = { name: 'TestFont', data: MINIMAL_TTF }; + + const subscription = exporter.exportEnded.subscribe((args) => { + exportCallCount++; + + if (exportCallCount === 1) { + options.customFont = undefined as any; + exporter.exportData(SampleTestData.contactsData(), options); + return; + } + + // The exporter is provided in root, so the font names it holds outlive the document + // they were registered on, while the registration itself does not - the second + // export builds a document `TestFont` was never added to. Carrying the name over + // would set that document in a font it does not carry and leave every string in it + // pointing at a font the reader cannot resolve, so an export that configures no + // font of its own goes back to helvetica rather than inheriting the previous one. + expectHelveticaFallback(args.pdf); + expect(args.pdf!.getFontList().TestFont).toBeUndefined(); + subscription.unsubscribe(); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); }); - /** - * `exportData` re-wraps every element it is given as a plain `DataRecord`, so it cannot be used - * to exercise the pivot and summary code paths. These tests hand the exporter the already built - * export records instead, which is what the grid itself does. - */ describe('Export record types', () => { - const exportRecords = (records: IExportRecord[]) => { - (exporter as any).options = options; - (exporter as any).isPivotGridExport = records[0]?.type === ExportRecordType.PivotGridRecord; - (exporter as any).exportGridRecordsData(records); - }; - const pivotOwner = (columns: IColumnInfo[]): IColumnList => ({ columns, columnWidths: columns.map(() => 200), @@ -607,6 +977,47 @@ describe('PDF Exporter', () => { maxRowLevel: 1 }); + it('should label every row when there are more records than row header columns', (done) => { + // The exporter used to work a dimension value out from the row header columns by + // position, clamping any record past the last column onto it - so once a pivot grid + // had more rows than row headers, every row from there on carried the last one's + // value. The values come from the records themselves now, so the count cannot matter. + const products = ['Product A', 'Product B', 'Product C', 'Product D', 'Product E']; + const records: IExportRecord[] = products.map((product, index) => ({ + data: { Product: product, London: index * 10 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + ...(index === 0 ? { dimensionKeys: ['Product'] } : {}) + })); + + // Only two row headers for the five records. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Product B', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 1, columnSpan: 1 + }, + { + header: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['London'], + ...products.map((product, index) => [product, String(index * 10)]) + ]); + done(); + }); + + exportRecords(records); + }); + it('should export a pivot grid with a single row dimension', (done) => { const records: IExportRecord[] = [ { @@ -623,11 +1034,16 @@ describe('PDF Exporter', () => { } ]; + // One row header per record, holding the dimension value that record is drawn with. (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ { header: 'Product A', field: 'Product', skip: false, headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 }, + { + header: 'Product B', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 1, columnSpan: 1 + }, { header: 'London', field: 'London', skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 @@ -640,7 +1056,13 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // The dimension takes a column ahead of the two measures, and each record is + // matched to the row header at its own index. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London', 'Paris'], + ['Product A', '100', '200'], + ['Product B', '150', '250'] + ]); done(); }); @@ -777,7 +1199,12 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // The dimension takes a column of its own, filled from the row dimension column's + // header, and the measure keeps the only header of the table. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London'], + ['Product A', '100'] + ]); done(); }); @@ -809,7 +1236,13 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // Only the measure key is left in the record by the time the exporter sees it, and + // a key with a separator in it is not taken for a dimension, so the guess comes up + // empty and the table is drawn without a row dimension column at all. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); @@ -877,7 +1310,26 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // Each island introduces itself with its own header row, its rows follow it, and + // the export returns to the parent afterwards. The collapsed child is left out. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Id', 'Name'], + ['1', 'Parent 1'], + ['ChildId', 'Title'], + ['11', 'Child A'], + ['GrandId', 'Label'], + ['111', 'Grandchild of A'], + ['12', 'Child B'], + ['2', 'Parent 2'] + ]); + + // Each level is indented one step further to the right than the one above it. + const cells = getRenderedCells(args.pdf); + const indentOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(indentOf('Parent 1')).toBeLessThan(indentOf('Child A')); + expect(indentOf('Child A')).toBeLessThan(indentOf('Grandchild of A')); + expect(indentOf('Parent 2')).toBe(indentOf('Parent 1')); + expect(indentOf('Child B')).toBe(indentOf('Child A')); done(); }); @@ -925,7 +1377,12 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // The island contributes neither a header row nor a data row - only the parent + // record makes it into the document. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Id', 'Name'], + ['1', 'Parent 1'] + ]); done(); }); @@ -977,7 +1434,16 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + // A label and a value are joined with a colon; on their own each is rendered as it + // stands; an empty pair and a shape the exporter does not recognise both come out + // as an empty cell, which leaves no text in the document at all. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['John', '30'], + ['Count: 2', 'Avg: 27.5'], + ['Count', '27.5'], + ['5'] + ]); done(); }); @@ -1013,7 +1479,20 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + + const [headerRow, dataRow] = getRenderedRows(args.pdf); + const columnWidth = getDrawnRectangles(args.pdf)[0].width; + + for (const text of [...headerRow, ...dataRow]) { + // Every one of the four cells is cut short, marked with an ellipsis and left + // narrow enough to sit inside its column with the cell padding to spare. + expect(text.endsWith('...')).toBeTrue(); + expect(text.length).toBeLessThan(longText.length); + expect(args.pdf!.getTextWidth(text)).toBeLessThanOrEqual(columnWidth - 10); + } + // A header is centred in its cell while a value is drawn from the left edge, so + // the two are cut to different lengths even though they start from the same text. + expect(headerRow[0]).not.toEqual(dataRow[0]); done(); }); @@ -1047,6 +1526,26 @@ describe('PDF Exporter', () => { startIndex: 0, level: 0 }, + // The dimension name above comes with one row header per record, each carrying + // the value that record shows in the dimension column. + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Product B', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + }, { header: 'London', field: 'City', @@ -1099,12 +1598,21 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The dimension name is drawn once, spanning both header levels, so it sits on a + // baseline of its own between the city row and the aggregation row. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London', 'Paris'], + ['Product'], + ['Sum', 'Sum'], + ['Product A', '100', '200'], + ['Product B', '150', '250'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should export multi-dimensional pivot grid with multiple row dimensions', (done) => { @@ -1186,22 +1694,16 @@ describe('PDF Exporter', () => { columnSpan: 1, columnGroupParent: 'Paris' }, - { - header: 'Product A', - field: 'Product', - skip: false, - headerType: ExportHeaderType.RowHeader, - startIndex: 0, - level: 0 - }, - { - header: 'Category 1', - field: 'Category', - skip: false, - headerType: ExportHeaderType.RowHeader, - startIndex: 0, - level: 1 - } + // One row header per record and per dimension level, in record order - that is + // what the exporter matches a record against to fill its dimension cells. + ...['Product A', 'Product A', 'Product B'].map((header, startIndex) => ({ + header, field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, startIndex, level: 0, columnSpan: 1 + })), + ...['Category 1', 'Category 2', 'Category 1'].map((header, startIndex) => ({ + header, field: 'Category', skip: false, + headerType: ExportHeaderType.RowHeader, startIndex, level: 1, columnSpan: 1 + })) ]; const owner: IColumnList = { @@ -1214,41 +1716,239 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Both dimensions get a column of their own, filled per record, ahead of the two + // aggregation columns - and 'Product A', which heads the first two records, is + // drawn once between their rows rather than on each of them. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London', 'Paris'], + ['Product', 'Category'], + ['Sum', 'Sum'], + ['Category 1', '100', '200'], + ['Product A'], + ['Category 2', '150', '250'], + ['Product B', 'Category 1', '120', '220'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); - it('should export pivot grid with row dimension headers and multi-level column headers', (done) => { + it('should merge a row dimension value that repeats down consecutive records', (done) => { const pivotData: IExportRecord[] = [ { - data: { Product: 'Product A', 'City-London-Sum': 100, 'City-London-Avg': 50, 'City-Paris-Sum': 200 }, + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, level: 0, type: ExportRecordType.PivotGridRecord, - dimensionKeys: ['Product'] - } - ]; - - const columns: IColumnInfo[] = [ - { - header: 'Product', - field: 'Product', - skip: false, - headerType: ExportHeaderType.PivotRowHeader, - startIndex: 0, - level: 0 + dimensionKeys: ['Product', 'Category'] }, { - header: 'London', - field: 'City', - skip: false, - headerType: ExportHeaderType.MultiColumnHeader, - startIndex: 0, + data: { Product: 'Product A', Category: 'Category 2', 'City-London-Sum': 150 }, level: 0, - columnSpan: 2, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product B', Category: 'Category 1', 'City-London-Sum': 120 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 1, startIndex: 1 + }, + ...['Product A', 'Product A', 'Product B'].map((header, startIndex) => ({ + header, field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex, columnSpan: 1 + })), + ...['Category 1', 'Category 2', 'Category 1'].map((header, startIndex) => ({ + header, field: 'Category', skip: false, + headerType: ExportHeaderType.RowHeader, level: 1, startIndex, columnSpan: 1 + })), + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const cells = getRenderedCells(args.pdf); + const rectangles = getDrawnRectangles(args.pdf); + const drawnTimes = (text: string) => cells.filter(cell => cell.text === text).length; + const cellOf = (text: string) => { + const label = cells.find(cell => cell.text === text)!; + + return rectangles.find(rectangle => rectangle.filled && + label.x >= rectangle.x && label.x <= rectangle.x + rectangle.width && + label.y >= rectangle.y && label.y <= rectangle.y + rectangle.height)!; + }; + + // 'Product A' heads the first two records, so it is drawn once, in a cell as tall + // as both of them and starting at the first - the way the grid merges its own row + // headers. A value that does not repeat keeps a cell of a single record. + expect(drawnTimes('Product A')).toBe(1); + expect(cellOf('Product A').height).toBeCloseTo(2 * cellOf('Product B').height, 6); + expect(cellOf('Product A').y).toBeCloseTo(cellOf('Category 1').y, 6); + + // The categories under it differ, so each keeps its own cell - and so does the + // one that comes round again further down, under the other product. + expect(drawnTimes('Category 1')).toBe(2); + expect(cellOf('Category 1').height).toBeCloseTo(cellOf('Product B').height, 6); + done(); + }); + + exportRecords(pivotData); + }); + + it('should keep a row dimension value that repeats under different parents in cells of its own', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Category 1', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + }, + { + data: { Product: 'Product B', Category: 'Category 1', 'City-London-Sum': 150 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 1, startIndex: 1 + }, + ...['Product A', 'Product B'].map((header, startIndex) => ({ + header, field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex, columnSpan: 1 + })), + ...['Category 1', 'Category 1'].map((header, startIndex) => ({ + header, field: 'Category', skip: false, + headerType: ExportHeaderType.RowHeader, level: 1, startIndex, columnSpan: 1 + })), + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: 1, + maxLevel: 0, + maxRowLevel: 2 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The category is the same on both records, but the products above it are not, + // so the two cells stay apart - and every value sits on its own record's row. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['Product A', 'Category 1', '100'], + ['Product B', 'Category 1', '150'] + ]); + done(); + }); + + exportRecords(pivotData); + }); + + it('should open a merged row dimension cell again on the next page', (done) => { + const pivotData: IExportRecord[] = Array.from({ length: 40 }, (_, index) => ({ + data: { Product: 'All Products', 'City-London-Sum': index }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + })); + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'All Products', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getPageCount(args.pdf)).toBe(2); + + // The dimension heads all forty records, but a cell cannot run past the bottom of + // a page: it is cut off there and opened again under the headers of the next one, + // so the value is drawn once per page rather than once for the whole export. + const drawn = getRenderedCells(args.pdf).filter(cell => cell.text === 'All Products'); + expect(drawn.map(cell => cell.page)).toEqual([1, 2]); + done(); + }); + + exportRecords(pivotData); + }); + + it('should export pivot grid with row dimension headers and multi-level column headers', (done) => { + const pivotData: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100, 'City-London-Avg': 50, 'City-Paris-Sum': 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + const columns: IColumnInfo[] = [ + { + header: 'Product', + field: 'Product', + skip: false, + headerType: ExportHeaderType.PivotRowHeader, + startIndex: 0, + level: 0 + }, + { + header: 'London', + field: 'City', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 2, columnGroup: 'London' }, { @@ -1290,6 +1990,15 @@ describe('PDF Exporter', () => { level: 1, columnSpan: 1, columnGroupParent: 'Paris' + }, + { + header: 'Product A', + field: 'Product', + skip: false, + headerType: ExportHeaderType.RowHeader, + startIndex: 0, + level: 0, + columnSpan: 1 } ]; @@ -1303,12 +2012,20 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // London spans two aggregations and Paris one, so the second header level is + // wider than the first, and the dimension name spans both levels. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London', 'Paris'], + ['Product'], + ['Sum', 'Avg', 'Sum'], + ['Product A', '100', '50', '200'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should export pivot grid with PivotMergedHeader columns', (done) => { @@ -1359,12 +2076,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Column1', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should export pivot grid when dimensionKeys are inferred from record data', (done) => { @@ -1415,12 +2136,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should export pivot grid with MultiRowHeader columns', (done) => { @@ -1502,12 +2227,21 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // 'Product A' heads both records, so it is drawn once over the two of them - + // a merged cell carries its value between the rows it covers, which is why it + // comes back as a row of its own here. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['Category 1', '100'], + ['Product A'], + ['Category 2', '150'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should export pivot grid with row dimension columns by level', (done) => { @@ -1588,12 +2322,21 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // 'Product A' heads both records, so it is drawn once over the two of them - + // a merged cell carries its value between the rows it covers, which is why it + // comes back as a row of its own here. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['Category 1', '100'], + ['Product A'], + ['Category 2', '150'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); }); @@ -1687,12 +2430,20 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['Parent 1', '40'], + ['Child Name', 'Child Age'], + ['Child 1', '10'], + ['Child 2', '12'], + ['Parent 2', '45'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); it('should export hierarchical grid with multiple child levels', (done) => { @@ -1789,12 +2540,21 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent 1'], + ['Child Name'], + ['Child 1'], + ['Grandchild Name'], + ['Grandchild 1'], + ['Grandchild 2'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); it('should export hierarchical grid with multi-level headers in child grid', (done) => { @@ -1808,7 +2568,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.MultiColumnHeader, startIndex: 0, level: 0, - columnSpan: 2 + columnSpan: 2, + columnGroup: 'Location' }, { header: 'City', @@ -1817,7 +2578,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.ColumnHeader, startIndex: 0, level: 1, - columnSpan: 1 + columnSpan: 1, + columnGroupParent: 'Location' }, { header: 'Country', @@ -1826,7 +2588,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.ColumnHeader, startIndex: 1, level: 1, - columnSpan: 1 + columnSpan: 1, + columnGroupParent: 'Location' } ]; @@ -1875,12 +2638,19 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent 1'], + ['Location'], + ['City', 'Country'], + ['London', 'UK'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); }); @@ -1939,12 +2709,21 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // A tree grid keeps every record in one table; the nesting shows up as the + // indent of the first cell rather than as a separate header row per level. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Root 1', '100'], + ['Child 1', '50'], + ['Grandchild 1', '25'], + ['Root 2', '200'] + ]); done(); }); - exporter.exportData(treeData, options); + exportRecords(treeData); }); }); @@ -1988,12 +2767,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Total', 'Sum: 500'] + ]); done(); }); - exporter.exportData(summaryData, options); + exportRecords(summaryData); }); it('should export summary records with summaryResult property', (done) => { @@ -2035,15 +2818,109 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Total', '1000'] + ]); + done(); + }); + + exportRecords(summaryData); + }); + + /** + * A record followed by a summary over the same two columns, and the owner that describes + * them. The two shading tests below both need a record to hold the summary row against. + */ + const recordAndSummary = (): IExportRecord[] => [ + { + data: { name: 'Chai', value: 500 }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + data: { name: { label: 'Count', value: 1 }, value: { label: 'Sum', value: 500 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + const setTwoColumnOwner = () => (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Value', field: 'value', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + it('should shade a summary row the way the header row is shaded', (done) => { + setTwoColumnOwner(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + const cells = getRenderedCells(args.pdf); + const rectangles = getDrawnRectangles(args.pdf); + const baselineOf = (text: string) => cells.find(cell => cell.text === text)!.y; + const drawnOver = (y: number) => + rectangles.filter(rectangle => y >= rectangle.y && y <= rectangle.y + rectangle.height); + + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Chai', '500'], + ['Count: 1', 'Sum: 500'] + ]); + + // A summary closes the rows above it the way the header opens them, so both of + // its cells are filled with the header's shade - and still bordered, as every + // other cell of the table is. + const summaryCells = drawnOver(baselineOf('Count: 1')); + expect(summaryCells.filter(rectangle => rectangle.filled).length).toBe(2); + expect(summaryCells.filter(rectangle => !rectangle.filled).length).toBe(2); + + // The records above it are left on the page's own background. + expect(drawnOver(baselineOf('Chai')).filter(rectangle => rectangle.filled).length).toBe(0); + done(); + }); + + exportRecords(recordAndSummary()); + }); + + it('should leave a summary row unshaded when the table borders are turned off', (done) => { + options.showTableBorders = false; + setTwoColumnOwner(); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + // Turning the borders off takes the header background with it, and the summary + // row is shaded on the same terms as the header - so nothing at all is drawn. + expect(getDrawnRectangles(args.pdf)).toEqual([]); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Chai', '500'], + ['Count: 1', 'Sum: 500'] + ]); done(); }); - exporter.exportData(summaryData, options); + exportRecords(recordAndSummary()); }); }); + /** + * A few of these declare a row dimension that has no column carrying its value, so their + * dimension cell comes out empty. The base exporter rebuilds each record out of the owner's + * `ColumnHeader` columns on the way in, which drops any key that has no column of its own, and + * the cell is then drawn blank - a blank cell leaves no text in the document, and so no entry + * in the row. + */ describe('Edge Cases and Special Scenarios', () => { it('should skip hidden records', (done) => { const dataWithHidden: IExportRecord[] = [ @@ -2065,12 +2942,18 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The hidden record takes no row at all - it is not drawn as a blank one. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['Visible', '30'], + ['Visible 2', '35'] + ]); done(); }); - exporter.exportData(dataWithHidden, options); + exportRecords(dataWithHidden); }); it('should handle pagination when data exceeds page height', (done) => { @@ -2083,12 +2966,24 @@ describe('PDF Exporter', () => { }); } - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBe(3); + // The header row is repeated at the top of every page, and the rows carry on from + // page to page in order, with none dropped at a break and none drawn twice. + expect(pages.map(page => page[0])).toEqual(pages.map(() => ['Name', 'Age', 'City'])); + expect(pages.flatMap(page => page.slice(1))).toEqual( + largeData.map(record => [record.data.Name, String(record.data.Age), record.data.City])); + // A page break only happens once a page is full, so every page but the last one + // holds the same number of rows. + expect(new Set(pages.slice(0, -1).map(page => page.length)).size).toBe(1); + expect(pages[pages.length - 1].length).toBeLessThanOrEqual(pages[0].length); done(); }); - exporter.exportData(largeData, options); + exportRecords(largeData); }); it('should handle pivot grid with empty row dimension fields', (done) => { @@ -2123,12 +3018,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid when no columns are defined', (done) => { @@ -2149,12 +3048,18 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The owner has no columns to lay the table out with, and the record has nothing + // left in it to derive them from either, so a single blank page comes out - with + // the header background as the only thing drawn on it. + expect(getPageCount(args.pdf)).toBe(1); + expect(getRenderedRows(args.pdf)).toEqual([]); + expect(getDrawnRectangles(args.pdf).map(rectangle => rectangle.filled)).toEqual([true]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with row dimension headers longer than fields', (done) => { @@ -2205,12 +3110,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with date values in row dimensions', (done) => { @@ -2232,12 +3141,24 @@ describe('PDF Exporter', () => { startIndex: 0, level: 0 }, + // The dimension needs a column of its own for its value to survive the record + // rebuild the base exporter does; being an exact match of a dimension key keeps + // it out of the data columns all the same. + { + header: 'Date', + field: 'Date', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, { header: 'Sum', field: 'City-London-Sum', skip: false, headerType: ExportHeaderType.ColumnHeader, - startIndex: 0, + startIndex: 1, level: 0, columnSpan: 1 } @@ -2253,12 +3174,20 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // A date in a dimension cell is written out in the locale's short date format + // rather than as the raw value. `Date` appears twice in the header row because + // the header is drawn from every column, including the one held back from the + // data columns for being a dimension. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Date', 'Date', 'Sum'], + [new Date('2023-01-01').toLocaleDateString(), '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle hierarchical grid with HeaderRecord type', (done) => { @@ -2313,10 +3242,14 @@ describe('PDF Exporter', () => { owner: DEFAULT_OWNER }, { - data: {}, + // A header record carries the header texts as an array, with `references` + // pointing at the columns they came from - that is how the base exporter + // builds the header row that introduces a child island. + data: childColumns.map(col => col.header), level: 1, type: ExportRecordType.HeaderRecord, - owner: childOwner + owner: childOwner, + references: childColumns }, { data: { name: 'Child 1' }, @@ -2326,12 +3259,20 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The header record introduces the child island with its own header row, which + // here happens to carry the same caption as the parent's. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent 1'], + ['Name'], + ['Child 1'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); it('should handle hierarchical grid with empty child columns', (done) => { @@ -2381,12 +3322,16 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent 1'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); it('should handle pagination with hierarchical grid', (done) => { @@ -2450,15 +3395,47 @@ describe('PDF Exporter', () => { }); } - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBe(5); + + // Every parent is followed by the header row of its island and then its child, in + // order and without repetition, however the page breaks happen to fall. + const expected: string[][] = []; + for (let i = 0; i < 30; i++) { + expected.push([`Parent ${i}`], ['Child Name'], [`Child ${i}`]); + } + expect(pages.flat().filter(row => row[0] !== 'Name')).toEqual(expected); + + // The parent header row is only redrawn on a page that opens on a parent record, + // so it does not appear on all five pages. + const pagesOpeningWithHeader = pages.filter(page => page[0][0] === 'Name').length; + expect(pagesOpeningWithHeader).toBeGreaterThan(0); + expect(pagesOpeningWithHeader).toBeLessThan(pages.length); + + // Child rows are indented one step further than the parents they belong to. + const cells = getRenderedCells(args.pdf); + const xOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(xOf('Parent 0')).toBeLessThan(xOf('Child 0')); + expect(xOf('Parent 29')).toBe(xOf('Parent 0')); done(); }); - exporter.exportData(hierarchicalData, options); + exportRecords(hierarchicalData); }); }); + /** + * These probe the defensive paths of the exporter with column shapes a grid would not normally + * produce, so several of them end up with an empty row dimension cell. That is expected rather + * than a quirk of the assertion: the base exporter rebuilds each record out of the owner's + * `ColumnHeader` columns on the way in, so a dimension key that has no column of its own is + * already gone by the time the PDF exporter looks for its value, and the row header columns + * these fixtures declare do not match the record either. A blank cell is still drawn - it just + * leaves no text in the document, and so no entry in the row. + */ describe('Additional Edge Cases and Error Paths', () => { it('should handle pivot grid with no defaultOwner', (done) => { const pivotData: IExportRecord[] = [ @@ -2473,12 +3450,19 @@ describe('PDF Exporter', () => { // Don't set DEFAULT_OWNER in the map (exporter as any)._ownersMap.clear(); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // With no owner registered there are no columns to lay the table out with, so + // the exporter falls back to the keys of the first record - including the + // dimension key, which therefore keeps its value. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'City-London-Sum'], + ['Product A', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid dimension inference from columnGroup', (done) => { @@ -2493,29 +3477,31 @@ describe('PDF Exporter', () => { const columns: IColumnInfo[] = [ { - header: 'Product', - field: 'Product', + // The field names nothing in the record, so the dimension has to be inferred + // from the column group instead. + header: 'All Categories', + field: 'NotInTheRecord', skip: false, headerType: ExportHeaderType.RowHeader, startIndex: 0, level: 0, - columnGroup: 'Product' + columnGroup: 'Category' }, { header: 'Category', field: 'Category', skip: false, - headerType: ExportHeaderType.RowHeader, - startIndex: 1, - level: 1, - columnGroupParent: 'Product' + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 }, { header: 'Sum', field: 'City-London-Sum', skip: false, headerType: ExportHeaderType.ColumnHeader, - startIndex: 0, + startIndex: 1, level: 0, columnSpan: 1 } @@ -2531,12 +3517,19 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // `Category` is taken as the dimension, which keeps it out of the data columns, + // and the cell is filled from the record's own value for it rather than from the + // caption of the row header that identified it. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Category', 'Sum'], + ['Category 1', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with simple keys inference', (done) => { @@ -2571,12 +3564,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with row dimension headers longer than fields and trim them', (done) => { @@ -2635,12 +3632,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum', 'SubCategory'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle multi-level headers with empty headersForLevel', (done) => { @@ -2693,12 +3694,20 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The group that has no children of its own is still drawn, on the level it + // declares, and the leaf column keeps its place underneath its own parent. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Parent'], + ['Name'], + ['Child'], + ['Test', 'Child'] + ]); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle columns with skip: true', (done) => { @@ -2740,12 +3749,18 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The skipped column is left out of both the header row and the data row, and + // the remaining column takes the whole table. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Age'], + ['30'] + ]); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle GRID_LEVEL_COL column', (done) => { @@ -2787,15 +3802,59 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The internal grid level column is never drawn, so only the real column is. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['John'] + ]); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); - it('should handle records with missing data property', (done) => { + it('should keep the grid level column out of the columns derived from the data', (done) => { + // When summaries are exported the base exporter adds GRID_LEVEL_COL to the owner and + // to every record, so a grid whose real columns have all been turned away is left + // with nothing but the internal one. Deriving the columns from the record data then + // has to leave it out, or the export puts the level field in the table. + const records: IExportRecord[] = [ + { + data: { [GRID_LEVEL_COL]: 0 }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: GRID_LEVEL_COL, field: GRID_LEVEL_COL, skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [20], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Nothing to show, so nothing is drawn - rather than a column headed + // `GRID_LEVEL_COL` with the record's nesting level under it. + expect(getRenderedRows(args.pdf)).toEqual([]); + expect(getRenderedText(args.pdf)).not.toContain(GRID_LEVEL_COL); + done(); + }); + + // The records go in directly: the owner names no column the base exporter would + // rebuild the record around, so it would strip the data before the exporter sees it. + drawRecords(records); + }); + + it('should handle records with missing data property', (done) => { const data: IExportRecord[] = [ { data: { Name: 'John', Age: 30 }, @@ -2809,12 +3868,34 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + // The columns have to be declared up front - deriving them from the records would + // trip over the record that has no data before the exporter is ever reached. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'Name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Age', field: 'Age', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Age'], + ['John', '30'] + ]); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle pivot grid with fuzzy key matching', (done) => { @@ -2836,12 +3917,24 @@ describe('PDF Exporter', () => { startIndex: 0, level: 0 }, + // The key the record actually holds needs a column of its own to survive the + // record rebuild; it is not an exact match of the dimension key, so it also stays + // among the data columns. + { + header: 'Product Name', + field: 'ProductName', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, { header: 'Sum', field: 'City-London-Sum', skip: false, headerType: ExportHeaderType.ColumnHeader, - startIndex: 0, + startIndex: 1, level: 0, columnSpan: 1 } @@ -2857,12 +3950,18 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // `Product` does not appear in the record, but `ProductName` contains it, and the + // fuzzy match is enough to fill the dimension cell. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Product Name', 'Sum'], + ['Product A', 'Product A', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with possible dimension keys by index fallback', (done) => { @@ -2884,12 +3983,31 @@ describe('PDF Exporter', () => { startIndex: 0, level: 0 }, + // The two simple keys need columns of their own to reach the exporter at all. + { + header: 'Simple Key 1', + field: 'SimpleKey1', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1 + }, + { + header: 'Simple Key 2', + field: 'SimpleKey2', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1 + }, { header: 'Sum', field: 'Complex-Key', skip: false, headerType: ExportHeaderType.ColumnHeader, - startIndex: 0, + startIndex: 2, level: 0, columnSpan: 1 } @@ -2905,12 +4023,19 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // `UnknownKey` matches nothing in the record by name, so the single dimension cell + // falls back to the first of the record's simple keys - a key with a separator in + // it is not considered a dimension at all. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Unknown', 'Simple Key 1', 'Simple Key 2', 'Sum'], + ['Value1', 'Value1', 'Value2', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle summary records with only label', (done) => { @@ -2952,12 +4077,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Total', 'Sum'] + ]); done(); }); - exporter.exportData(summaryData, options); + exportRecords(summaryData); }); it('should handle summary records with only value', (done) => { @@ -2999,12 +4128,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Total', '500'] + ]); done(); }); - exporter.exportData(summaryData, options); + exportRecords(summaryData); }); it('should handle pivot grid with empty PivotRowHeader columns', (done) => { @@ -3047,12 +4180,18 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // A row header with no caption of its own is given a generated one on the way + // in, and that is what ends up above the dimension column. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Column1', 'Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle hierarchical grid with owner not in map', (done) => { @@ -3084,22 +4223,24 @@ describe('PDF Exporter', () => { data: { name: 'Parent 1' }, level: 0, type: ExportRecordType.HierarchicalGridRecord, - owner: DEFAULT_OWNER - }, - { - data: { name: 'Child 1' }, - level: 1, - type: ExportRecordType.HierarchicalGridRecord, owner: childOwner } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The missing owner leaves the exporter without columns, so it derives them + // from the record data and the export still produces a readable table. + expect(getRenderedRows(args.pdf)).toEqual([ + ['name'], + ['Parent 1'] + ]); done(); }); - exporter.exportData(hierarchicalData, options); + // The base exporter would throw on the missing owner long before the PDF exporter is + // reached, so the records go in directly here. + drawRecords(hierarchicalData); }); it('should handle tree grid with undefined level', (done) => { @@ -3141,12 +4282,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Root 1', '100'] + ]); done(); }); - exporter.exportData(treeData, options); + exportRecords(treeData); }); it('should handle pivot grid with columnGroupParent as non-string', (done) => { @@ -3198,12 +4343,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with column header matching record values', (done) => { @@ -3246,12 +4395,18 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The row header carries no field the record has, but its caption matches one + // of the record's values, which is enough for it to be picked as the dimension. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['Product A', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with record index-based column selection', (done) => { @@ -3316,12 +4471,17 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Product A', '100'], + ['Product B', '200'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle pivot grid with empty allColumns in drawDataRow', (done) => { @@ -3356,31 +4516,36 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle very long header text truncation', (done) => { const longHeaderText = 'This is a very long header text that should be truncated because it exceeds the maximum width of the column header cell in the PDF export'; - const columns: IColumnInfo[] = [ - { - header: longHeaderText, - field: 'name', - skip: false, - headerType: ExportHeaderType.ColumnHeader, - startIndex: 0, - level: 0, - columnSpan: 1 - } - ]; + // Four columns share the page, so none of them is anywhere near wide enough for the + // header text - a single column would simply be given the whole page and fit it. + const fields = ['name', 'second', 'third', 'fourth']; + const columns: IColumnInfo[] = fields.map((field, startIndex) => ({ + header: longHeaderText, + field, + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex, + level: 0, + columnSpan: 1 + })); const owner: IColumnList = { columns: columns, - columnWidths: [200], + columnWidths: fields.map(() => 200), indexOfLastPinnedColumn: -1, maxLevel: 0 }; @@ -3389,18 +4554,30 @@ describe('PDF Exporter', () => { const data: IExportRecord[] = [ { - data: { name: 'Test' }, + data: { name: 'Test', second: 'Test', third: 'Test', fourth: 'Test' }, level: 0, type: ExportRecordType.DataRecord } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const [headerRow, dataRow] = getRenderedRows(args.pdf); + const columnWidth = getDrawnRectangles(args.pdf)[0].width; + + expect(headerRow.length).toBe(fields.length); + for (const header of headerRow) { + expect(header.endsWith('...')).toBeTrue(); + expect(longHeaderText.startsWith(header.slice(0, -3))).toBeTrue(); + expect(args.pdf!.getTextWidth(header)).toBeLessThanOrEqual(columnWidth - 10); + } + // The values are short enough to be left exactly as they are. + expect(dataRow).toEqual(['Test', 'Test', 'Test', 'Test']); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle pivot grid with row dimension columns but no matching data', (done) => { @@ -3443,12 +4620,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle column field as non-string gracefully', (done) => { @@ -3492,12 +4673,16 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name', 'Value'], + ['Test', '123'] + ]); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle empty rowDimensionHeaders fallback path', (done) => { @@ -3532,12 +4717,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle PivotMergedHeader with empty header text', (done) => { @@ -3580,12 +4769,16 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['Column1', '100'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); it('should handle resolveLayoutStartIndex with no child columns', (done) => { @@ -3620,12 +4813,157 @@ describe('PDF Exporter', () => { } ]; - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Parent'] + ]); + done(); + }); + + exportRecords(data); + }); + + it('should export a column group that has no group key of its own', (done) => { + // Without a `columnGroup` the group has nothing to match its children on. Pairing it + // with every column that has no parent used to make it its own child, and resolving + // its layout position then recursed until the stack gave out - taking the export down + // with it, silently, because the failure happened inside the drawing promise. + const columns: IColumnInfo[] = [ + { + header: 'Location', + field: 'location', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 2 + }, + { + header: 'City', + field: 'city', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1 + }, + { + header: 'Country', + field: 'country', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 1, + level: 1, + columnSpan: 1 + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns, + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + } as IColumnList); + + const data: IExportRecord[] = [ + { + data: { city: 'London', country: 'UK' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The group is treated as having no children and falls back to the first column, + // so the two levels of headers and the row are all still drawn. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Location'], + ['City', 'Country'], + ['London', 'UK'] + ]); + done(); + }); + + exportRecords(data); + }); + + it('should export two column groups that name each other as their parent', (done) => { + // A cycle that spans two groups rather than one, which the group key check alone + // would not catch. + const columns: IColumnInfo[] = [ + { + header: 'First', + field: 'first', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 0, + level: 0, + columnSpan: 1, + columnGroup: 'First', + columnGroupParent: 'Second' + }, + { + header: 'Second', + field: 'second', + skip: false, + headerType: ExportHeaderType.MultiColumnHeader, + startIndex: 1, + level: 0, + columnSpan: 1, + columnGroup: 'Second', + columnGroupParent: 'First' + }, + { + header: 'Value', + field: 'value', + skip: false, + headerType: ExportHeaderType.ColumnHeader, + startIndex: 0, + level: 1, + columnSpan: 1, + columnGroupParent: 'First' + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns, + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + } as IColumnList); + + const data: IExportRecord[] = [ + { + data: { value: 'Test' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The export completes and every header still reaches the document. + expect(new Set(getRenderedText(args.pdf))) + .toEqual(new Set(['First', 'Second', 'Value', 'Test'])); + + // Neither group can be placed relative to the other, so both fall back to the + // first column and are drawn over one another - a misplaced header rather than a + // lost export. + const groups = getRenderedCells(args.pdf) + .filter(cell => cell.text === 'First' || cell.text === 'Second'); + expect(groups.length).toBe(2); + expect(new Set(groups.map(cell => cell.y)).size).toBe(1); + + const cells = getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled); + const topRow = cells.filter(rectangle => rectangle.y === Math.min(...cells.map(c => c.y))); + expect(topRow.length).toBe(2); + expect(topRow[0].x).toBe(topRow[1].x); done(); }); - exporter.exportData(data, options); + exportRecords(data); }); it('should handle data with zero total columns', (done) => { @@ -3646,12 +4984,1255 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, owner); - exporter.exportEnded.pipe(first()).subscribe(() => { + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Nothing to draw, but still a valid one page document rather than a failure. + expect(getPageCount(args.pdf)).toBe(1); + expect(getRenderedRows(args.pdf)).toEqual([]); + expect(getDrawnRectangles(args.pdf).map(rectangle => rectangle.filled)).toEqual([true]); + done(); + }); + + exportRecords(data); + }); + }); + + describe('Row dimension value resolution', () => { + const pivotOwnerFor = (columns: IColumnInfo[], maxRowLevel = 1): IColumnList => ({ + columns, + columnWidths: columns.map(() => 200), + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel + }); + + it('should take a row dimension from a row header whose field the record carries', (done) => { + // No dimension keys, so the dimension has to be inferred: the row header declares a + // field the record data has, which is what makes it a dimension. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The row header's own caption fills the dimension cell, and `Product` is held + // back from the data columns for being the dimension. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Product A', '100'] + ]); + done(); + }); + + // Straight to the PDF exporter, so that the record the base exporter would have + // preserved is not there and the value has to be worked out from the columns. + drawRecords(records); + }); + + it('should label each record with its own dimension value when they outnumber the row headers', (done) => { + // Three records over two row headers, all naming the same field. Reading the value + // off the record is the only thing that tells them apart: a column matched by the + // field it names would be the first one every time, labelling all three 'Product A'. + const records: IExportRecord[] = ['Product A', 'Product B', 'Product C'].map(value => ({ + data: { Product: value, 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + })); + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + ...['Product A', 'Product B'].map((header, startIndex) => ({ + header, field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex, columnSpan: 1 + })), + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Product A', '100'], + ['Product B', '100'], + ['Product C', '100'] + ]); + done(); + }); + + // Straight to the PDF exporter, so that the record the base exporter would have + // preserved is not there and the value has to be worked out from the columns. + drawRecords(records); + }); + + it('should match a row header to a record by its caption when its field does not fit', (done) => { + // The row header names a field the record does not have, so the match has to come + // from its caption turning up among the record's own values. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + { + header: 'Product A', field: 'NotInTheRecord', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Product B', field: 'AlsoNotInTheRecord', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 1, columnSpan: 1 + }, + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The two row headers are put in level and start index order first, so the match + // lands on the one the record actually names. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Product A', '100'] + ]); + done(); + }); + + // Straight to the PDF exporter, so that the record the base exporter would have + // preserved is not there and the value has to be worked out from the columns. + drawRecords(records); + }); + + it('should fall back to a row header field when the header has no caption', (done) => { + // The records go in directly: on the way through the base exporter a column without a + // caption is given a generated one, which would hide the fallback being tested here. + const records: IExportRecord[] = [ + { + data: { 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + { + header: '', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // With no caption to show, the dimension cell falls back to the field name. The + // column above it stays blank, because the caption is what would have headed it. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Sum'], + ['Product', '100'] + ]); + done(); + }); + + drawRecords(records); + }); + + it('should reuse the first simple key when a record has fewer of them than dimensions', (done) => { + // Two dimensions, neither of which the record carries, and only one simple key to + // place them from - the second dimension has no key of its own to fall back to. + const records: IExportRecord[] = [ + { + data: { Category: 'Tools', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['MissingA', 'MissingB'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], 2)); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Both dimension cells end up showing that one value. Their columns are headed by + // the dimension keys, which are only drawn for a pivot row header column, and + // there is none here - so the header row holds just the two data columns. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Category', 'Sum'], + ['Tools', 'Tools', 'Tools', '100'] + ]); + done(); + }); + + exportRecords(records); + }); + + it('should truncate a row dimension value that does not fit its column', (done) => { + const longValue = 'A dimension value far too long to fit in the column it is drawn in'.repeat(3); + const records: IExportRecord[] = [ + { + data: { Category: longValue, 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Category'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwnerFor([ + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const dimensionCell = getRenderedRows(args.pdf)[1][0]; + const columnWidth = getDrawnRectangles(args.pdf).find(rectangle => !rectangle.filled)!.width; + + // Cut short exactly like a data cell is, and still a prefix of the real value. + expect(dimensionCell.endsWith('...')).toBeTrue(); + expect(longValue.startsWith(dimensionCell.slice(0, -3))).toBeTrue(); + expect(args.pdf!.getTextWidth(dimensionCell)).toBeLessThanOrEqual(columnWidth - 10); + done(); + }); + + exportRecords(records); + }); + }); + + describe('Header redrawing across pages', () => { + /** Enough records to spill onto a second page at the default page size. */ + const manyRecords = (count: number, fields: Record any>): IExportRecord[] => + Array.from({ length: count }, (_, index) => ({ + data: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, value(index)])), + level: 0, + type: ExportRecordType.DataRecord + })); + + it('should redraw multi-column headers at the top of every page', (done) => { + const records = manyRecords(50, { + city: (index) => `City ${index}`, + country: (index) => `Country ${index}` + }); + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Location', field: 'location', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnSpan: 2, columnGroup: 'Location' + }, + { + header: 'City', field: 'city', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Location' + }, + { + header: 'Country', field: 'country', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'Location' + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBeGreaterThan(1); + // Both header levels are repeated on every page, not just the first. + for (const page of pages) { + expect(page[0]).toEqual(['Location']); + expect(page[1]).toEqual(['City', 'Country']); + } + expect(pages.flatMap(page => page.slice(2))).toEqual( + records.map(record => [record.data.city, record.data.country])); + done(); + }); + + exportRecords(records); + }); + + it('should redraw a pivot row dimension header on a later page', (done) => { + // With no header levels below it the second page falls to the plain header drawing, + // which lays the dimension column out on its own. + const longDimensionName = 'A row dimension name far too long to fit the column it heads'.repeat(2); + const records: IExportRecord[] = Array.from({ length: 50 }, (_, index) => ({ + data: { Product: `Product ${index}`, 'City-London-Sum': index }, + level: 0, + type: ExportRecordType.PivotGridRecord, + ...(index === 0 ? { dimensionKeys: ['Product'] } : {}) + })); + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: longDimensionName, field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBeGreaterThan(1); + + const columnWidth = getDrawnRectangles(args.pdf).find(rectangle => !rectangle.filled)!.width; + for (const page of pages) { + const dimensionHeader = page[0][0]; + // The dimension name is cut to fit on every page it is redrawn on. + expect(dimensionHeader.endsWith('...')).toBeTrue(); + expect(args.pdf!.getTextWidth(dimensionHeader)).toBeLessThanOrEqual(columnWidth - 10); + } + done(); + }); + + exportRecords(records); + }); + + it('should redraw the headers of a child island that outgrows a page', (done) => { + const childOwner = 'child1'; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(childOwner, { + columns: [ + { + header: 'Child Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + // One parent with more children than a single page can hold. + const records: IExportRecord[] = [ + { + data: { name: 'Parent' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + ...Array.from({ length: 40 }, (_, index) => ({ + data: { name: `Child ${index}` }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + })) + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBeGreaterThan(1); + // The island reintroduces itself with its own header row after the break, and + // every child is drawn exactly once. + expect(pages[1][0]).toEqual(['Child Name']); + expect(pages.flat().filter(row => /^Child \d+$/.test(row[0]))) + .toEqual(Array.from({ length: 40 }, (_, index) => [`Child ${index}`])); + done(); + }); + + exportRecords(records); + }); + + it('should skip a child island that only contributes a header record', (done) => { + const childOwner = 'child1'; + const childColumns: IColumnInfo[] = [ + { + header: 'Child Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(childOwner, { + columns: childColumns, + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + const records: IExportRecord[] = [ + { + data: { name: 'Parent' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + // An island that announces itself but has no rows to show. + data: childColumns.map(col => col.header), + level: 1, + type: ExportRecordType.HeaderRecord, + owner: childOwner, + references: childColumns + } + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Not even the island's header row is drawn - there is nothing under it. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent'] + ]); + done(); + }); + + exportRecords(records); + }); + }); + + describe('Pivot and multi level header drawing', () => { + it('should skip a pivot row header that has nothing to put in it', (done) => { + // The records go in directly, because on the way through the base exporter a column + // without a caption is given a generated one - which is exactly what this checks the + // absence of. + const records: IExportRecord[] = [ + { + data: { 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + // Neither a caption, a field, nor a dimension name to borrow. + header: '', field: '', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 1 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['100'] + ]); + // The empty one is passed over before anything is drawn for it, so the header row + // holds one shaded cell for the dimension and one for the data column - and not a + // third, blank one between them. + const shaded = getDrawnRectangles(args.pdf).filter(rectangle => rectangle.filled); + const headerTop = Math.min(...shaded.map(rectangle => rectangle.y)); + const headerBackgrounds = shaded.filter(rectangle => rectangle.y === headerTop); + expect(headerBackgrounds.length).toBe(2); + expect(new Set(headerBackgrounds.map(rectangle => rectangle.x)).size).toBe(2); + done(); + }); + + drawRecords(records); + }); + + it('should still shade a pivot row dimension header when borders are turned off', (done) => { + options.showTableBorders = false; + + const records: IExportRecord[] = [ + { + data: { 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 1 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['100'] + ]); + // The dimension header keeps its shaded background even with the borders off - + // it is the one rectangle in the document, and it is filled rather than stroked. + const rectangles = getDrawnRectangles(args.pdf); + expect(rectangles.length).toBe(1); + expect(rectangles[0].filled).toBeTrue(); + done(); + }); + + exportRecords(records); + }); + + it('should shade the row dimension cell of a pivot record', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.PivotRowHeader, level: 0, startIndex: 0 + }, + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: 0, + maxLevel: 0, + maxRowLevel: 1 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Sum'], + ['Product A', '100'] + ]); + + const cells = getRenderedCells(args.pdf); + const rectangles = getDrawnRectangles(args.pdf); + const drawnUnder = (text: string) => { + const label = cells.find(cell => cell.text === text)!; + + return rectangles.filter(rectangle => + label.x >= rectangle.x && label.x <= rectangle.x + rectangle.width && + label.y >= rectangle.y && label.y <= rectangle.y + rectangle.height); + }; + + // A dimension value heads the record it sits on, so it is shaded like the + // dimension caption above it - and the aggregate beside it is not. + expect(drawnUnder('Product A').filter(rectangle => rectangle.filled).length).toBe(1); + expect(drawnUnder('100').filter(rectangle => rectangle.filled).length).toBe(0); + done(); + }); + + exportRecords(records); + }); + + it('should draw a summary row under the columns of a multi level header', (done) => { + const records: IExportRecord[] = [ + { + data: { a: 'One', b: 'Two' }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + data: { a: { label: 'Count', value: 1 }, b: { label: 'Sum', value: 3 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Group', field: 'group', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnSpan: 2, columnGroup: 'Group' + }, + { + header: 'A', field: 'a', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Group' + }, + { + header: 'B', field: 'b', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'Group' + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['Group'], + ['A', 'B'], + ['One', 'Two'], + ['Count: 1', 'Sum: 3'] + ]); + + // A summary row is laid out on the same columns as the records above it, under + // the leaf headers rather than under the group that spans them. + const cells = getRenderedCells(args.pdf); + const xOf = (text: string) => cells.find(cell => cell.text === text)!.x; + expect(xOf('Count: 1')).toBeCloseTo(xOf('One'), 6); + expect(xOf('Sum: 3')).toBeCloseTo(xOf('Two'), 6); + done(); + }); + + exportRecords(records); + }); + + it('should truncate a multi column header that does not fit its group', (done) => { + const longHeader = 'A column group caption far too long for the columns beneath it'.repeat(4); + const records: IExportRecord[] = [ + { + data: { a: 'One', b: 'Two' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: longHeader, field: 'group', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnSpan: 2, columnGroup: 'Group' + }, + { + header: 'A', field: 'a', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Group' + }, + { + header: 'B', field: 'b', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'Group' + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const [groupRow, leafRow, dataRow] = getRenderedRows(args.pdf); + const groupWidth = getDrawnRectangles(args.pdf) + .find(rectangle => !rectangle.filled)!.width; + + // The group caption is cut to the width of the two columns it spans; the columns + // themselves are short enough to be left alone. + expect(groupRow[0].endsWith('...')).toBeTrue(); + expect(longHeader.startsWith(groupRow[0].slice(0, -3))).toBeTrue(); + expect(args.pdf!.getTextWidth(groupRow[0])).toBeLessThanOrEqual(groupWidth - 10); + expect(leafRow).toEqual(['A', 'B']); + expect(dataRow).toEqual(['One', 'Two']); + done(); + }); + + exportRecords(records); + }); + + it('should redraw the multi column headers of a child island that outgrows a page', (done) => { + const childOwner = 'child1'; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(childOwner, { + columns: [ + { + header: 'Location', field: 'location', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnSpan: 2, columnGroup: 'Location' + }, + { + header: 'City', field: 'city', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Location' + }, + { + header: 'Country', field: 'country', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'Location' + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + } as IColumnList); + + const records: IExportRecord[] = [ + { + data: { name: 'Parent' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + ...Array.from({ length: 40 }, (_, index) => ({ + data: { city: `City ${index}`, country: `Country ${index}` }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + })) + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + + const pages = getRenderedRowsByPage(args.pdf); + expect(pages.length).toBeGreaterThan(1); + // Both of the island's header levels come back at the top of the next page. + expect(pages[1][0]).toEqual(['Location']); + expect(pages[1][1]).toEqual(['City', 'Country']); + expect(pages.flat().filter(row => /^City \d+$/.test(row[0]))) + .toEqual(Array.from({ length: 40 }, (_, index) => [`City ${index}`, `Country ${index}`])); + done(); + }); + + exportRecords(records); + }); + }); + + describe('Columns that leave their optional properties out', () => { + it('should ignore a row dimension column whose field and group are not strings', (done) => { + // A pivot grid stores column references rather than names in `columnGroup` and + // `columnGroupParent`, so the exporter has to cope with values it cannot read as a + // key. Here nothing about the row header is usable, so the dimension has to be + // guessed from the record's own simple keys instead. The records go in directly + // because the base exporter trims every caption, which a non-string one does not + // survive. + const columnReference = { field: 'Product' } as any; + const records: IExportRecord[] = [ + { + data: { Category: 'Tools', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 42 as any, field: 7 as any, skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 0, columnSpan: 1, + // The group is a reference, so the exporter falls through to the parent, + // which names a group the record knows nothing about. + columnGroup: columnReference, columnGroupParent: 'NotInTheRecord' + }, + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 1 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // `Category` is the only simple key left in the record, so it becomes the + // dimension - which also keeps it out of the data columns. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Category', 'Sum'], + ['Tools', '100'] + ]); + done(); + }); + + drawRecords(records); + }); + + it('should order row dimension columns that declare no level or start index', (done) => { + // `level` and `startIndex` are optional, and the exporter sorts the row dimension + // columns by both - with neither given every column has to land on the same level and + // keep the order it was declared in. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Tools', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { header: 'Product', field: 'Product', skip: false, headerType: ExportHeaderType.PivotRowHeader }, + { header: 'Category', field: 'Category', skip: false, headerType: ExportHeaderType.PivotRowHeader }, + { header: 'Product A', field: 'Product', skip: false, headerType: ExportHeaderType.RowHeader, columnSpan: 1 }, + { header: 'Tools', field: 'Category', skip: false, headerType: ExportHeaderType.RowHeader, columnSpan: 1 }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 2 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Both dimensions are headed, in the order they were declared, and both cells are + // filled from the record itself - where a row header sits only decides the order + // of the headings, not which value lands in which cell. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['Product A', 'Tools', '100'] + ]); + done(); + }); + + exportRecords(records); + }); + + it('should leave out a column group that declares no span', (done) => { + const records: IExportRecord[] = [ + { + data: { a: 'One', b: 'Two' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + // No caption and no span - the field stands in for the caption and the + // group is laid out one column wide. + header: '', field: 'groupA', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnGroup: 'A' + }, + { + header: 'A', field: 'a', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'A' + }, + { + header: 'Group B', field: 'groupB', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 1, + columnGroup: 'B' + }, + { + header: 'B', field: 'b', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'B' + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // A header is only drawn for a column that spans at least one column, and a group + // that leaves `columnSpan` out spans none - so neither group reaches the + // document and the leaf columns are left to head the table on their own. + expect(getRenderedRows(args.pdf)).toEqual([ + ['A', 'B'], + ['One', 'Two'] + ]); + const cells = getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled); + expect(new Set(cells.map(rectangle => rectangle.width)).size).toBe(1); + done(); + }); + + exportRecords(records); + }); + + it('should lay out a child island whose columns declare no span', (done) => { + const childOwner = 'child1'; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(childOwner, { + columns: [ + { + header: 'Location', field: 'location', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnGroup: 'Location' + }, + { + header: 'City', field: 'city', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Location' + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 1 + } as IColumnList); + + const records: IExportRecord[] = [ + { + data: { name: 'Parent' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { city: 'London' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: childOwner + } + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The island's column still gets the whole of its table width, counting the + // span-less group as one column, but the group itself is not drawn. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent'], + ['City'], + ['London'] + ]); + done(); + }); + + exportRecords(records); + }); + + it('should head a column with its field when it has no caption', (done) => { + // Straight to the PDF exporter: on the way through the base exporter a column without + // a caption is given a generated one, which is what this checks the absence of. + const records: IExportRecord[] = [ + { + data: { name: 'John' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: '', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(getRenderedRows(args.pdf)).toEqual([ + ['name'], + ['John'] + ]); + done(); + }); + + drawRecords(records); + }); + + it('should skip a child island whose owner is missing from the owners map', (done) => { + // The base exporter would throw on the unknown owner long before the PDF exporter is + // reached, so the records go in directly here. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Name', field: 'name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ], + columnWidths: [200], + indexOfLastPinnedColumn: -1, + maxLevel: 0 + } as IColumnList); + + const records: IExportRecord[] = [ + { + data: { name: 'Parent' }, + level: 0, + type: ExportRecordType.HierarchicalGridRecord, + owner: DEFAULT_OWNER + }, + { + data: { name: 'Child' }, + level: 1, + type: ExportRecordType.HierarchicalGridRecord, + owner: 'neverRegistered' + } + ]; + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // With no columns to draw it with, the island is left out and the parent table + // is finished off as if it had no children. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Name'], + ['Parent'] + ]); + done(); + }); + + drawRecords(records); + }); + + it('should order row dimension headers by level and then by start index', (done) => { + // No pivot row header to take the dimension captions from, so the exporter has to + // build them out of the row dimension columns - which it first sorts by level and, + // within a level, by start index. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Tools', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + // Declared out of order, and two of them share a level. + { + header: 'Tools', field: 'Category', skip: false, + headerType: ExportHeaderType.RowHeader, level: 1, startIndex: 0, columnSpan: 1 + }, + { + header: 'Product B', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, level: 0, startIndex: 1, columnSpan: 1 + }, + { + // Neither a level nor a start index, so it falls to the front of level 0. + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.RowHeader, columnSpan: 1 + }, + { + header: 'Product', field: 'Product', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Category', field: 'Category', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + }, + { + header: 'Sum', field: 'City-London-Sum', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 2, columnSpan: 1 + } + ], + columnWidths: [200, 200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 2 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // Each dimension is filled from the row header at its own level: the first from + // level 0, whose lowest start index wins, and the second from level 1. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Product', 'Category', 'Sum'], + ['Product A', 'Tools', '100'] + ]); + done(); + }); + + exportRecords(records); + }); + + it('should head a multi level column with its field when it has no caption', (done) => { + // Straight to the PDF exporter again, so that the caption stays empty. + const records: IExportRecord[] = [ + { + data: { a: 'One', b: 'Two' }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: 'Group', field: 'group', skip: false, + headerType: ExportHeaderType.MultiColumnHeader, level: 0, startIndex: 0, + columnSpan: 2, columnGroup: 'Group' + }, + { + header: '', field: 'a', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 0, + columnSpan: 1, columnGroupParent: 'Group' + }, + { + header: 'B', field: 'b', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 1, + columnSpan: 1, columnGroupParent: 'Group' + }, + { + // Nothing to head it with at all. + header: '', field: '', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 1, startIndex: 2, + columnSpan: 1 + } + ], + columnWidths: [200, 200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 1, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + // The column with a field is headed by it; the one with neither is drawn as a + // cell but has no text to put in it, and neither has the record. + expect(getRenderedRows(args.pdf)).toEqual([ + ['Group'], + ['a', 'B'], + ['One', 'Two'] + ]); + // The group, the three columns under it and the three cells of the row. + expect(getDrawnRectangles(args.pdf).filter(rectangle => !rectangle.filled).length) + .toBe(1 + 3 + 3); done(); }); - exporter.exportData(data, options); + drawRecords(records); }); }); }); diff --git a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts index e5908fd6c13..c5c1dce20b4 100644 --- a/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.ts @@ -9,6 +9,19 @@ export interface IPdfExportEndedEventArgs extends IBaseEventArgs { pdf?: jsPDF; } +interface IPdfFontNames { + normal: string; + bold: string; +} + +/** One row dimension cell of a pivot record, as `drawDataRow` is handed it. */ +interface IRowDimensionCell { + /** The dimension value to draw. */ + text: string; + /** The records the cell covers, or zero when a cell above this record covers it. */ + rowSpan: number; +} + /** * **Ignite UI for Angular PDF Exporter Service** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/exporter_pdf.html) @@ -47,9 +60,6 @@ export class IgxPdfExporterService extends IgxBaseExporter { */ public override exportEnded = new EventEmitter(); - private _currentFontName = 'helvetica'; - private _currentBoldFontName = 'helvetica'; - protected exportDataImplementation(data: IExportRecord[], options: IgxPdfExporterOptions, done: () => void): void { const firstDataElement = data[0]; const isHierarchicalGrid = firstDataElement?.type === ExportRecordType.HierarchicalGridRecord; @@ -202,8 +212,10 @@ export class IgxPdfExporterService extends IgxBaseExporter { const hasMultiRowHeaders = maxRowLevel > 0 && rowDimensionFields.length > 0; if (leafColumns.length === 0 && data.length > 0 && firstDataElement) { - // If no columns are defined, use the keys from the first data record - const keys = Object.keys(firstDataElement.data); + // If no columns are defined, use the keys from the first data record. GRID_LEVEL_COL + // is added to both the owner and the record data when summaries are exported, so it + // can be among those keys - and it is an internal field, not one to put in the table. + const keys = Object.keys(firstDataElement.data).filter(key => key !== GRID_LEVEL_COL); keys.forEach((key) => { leafColumns.push({ @@ -226,34 +238,43 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); const font = options.customFont; + const fontNames: IPdfFontNames = { normal: 'helvetica', bold: 'helvetica' }; + // Add custom Unicode font if provided if (typeof font?.name === 'string' && font.name.trim() && typeof font?.data === 'string' && font.data.trim()) { try { const fontFileName = `${font.name}.ttf`; pdf.addFileToVFS(fontFileName, font.data); pdf.addFont(fontFileName, font.name, 'normal'); - this._currentFontName = font.name; + fontNames.normal = font.name; // Register bold font if provided if (typeof font.bold?.name === 'string' && font.bold.name.trim() && typeof font.bold?.data === 'string' && font.bold.data.trim()) { const boldFontFileName = `${font.bold.name}.ttf`; pdf.addFileToVFS(boldFontFileName, font.bold.data); pdf.addFont(boldFontFileName, font.bold.name, 'bold'); - this._currentBoldFontName = font.bold.name; + fontNames.bold = font.bold.name; } else { // If no bold variant provided, use the normal font for bold as well pdf.addFont(fontFileName, font.name, 'bold'); - this._currentBoldFontName = font.name; + fontNames.bold = font.name; } + + // jsPDF takes a font file it cannot read without complaint: it reports the + // problem on its own event bus instead of throwing, and only fails once the + // font is first used. Put both styles to work here, where falling back is + // still possible, rather than let the failure land part way through drawing + // the table and take the whole export down with it. + this.verifyFont(pdf, fontNames.normal, 'normal'); + this.verifyFont(pdf, fontNames.bold, 'bold'); } catch (error) { console.warn(`Failed to load custom font '${font.name}', falling back to helvetica:`, error); - this._currentFontName = 'helvetica'; - this._currentBoldFontName = 'helvetica'; + fontNames.normal = 'helvetica'; + fontNames.bold = 'helvetica'; + pdf.setFont(fontNames.normal, 'normal'); } } else if (options.customFont) { console.warn('Custom font configuration is incomplete (missing name or data), falling back to helvetica'); - this._currentFontName = 'helvetica'; - this._currentBoldFontName = 'helvetica'; } const pageWidth = pdf.internal.pageSize.getWidth(); @@ -293,42 +314,29 @@ export class IgxPdfExporterService extends IgxBaseExporter { headerHeight, usableWidth, options, - allColumns + fontNames ); } else { // Draw simple single-level headers - this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options); + this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options, fontNames); yPosition += headerHeight; } // Draw data rows - pdf.setFont(this._currentFontName, 'normal'); + pdf.setFont(fontNames.normal, 'normal'); // Check if this is a tree grid export (tree grids can have both TreeGridRecord and DataRecord types for nested children) const isTreeGridExport = data.some(record => record.type === ExportRecordType.TreeGridRecord); - // For pivot grids, get row dimension columns to help with value lookup - const rowDimensionColumnsByLevel: Map = new Map(); - if (isPivotGrid && defaultOwner) { - const allRowDimCols = allColumns.filter(col => - (col.headerType === ExportHeaderType.RowHeader || - col.headerType === ExportHeaderType.MultiRowHeader || - col.headerType === ExportHeaderType.PivotMergedHeader) && - !col.skip - ); - // Group by level - allRowDimCols.forEach(col => { - const level = col.level ?? 0; - if (!rowDimensionColumnsByLevel.has(level)) { - rowDimensionColumnsByLevel.set(level, []); - } - rowDimensionColumnsByLevel.get(level)!.push(col); - }); - // Sort each level by startIndex - rowDimensionColumnsByLevel.forEach((cols, _level) => { - cols.sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); - }); - } + // A row dimension value that repeats down consecutive records is drawn once, over + // all of them, the way the grid merges its own row headers - so the cells have to be + // measured against the records below before the first of them can be drawn. + const rowDimensionValues = this.resolveRowDimensionValues( + data, rowDimensionColumnCount, rowDimensionFields, allColumns, isPivotGrid); + const rowDimensionRuns = this.measureRowDimensionRuns(data, rowDimensionValues, rowDimensionColumnCount); + // How many more records the cell opened above still covers, per dimension. A page + // break closes them all: the next page opens its own under the headers it redraws. + const openRowDimensionCells = new Array(rowDimensionColumnCount).fill(0); let i = 0; while (i < data.length) { @@ -344,6 +352,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { if (yPosition + rowHeight > pageHeight - margin) { pdf.addPage(); yPosition = margin; + openRowDimensionCells.fill(0); // Redraw headers on new page if (hasMultiColumnHeaders || hasMultiRowHeaders) { @@ -359,10 +368,10 @@ export class IgxPdfExporterService extends IgxBaseExporter { headerHeight, usableWidth, options, - allColumns + fontNames ); } else { - this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options); + this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options, fontNames); yPosition += headerHeight; } } @@ -380,8 +389,14 @@ export class IgxPdfExporterService extends IgxBaseExporter { const indentLevel = (isTreeGridExport && record.level !== undefined) ? (record.level || 0) : 0; const indent = indentLevel * indentSize; + const rowDimensionCells = this.takeRowDimensionCells( + rowDimensionValues[i], + rowDimensionRuns[i], + openRowDimensionCells, + Math.floor((pageHeight - margin - yPosition) / rowHeight)); + // Draw parent row - this.drawDataRow(pdf, record, leafColumns, rowDimensionFields, margin, yPosition, columnWidth, rowHeight, indent, options, allColumns, isPivotGrid, rowDimensionColumnsByLevel, i, rowDimensionHeaders); + this.drawDataRow(pdf, record, leafColumns, rowDimensionCells, margin, yPosition, columnWidth, rowHeight, indent, options); yPosition += rowHeight; // For hierarchical grids, check if this record has child records @@ -424,7 +439,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { pageHeight, headerHeight, rowHeight, - options + options, + fontNames ); } @@ -454,16 +470,16 @@ export class IgxPdfExporterService extends IgxBaseExporter { headerHeight: number, _tableWidth: number, options: IgxPdfExporterOptions, - allColumns?: any[] + fontNames: IPdfFontNames ): number { let yPosition = yStart; - pdf.setFont(this._currentBoldFontName, 'bold'); + pdf.setFont(fontNames.bold, 'bold'); // First, draw row dimension header labels (for pivot grids) if present // Draw headers if we have any row dimension headers, regardless of maxRowLevel - if (rowDimensionHeaders.length > 0 && allColumns) { + if (rowDimensionHeaders.length > 0) { // Get PivotRowHeader columns - these are the dimension header names - const pivotRowHeaderCols = allColumns.filter(col => + const pivotRowHeaderCols = columns.filter(col => col.headerType === ExportHeaderType.PivotRowHeader && !col.skip ).sort((a, b) => (a.startIndex ?? 0) - (b.startIndex ?? 0)); @@ -480,16 +496,14 @@ export class IgxPdfExporterService extends IgxBaseExporter { const width = baseColumnWidth; const height = headerHeight * rowDimensionHeaderRowSpan; - // Skip if this is a merged/empty header that shouldn't be drawn - // PivotMergedHeader columns are typically placeholders and shouldn't be drawn separately - // Also skip if header text is empty and it's not a valid header - if ((pivotCol.headerType === ExportHeaderType.PivotMergedHeader && !headerText) || - (!headerText && !pivotCol.header && !pivotCol.field)) { + // Skip a placeholder header - one with no caption, no field and no dimension name + // to borrow, which is what leaves the text above empty + if (!headerText) { return; } // Set fill color to light gray for header background (explicitly set before each cell) - pdf.setFillColor(240, 240, 240); + this.setShadedFill(pdf); // Set stroke color to black for borders pdf.setDrawColor(0, 0, 0); @@ -526,36 +540,6 @@ export class IgxPdfExporterService extends IgxBaseExporter { // Don't move yPosition yet - data column headers will be drawn at the same yPosition // We'll move yPosition after drawing all header rows - } else if (rowDimensionHeaders.length > 0) { - // Fallback: draw simple headers without merging - rowDimensionHeaders.forEach((headerText, index) => { - const width = baseColumnWidth; - const height = headerHeight; - const xPosition = xStart + (index * baseColumnWidth); - - if (options.showTableBorders) { - pdf.rect(xPosition, yPosition, width, height, 'F'); - pdf.rect(xPosition, yPosition, width, height); - } - - // Center text in cell - let displayText = headerText || ''; - const maxTextWidth = width - 10; - - if (pdf.getTextWidth(displayText) > maxTextWidth) { - while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { - displayText = displayText.substring(0, displayText.length - 1); - } - displayText += '...'; - } - - const textWidth = pdf.getTextWidth(displayText); - const textX = xPosition + (width - textWidth) / 2; - const textY = yPosition + height / 2 + options.fontSize / 3; - - pdf.text(displayText, textX, textY); - }); - yPosition += headerHeight; } // Filter out row header types and GRID_LEVEL_COL from column rendering @@ -579,16 +563,32 @@ export class IgxPdfExporterService extends IgxBaseExporter { leafHeaders.forEach((col, idx) => headerLayoutMap.set(col, idx)); + // A column group owns its children through its `columnGroup` key, so a group without one + // has no children to look up - matching on it anyway would pair every column that has no + // parent with it, the group itself included. `resolving` catches the same cycle when it + // spans more than one group, so that a malformed column list costs a misplaced header + // rather than a stack overflow that takes the whole export down with it. + const resolving = new Set(); + const resolveLayoutStartIndex = (col: any): number => { if (headerLayoutMap.has(col)) { return headerLayoutMap.get(col)!; } - if (col.headerType === ExportHeaderType.MultiColumnHeader) { + const groupKey = col.columnGroup; + const ownsChildren = col.headerType === ExportHeaderType.MultiColumnHeader && + groupKey !== undefined && groupKey !== null && + !resolving.has(col); + + if (ownsChildren) { + resolving.add(col); + const childColumns = columnHeaders.filter(child => - child.columnGroupParent === col.columnGroup && child.columnSpan > 0); + child !== col && child.columnGroupParent === groupKey && child.columnSpan > 0); const childIndices = childColumns.map(child => resolveLayoutStartIndex(child)); + resolving.delete(col); + if (childIndices.length > 0) { const minIndex = Math.min(...childIndices); headerLayoutMap.set(col, minIndex); @@ -630,7 +630,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { const height = headerHeight * rowSpan; if (options.showTableBorders) { - pdf.setFillColor(240, 240, 240); + this.setShadedFill(pdf); pdf.setDrawColor(0, 0, 0); pdf.rect(xPosition, yPosition, width, height, 'F'); pdf.rect(xPosition, yPosition, width, height); @@ -660,14 +660,14 @@ export class IgxPdfExporterService extends IgxBaseExporter { // After drawing all headers, move yPosition down by the total header height // For pivot grids with row dimension headers, this should be the max of row dimension header height and data column header height - if (rowDimensionHeaders.length > 0 && allColumns) { + if (rowDimensionHeaders.length > 0) { const dataColumnHeaderRows = maxLevel + 1; const rowDimensionHeaderRowSpan = Math.max(dataColumnHeaderRows, 1); const totalHeaderHeight = headerHeight * rowDimensionHeaderRowSpan; yPosition = yStart + totalHeaderHeight; } - pdf.setFont(this._currentFontName, 'normal'); + pdf.setFont(fontNames.normal, 'normal'); return yPosition; } @@ -684,7 +684,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { pageHeight: number, headerHeight: number, rowHeight: number, - options: IgxPdfExporterOptions + options: IgxPdfExporterOptions, + fontNames: IPdfFontNames ): number { // Get columns for this child owner const childOwnerObj = this._ownersMap.get(childOwner); @@ -763,10 +764,11 @@ export class IgxPdfExporterService extends IgxBaseExporter { childColumnWidth, headerHeight, actualChildTableWidth, - options + options, + fontNames ); } else { - this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options); + this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options, fontNames); yPosition += headerHeight; } @@ -786,10 +788,10 @@ export class IgxPdfExporterService extends IgxBaseExporter { yPosition = this.drawMultiLevelHeaders( pdf, allChildColumns, [], maxLevel, 0, childTableX, yPosition, childColumnWidth, headerHeight, - actualChildTableWidth, options + actualChildTableWidth, options, fontNames ); } else { - this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options); + this.drawTableHeaders(pdf, childColumns, [], childTableX, yPosition, childColumnWidth, headerHeight, actualChildTableWidth, options, fontNames); yPosition += headerHeight; } } @@ -851,7 +853,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { pageHeight, headerHeight, rowHeight, - options + options, + fontNames ); } } @@ -874,10 +877,11 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth: number, headerHeight: number, tableWidth: number, - options: IgxPdfExporterOptions + options: IgxPdfExporterOptions, + fontNames: IPdfFontNames ): void { - pdf.setFont(this._currentBoldFontName, 'bold'); - pdf.setFillColor(240, 240, 240); + pdf.setFont(fontNames.bold, 'bold'); + this.setShadedFill(pdf); if (options.showTableBorders) { pdf.rect(xStart, yPosition, tableWidth, headerHeight, 'F'); @@ -911,13 +915,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { const rowDimensionOffset = rowDimensionHeaders.length * columnWidth; - // Draw data column headers + // Draw data column headers - GRID_LEVEL_COL is already out of the list by now columns.forEach((col, index) => { - // Skip GRID_LEVEL_COL - it shouldn't be rendered - if (col.field === GRID_LEVEL_COL) { - return; - } - const xPosition = xStart + rowDimensionOffset + (index * columnWidth); let headerText = col.header || col.field; @@ -942,168 +941,43 @@ export class IgxPdfExporterService extends IgxBaseExporter { pdf.text(headerText, textX, textY); }); - pdf.setFont(this._currentFontName, 'normal'); + pdf.setFont(fontNames.normal, 'normal'); } private drawDataRow( pdf: jsPDF, record: IExportRecord, columns: any[], - rowDimensionFields: string[], + rowDimensionCells: IRowDimensionCell[], xStart: number, yPosition: number, columnWidth: number, rowHeight: number, indent: number, - options: IgxPdfExporterOptions, - allColumns?: any[], - isPivotGrid?: boolean, - _rowDimensionColumnsByLevel?: Map, - recordIndex?: number, - rowDimensionHeaders?: string[] + options: IgxPdfExporterOptions ): void { const isSummaryRecord = record.type === 'SummaryRecord'; - // Draw row dimension cells first (for pivot grids) - // For pivot grids, the row dimension columns have 'header' property that contains the actual dimension values - // Use the maximum of fields and headers to ensure we draw all columns - const maxRowDimCols = Math.max(rowDimensionFields.length, rowDimensionHeaders?.length || 0); - for (let index = 0; index < maxRowDimCols; index++) { - const xPosition = xStart + (index * columnWidth); - let cellValue: any = null; - - // Primary approach: Get the value from row dimension columns' header property - // The row dimension columns are created with header = actual dimension value to display - if (isPivotGrid && allColumns) { - // Get all row dimension columns sorted by level and startIndex - const allRowDimCols = allColumns.filter(col => - (col.headerType === ExportHeaderType.RowHeader || - col.headerType === ExportHeaderType.MultiRowHeader || - col.headerType === ExportHeaderType.PivotMergedHeader) && - !col.skip - ).sort((a, b) => { - const levelDiff = (a.level ?? 0) - (b.level ?? 0); - if (levelDiff !== 0) return levelDiff; - return (a.startIndex ?? 0) - (b.startIndex ?? 0); - }); - - // For hierarchical dimensions, match columns by level - // The index corresponds to the dimension level (0 = first dimension, 1 = second, etc.) - const colsForLevel = allRowDimCols.filter(col => (col.level ?? 0) === index); - - // The row dimension columns are created in the same order as records appear - // We can use the record index to find the corresponding column - // However, for hierarchical dimensions, we need to account for row spans - if (colsForLevel.length > 0) { - // Try to find the column that matches this record - // First, try matching by checking if column field/header matches record data - let matchedCol = null; - if (record.data) { - for (const col of colsForLevel) { - const colField = typeof col.field === 'string' ? col.field : null; - const colHeader = typeof col.header === 'string' ? col.header : null; - - // Check if column field exists as a key in record data - if (colField && record.data[colField] !== undefined) { - matchedCol = col; - break; - } - // Check if column header matches a value in record data - if (colHeader) { - const recordValues = Object.values(record.data).map(v => String(v)); - if (recordValues.includes(colHeader)) { - matchedCol = col; - break; - } - } - } - } - - // If no match found, try to use record index to select column - // This works because columns are created in the same order as records - if (!matchedCol && recordIndex !== undefined) { - // For hierarchical dimensions with row spans, we need to account for that - // For now, use a simple index-based approach - const colIndex = Math.min(recordIndex, colsForLevel.length - 1); - matchedCol = colsForLevel[colIndex]; - } - - // If still no match, use the first column at this level - if (!matchedCol && colsForLevel.length > 0) { - matchedCol = colsForLevel[0]; - } - - // Use the header property - it contains the actual dimension value to display - if (matchedCol) { - if (matchedCol.header && typeof matchedCol.header === 'string') { - cellValue = matchedCol.header; - } else if (matchedCol.field && typeof matchedCol.field === 'string') { - cellValue = matchedCol.field; - } - } - } - } - - // Fallback: Try to get value using dimensionKeys (member names as keys in record.data) - if ((cellValue === null || cellValue === undefined) && record.data) { - const fieldName = rowDimensionFields[index]; - if (fieldName) { - cellValue = record.data[fieldName]; - } - } - - // Last resort: Try to find it by checking all keys in record data - if ((cellValue === null || cellValue === undefined) && record.data) { - const recordKeys = Object.keys(record.data); - const fieldName = rowDimensionFields[index]; - - // If we have a fieldName, try exact and fuzzy matching - if (fieldName) { - const matchingKey = recordKeys.find(key => - key.toLowerCase() === fieldName.toLowerCase() || - key === fieldName || - fieldName.toLowerCase().includes(key.toLowerCase()) || - key.toLowerCase().includes(fieldName.toLowerCase()) - ); - if (matchingKey) { - cellValue = record.data[matchingKey]; - } - } - - // For hierarchical dimensions, try using dimension keys by index - if ((cellValue === null || cellValue === undefined) && isPivotGrid && recordKeys.length > 0) { - const possibleDimKeys = recordKeys.filter(key => { - return !key.includes('-') && !key.includes('_') && - key === key.trim() && - key.length < 50; - }); - - if (possibleDimKeys.length > index) { - cellValue = record.data[possibleDimKeys[index]]; - } else if (possibleDimKeys.length > 0) { - cellValue = record.data[possibleDimKeys[0]]; - } - } + // Draw the row dimension cells first (for pivot grids). Which of them this record + // opens, and how far down the page each one reaches, is worked out by the caller. + rowDimensionCells.forEach((cell, index) => { + if (cell.rowSpan < 1) { + // The cell of a record above covers this one. + return; } - // Convert value to string - if (cellValue === null || cellValue === undefined) { - cellValue = ''; - } else if (cellValue instanceof Date) { - cellValue = cellValue.toLocaleDateString(); - } else { - cellValue = String(cellValue); - } + const xPosition = xStart + (index * columnWidth); + const height = rowHeight * cell.rowSpan; if (options.showTableBorders) { - pdf.setFillColor(255, 255, 255); - pdf.setDrawColor(0, 0, 0); - pdf.rect(xPosition, yPosition, columnWidth, rowHeight); + // A row dimension cell heads its record the way the column headers head the + // columns, so it is shaded rather than left to read as one of the values. + this.drawBodyCell(pdf, xPosition, yPosition, columnWidth, height, true); } // Truncate text if it's too long const maxTextWidth = columnWidth - 10; - let displayText = cellValue; + let displayText = cell.text; if (pdf.getTextWidth(displayText) > maxTextWidth) { while (pdf.getTextWidth(displayText + '...') > maxTextWidth && displayText.length > 0) { @@ -1112,19 +986,16 @@ export class IgxPdfExporterService extends IgxBaseExporter { displayText += '...'; } - const textY = yPosition + rowHeight / 2 + options.fontSize / 3; + // A merged cell carries its value down the middle of the records it covers, the + // way the grid centres a row header over the rows it spans. + const textY = yPosition + (height / 2) + options.fontSize / 3; pdf.text(displayText, xPosition + 5, textY); - } + }); - const rowDimensionOffset = maxRowDimCols * columnWidth; + const rowDimensionOffset = rowDimensionCells.length * columnWidth; - // Draw data columns + // Draw data columns - GRID_LEVEL_COL is already out of the list by now columns.forEach((col, index) => { - // Skip GRID_LEVEL_COL - it's an internal column - if (col.field === GRID_LEVEL_COL) { - return; - } - const xPosition = xStart + rowDimensionOffset + (index * columnWidth); let cellValue = record.data[col.field]; @@ -1159,9 +1030,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { } if (options.showTableBorders) { - pdf.setFillColor(255, 255, 255); - pdf.setDrawColor(0, 0, 0); - pdf.rect(xPosition, yPosition, columnWidth, rowHeight); + this.drawBodyCell(pdf, xPosition, yPosition, columnWidth, rowHeight, isSummaryRecord); } // Apply indentation to the first column for hierarchical data @@ -1183,6 +1052,273 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); } + /** + * The value every record holds for each of its row dimensions, worked out once up front so + * that the merging below can look down the page without searching the columns again for + * every row it passes. + */ + private resolveRowDimensionValues( + data: IExportRecord[], + columnCount: number, + rowDimensionFields: string[], + allColumns: any[], + isPivotGrid: boolean + ): string[][] { + return data.map((record, index) => Array.from({ length: columnCount }, (_, level) => + this.resolveRowDimensionValue(record, level, rowDimensionFields, allColumns, isPivotGrid, index))); + } + + /** + * How many records from each one on carry the same row dimension value, level by level - + * the run the grid shows as a single, merged row header cell. A value only continues a run + * when the dimensions above it match too, so a date that repeats under two different cities + * stays two cells. A blank value is left on its own: it is what the export falls back to + * when it cannot work a dimension out, and merging blanks would take the row lines of the + * dimension column with them. + */ + private measureRowDimensionRuns(data: IExportRecord[], values: string[][], columnCount: number): number[][] { + const runs = data.map(() => new Array(columnCount).fill(1)); + let next = -1; + + for (let index = data.length - 1; index >= 0; index--) { + if (data[index].hidden) { + continue; + } + + for (let level = 0; level < columnCount; level++) { + const continued = next !== -1 && values[index][level] !== '' && + values[index].slice(0, level + 1).every((value, above) => value === values[next][above]); + + runs[index][level] = continued ? runs[next][level] + 1 : 1; + } + + next = index; + } + + return runs; + } + + /** + * Opens the row dimension cells of a record. One that a record above already covers comes + * back with no span at all, and one that would reach past the bottom of the page is cut off + * there - the next page opens it again, under its own redrawn headers. + */ + private takeRowDimensionCells( + values: string[], + runs: number[], + openCells: number[], + rowsLeftOnPage: number + ): IRowDimensionCell[] { + return values.map((text, level) => { + if (openCells[level] > 0) { + openCells[level]--; + + return { text, rowSpan: 0 }; + } + + const rowSpan = Math.max(1, Math.min(runs[level], rowsLeftOnPage)); + openCells[level] = rowSpan - 1; + + return { text, rowSpan }; + }); + } + + /** + * The value a pivot record holds for one of its row dimensions: read off the record where it + * carries the dimension itself, and worked out from the row header columns by position where + * it does not. + */ + private resolveRowDimensionValue( + record: IExportRecord, + level: number, + rowDimensionFields: string[], + allColumns: any[] | undefined, + isPivotGrid: boolean | undefined, + recordIndex: number | undefined + ): string { + let cellValue: any = null; + + // Primary source: the record as it was before the base exporter reduced it to the + // owner's data columns. It is kept whenever the owner has row headers - that is, + // for every pivot grid - and holds each dimension's value under the dimension's own + // name, which is what the CSV exporter reads as well. Everything below it has to + // work the value out from the columns instead, and can only do so by position. + const dimensionKey = rowDimensionFields[level]; + if (isPivotGrid && dimensionKey && record.rawData?.[dimensionKey] !== undefined) { + cellValue = record.rawData[dimensionKey]; + } + + // Otherwise: get the value from row dimension columns' header property + // The row dimension columns are created with header = actual dimension value to display + if (cellValue === null && isPivotGrid && allColumns) { + // Get all row dimension columns sorted by level and startIndex + const allRowDimCols = allColumns.filter(col => + (col.headerType === ExportHeaderType.RowHeader || + col.headerType === ExportHeaderType.MultiRowHeader || + col.headerType === ExportHeaderType.PivotMergedHeader) && + !col.skip + ).sort((a, b) => { + const levelDiff = (a.level ?? 0) - (b.level ?? 0); + if (levelDiff !== 0) return levelDiff; + return (a.startIndex ?? 0) - (b.startIndex ?? 0); + }); + + // For hierarchical dimensions, match columns by level + // The level is the dimension level (0 = first dimension, 1 = second, etc.) + const colsForLevel = allRowDimCols.filter(col => (col.level ?? 0) === level); + + // The row dimension columns are created in the same order as records appear + // We can use the record index to find the corresponding column + // However, for hierarchical dimensions, we need to account for row spans + if (colsForLevel.length > 0) { + // Try to find the column that matches this record + // First, try matching by checking if column field/header matches record data + let matchedCol = null; + if (record.data) { + for (const col of colsForLevel) { + const colField = typeof col.field === 'string' ? col.field : null; + const colHeader = typeof col.header === 'string' ? col.header : null; + + // The record carries the dimension under the field the column names, so + // its own value is the one to draw. Taking the column's caption instead + // would label every record with the caption of the first column of the + // level: they all name the same field, so the first of them matches every + // record that has it, and three products would come out as three of the + // first one. + if (colField && record.data[colField] !== undefined) { + cellValue = record.data[colField]; + break; + } + // Check if column header matches a value in record data + if (colHeader) { + const recordValues = Object.values(record.data).map(v => String(v)); + if (recordValues.includes(colHeader)) { + matchedCol = col; + break; + } + } + } + } + + // If no match found, fall back on the record index to select a column. This + // works because columns are created in the same order as records, and always + // lands on one, so nothing below has to cope with there still being no match. + if (cellValue === null && !matchedCol && recordIndex !== undefined) { + // For hierarchical dimensions with row spans, we need to account for that + // For now, use a simple index-based approach + const colIndex = Math.min(recordIndex, colsForLevel.length - 1); + matchedCol = colsForLevel[colIndex]; + } + + // Use the header property - it contains the actual dimension value to display + if (matchedCol) { + if (matchedCol.header && typeof matchedCol.header === 'string') { + cellValue = matchedCol.header; + } else if (matchedCol.field && typeof matchedCol.field === 'string') { + cellValue = matchedCol.field; + } + } + } + } + + // Fallback: Try to get value using dimensionKeys (member names as keys in record.data) + if ((cellValue === null || cellValue === undefined) && record.data) { + const fieldName = rowDimensionFields[level]; + if (fieldName) { + cellValue = record.data[fieldName]; + } + } + + // Last resort: Try to find it by checking all keys in record data + if ((cellValue === null || cellValue === undefined) && record.data) { + const recordKeys = Object.keys(record.data); + const fieldName = rowDimensionFields[level]; + + // If we have a fieldName, try exact and fuzzy matching + if (fieldName) { + const matchingKey = recordKeys.find(key => + key.toLowerCase() === fieldName.toLowerCase() || + key === fieldName || + fieldName.toLowerCase().includes(key.toLowerCase()) || + key.toLowerCase().includes(fieldName.toLowerCase()) + ); + if (matchingKey) { + cellValue = record.data[matchingKey]; + } + } + + // For hierarchical dimensions, try using dimension keys by level + if ((cellValue === null || cellValue === undefined) && isPivotGrid && recordKeys.length > 0) { + const possibleDimKeys = recordKeys.filter(key => { + return !key.includes('-') && !key.includes('_') && + key === key.trim() && + key.length < 50; + }); + + if (possibleDimKeys.length > level) { + cellValue = record.data[possibleDimKeys[level]]; + } else if (possibleDimKeys.length > 0) { + cellValue = record.data[possibleDimKeys[0]]; + } + } + } + + // Convert value to string + if (cellValue === null || cellValue === undefined) { + cellValue = ''; + } else if (cellValue instanceof Date) { + cellValue = cellValue.toLocaleDateString(); + } else { + cellValue = String(cellValue); + } + + return cellValue; + } + + /** + * Selects the shade a header cell is filled with. Every cell that heads rather than holds + * data takes the same one: a summary row, which closes the rows above it the way the header + * opens them, and a pivot grid's row dimension cells, which head the record they sit on. + */ + private setShadedFill(pdf: jsPDF): void { + pdf.setFillColor(240, 240, 240); + } + + /** + * Draws the border of a cell below the header row, together with the shaded background that + * goes with it when the cell heads its row rather than holding one of its values. + */ + private drawBodyCell( + pdf: jsPDF, + x: number, + y: number, + width: number, + height: number, + shaded: boolean + ): void { + pdf.setDrawColor(0, 0, 0); + + if (shaded) { + this.setShadedFill(pdf); + pdf.rect(x, y, width, height, 'F'); + } else { + pdf.setFillColor(255, 255, 255); + } + + pdf.rect(x, y, width, height); + } + + /** + * Selects a font and measures a character with it, so that a font file jsPDF could not read + * throws here instead of part way through drawing the table. Both steps are needed: selecting + * a font is enough for a name that was never registered, and measuring is what reaches the + * glyph data an unreadable file leaves missing. + */ + private verifyFont(pdf: jsPDF, fontName: string, fontStyle: string): void { + pdf.setFont(fontName, fontStyle); + pdf.getTextWidth('0'); + } + private saveFile(pdf: jsPDF, fileName: string): void { const blob = pdf.output('blob'); ExportUtilities.saveBlobToFile(blob, fileName);