From 796749711b47ceb3fa80001f9ccce80669b9b506 Mon Sep 17 00:00:00 2001 From: Stamen Stoychev Date: Wed, 26 Aug 2026 13:36:36 +0300 Subject: [PATCH 01/17] test(*): target testing low coverage parts of the code --- .../core/src/core/utils.spec.ts | 168 +++++++- .../filtering-condition.spec.ts | 184 +++++++++ .../data-operations/merge-strategy.spec.ts | 127 ++++++ .../data-operations/sorting-strategy.spec.ts | 92 ++++- .../scroll_inertia.directive.spec.ts | 110 ++++++ .../selection/drag-select.directive.spec.ts | 186 +++++++++ .../src/services/pdf/pdf-exporter.spec.ts | 363 ++++++++++++++++++ .../src/pivot-grid-keyboard-nav.spec.ts | 89 +++++ 8 files changed, 1317 insertions(+), 2 deletions(-) create mode 100644 projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts create mode 100644 projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts diff --git a/projects/igniteui-angular/core/src/core/utils.spec.ts b/projects/igniteui-angular/core/src/core/utils.spec.ts index 2a6bb842080..9d9f8a4af2c 100644 --- a/projects/igniteui-angular/core/src/core/utils.spec.ts +++ b/projects/igniteui-angular/core/src/core/utils.spec.ts @@ -1,5 +1,6 @@ import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; -import { cloneValue, isObject, isDate } from './utils'; +import { cloneValue, cloneValueCached, cloneHierarchicalArray, compareMaps, getComponentCssSizeVar, + intoChunks, isDate, isLeftToRight, isObject, showMessage, uniqueDates } from './utils'; describe('Utils', () => { const complexObject = { @@ -169,6 +170,171 @@ describe('Utils', () => { const undefinedClone = cloneValue(undefined); expect(undefinedClone).toBeUndefined(); }); + + it('Should skip the `externalObject` key', () => { + const source = { Number: 1, externalObject: { framework: 'reference that must not be cloned' } }; + + const clone = cloneValue(source); + + expect(clone.Number).toBe(1); + expect('externalObject' in clone).toBeFalsy(); + }); + }); + + describe('Utils - cloneValueCached() unit tests', () => { + it('Should clone primitives, dates and arrays the same way cloneValue does', () => { + const cache = new Map(); + const date = new Date(10 * 1000 * 60 * 60 * 24); + const array = [1, { a: 1 }, 'string']; + + expect(cloneValueCached(1, cache)).toBe(1); + expect(cloneValueCached('string', cache)).toBe('string'); + expect(cloneValueCached(true, cache)).toBe(true); + expect(cloneValueCached(null, cache)).toBeNull(); + expect(cloneValueCached(undefined, cache)).toBeUndefined(); + + const clonedDate = cloneValueCached(date, cache); + expect(clonedDate).toEqual(date); + expect(clonedDate).not.toBe(date); + + // Arrays are shallow copied - the array itself is new, its items are not. + const clonedArray = cloneValueCached(array, cache); + expect(clonedArray).toEqual(array); + expect(clonedArray).not.toBe(array); + expect(clonedArray[1]).toBe(array[1]); + }); + + it('Should not clone Map or Set', () => { + const cache = new Map(); + const map = new Map([['key', 'value']]); + const set = new Set(['value']); + + expect(cloneValueCached(map, cache)).toBe(map); + expect(cloneValueCached(set, cache)).toBe(set); + }); + + it('Should deep clone objects', () => { + const cache = new Map(); + const clone = cloneValueCached(complexObject, cache); + + expect(clone).toEqual(complexObject); + expect(clone).not.toBe(complexObject); + expect(clone.Object10).not.toBe(complexObject.Object10); + expect(clone.Object10.Object100).not.toBe(complexObject.Object10.Object100); + }); + + it('Should reuse the cached clone for repeated references', () => { + const cache = new Map(); + const shared = { value: 'shared' }; + const source = { first: shared, second: shared }; + + const clone = cloneValueCached(source, cache); + + expect(clone).toEqual(source); + // The same source reference has to resolve to the same clone, not to two separate copies. + expect(clone.first).toBe(clone.second); + expect(clone.first).not.toBe(shared); + }); + + it('Should handle circular references', () => { + const cache = new Map(); + const source: any = { name: 'root' }; + source.self = source; + source.child = { parent: source }; + + const clone = cloneValueCached(source, cache); + + expect(clone.name).toBe('root'); + expect(clone.self).toBe(clone); + expect(clone.child.parent).toBe(clone); + expect(clone).not.toBe(source); + }); + }); + + describe('Utils - uniqueDates() unit tests', () => { + it('Should keep only the first entry for every distinct label', () => { + const first = { label: '1/1/2024', value: new Date(2024, 0, 1) }; + const duplicate = { label: '1/1/2024', value: new Date(2024, 0, 1) }; + const second = { label: '2/1/2024', value: new Date(2024, 1, 1) }; + + expect(uniqueDates([first, duplicate, second, second])).toEqual([first, second]); + expect(uniqueDates([])).toEqual([]); + }); + }); + + describe('Utils - compareMaps() unit tests', () => { + it('Should compare maps by size, keys and values', () => { + const map = new Map([['a', 1], ['b', 2]]); + + expect(compareMaps(map, new Map([['a', 1], ['b', 2]]))).toBeTruthy('equal maps'); + expect(compareMaps(map, new Map([['a', 1], ['b', 3]]))).toBeFalsy('different value'); + expect(compareMaps(map, new Map([['a', 1], ['c', 2]]))).toBeFalsy('different key'); + expect(compareMaps(map, new Map([['a', 1]]))).toBeFalsy('different size'); + expect(compareMaps(new Map(), new Map())).toBeTruthy('two empty maps'); + }); + + it('Should treat a missing second map as equal only to a missing first one', () => { + expect(compareMaps(null, null)).toBeTruthy('both missing'); + expect(compareMaps(new Map([['a', 1]]), null)).toBeFalsy('only the second one missing'); + }); + }); + + describe('Utils - intoChunks() unit tests', () => { + it('Should split an array into chunks of the requested size', () => { + const array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + expect(Array.from(intoChunks(array, 2))).toEqual([[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]); + // The last chunk holds the remainder when the size is not a divisor of the length. + expect(Array.from(intoChunks(array, 3))).toEqual([[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]]); + expect(Array.from(intoChunks(array, 20))).toEqual([array]); + expect(Array.from(intoChunks([], 3))).toEqual([]); + }); + + it('Should throw for a chunk size below one', () => { + expect(() => Array.from(intoChunks([1, 2, 3], 0))).toThrowError('size must be an integer >= 1'); + expect(() => Array.from(intoChunks([1, 2, 3], -3))).toThrowError('size must be an integer >= 1'); + }); + }); + + describe('Utils - getComponentCssSizeVar() unit tests', () => { + it('Should map the numeric size to the matching CSS variable', () => { + expect(getComponentCssSizeVar('1')).toBe('var(--ig-size, var(--ig-size-small))'); + expect(getComponentCssSizeVar('2')).toBe('var(--ig-size, var(--ig-size-medium))'); + expect(getComponentCssSizeVar('3')).toBe('var(--ig-size, var(--ig-size-large))'); + // Anything unrecognized falls back to the large size. + expect(getComponentCssSizeVar('')).toBe('var(--ig-size, var(--ig-size-large))'); + }); + }); + + describe('Utils - showMessage() unit tests', () => { + it('Should always report the message as shown', () => { + // The warning itself is a console side effect - what the callers act on is the returned flag, + // which has to stay `true` both for the call that logs and for the one that skips it. + expect(showMessage('Deprecated', false)).toBeTruthy('not shown yet'); + expect(showMessage('Deprecated', true)).toBeTruthy('already shown'); + }); + }); + + describe('Utils - cloneHierarchicalArray() unit tests', () => { + it('Should clone nested arrays and tolerate a missing source', () => { + const source = [ + { id: 1, children: [{ id: 11, children: [] }] }, + { id: 2 } + ]; + + const clone = cloneHierarchicalArray(source, 'children'); + expect(clone).toEqual(source); + expect(clone).not.toBe(source); + expect(clone[0].children).not.toBe(source[0].children); + + expect(cloneHierarchicalArray(null, 'children')).toEqual([]); + }); + }); + + describe('Utils - isLeftToRight() unit tests', () => { + it('Should default to left-to-right when there is no element', () => { + expect(isLeftToRight(null)).toBeTruthy('no element'); + }); }); describe('Utils - isObject() unit tests', () => { diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts index a8f8d0ade0e..756a4b154ba 100644 --- a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts +++ b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts @@ -1,6 +1,8 @@ import { IgxStringFilteringOperand, IgxNumberFilteringOperand, IgxDateFilteringOperand, + IgxDateTimeFilteringOperand, + IgxTimeFilteringOperand, IgxBooleanFilteringOperand, IgxFilteringOperand} from './filtering-condition'; @@ -143,6 +145,188 @@ describe('Unit testing FilteringCondition', () => { expect(!f.condition('notNull').logic(null) && f.condition('notNull').logic(undefined) && f.condition('notNull').logic(false)) .toBeTruthy('notNull'); }); + it('tests dateTime conditions', () => { + const fdt = IgxDateTimeFilteringOperand.instance(); + const now = new Date(); + const yesterday = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const lastMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() - 1)); +})(new Date()); + const nextMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() + 1)); +})(new Date()); + const lastYear = ((d) => new Date(d.setFullYear(d.getFullYear() - 1)))(new Date()); + const nextYear = ((d) => new Date(d.setFullYear(d.getFullYear() + 1)))(new Date()); + + expect(fdt.condition('before').logic(yesterday, now) && + !fdt.condition('before').logic(now, yesterday) && + !fdt.condition('before').logic(null, now)) + .toBeTruthy('before'); + expect(fdt.condition('after').logic(now, yesterday) && + !fdt.condition('after').logic(yesterday, now) && + !fdt.condition('after').logic(null, now)) + .toBeTruthy('after'); + expect(fdt.condition('today').logic(now) && + !fdt.condition('today').logic(nextYear) && + !fdt.condition('today').logic(null)) + .toBeTruthy('today'); + expect(fdt.condition('yesterday').logic(yesterday) && + !fdt.condition('yesterday').logic(nextYear) && + !fdt.condition('yesterday').logic(null)) + .toBeTruthy('yesterday'); + expect(fdt.condition('thisMonth').logic(now) && + !fdt.condition('thisMonth').logic(nextYear) && + !fdt.condition('thisMonth').logic(null)) + .toBeTruthy('thisMonth'); + expect(fdt.condition('lastMonth').logic(lastMonth) && + !fdt.condition('lastMonth').logic(now) && + !fdt.condition('lastMonth').logic(null)) + .toBeTruthy('lastMonth'); + expect(fdt.condition('nextMonth').logic(nextMonth) && + !fdt.condition('nextMonth').logic(now) && + !fdt.condition('nextMonth').logic(null)) + .toBeTruthy('nextMonth'); + expect(fdt.condition('thisYear').logic(now) && + !fdt.condition('thisYear').logic(nextYear) && + !fdt.condition('thisYear').logic(null)) + .toBeTruthy('thisYear'); + expect(fdt.condition('lastYear').logic(lastYear) && + !fdt.condition('lastYear').logic(now) && + !fdt.condition('lastYear').logic(null)) + .toBeTruthy('lastYear'); + expect(fdt.condition('nextYear').logic(nextYear) && + !fdt.condition('nextYear').logic(now) && + !fdt.condition('nextYear').logic(null)) + .toBeTruthy('nextYear'); + }); + it('tests dateTime conditions when the current month rolls over a year boundary', () => { + const fdt = IgxDateTimeFilteringOperand.instance(); + const fd = IgxDateFilteringOperand.instance(); + jasmine.clock().install(); + try { + // In January `lastMonth` has to roll back to December of the previous year. + jasmine.clock().mockDate(new Date(2024, 0, 15)); + expect(fdt.condition('lastMonth').logic(new Date(2023, 11, 31)) && + !fdt.condition('lastMonth').logic(new Date(2024, 0, 1)) && + !fdt.condition('lastMonth').logic(new Date(2023, 10, 30))) + .toBeTruthy('dateTime lastMonth in January'); + expect(fd.condition('lastMonth').logic(new Date(2023, 11, 31)) && + !fd.condition('lastMonth').logic(new Date(2024, 0, 1))) + .toBeTruthy('date lastMonth in January'); + expect(fdt.condition('nextMonth').logic(new Date(2024, 1, 1)) && + !fdt.condition('nextMonth').logic(new Date(2024, 0, 31))) + .toBeTruthy('dateTime nextMonth in January'); + + // In December `nextMonth` has to roll forward to January of the next year. + jasmine.clock().mockDate(new Date(2024, 11, 15)); + expect(fdt.condition('nextMonth').logic(new Date(2025, 0, 1)) && + !fdt.condition('nextMonth').logic(new Date(2024, 11, 31)) && + !fdt.condition('nextMonth').logic(new Date(2026, 0, 1))) + .toBeTruthy('dateTime nextMonth in December'); + expect(fd.condition('nextMonth').logic(new Date(2025, 0, 1)) && + !fd.condition('nextMonth').logic(new Date(2024, 11, 31))) + .toBeTruthy('date nextMonth in December'); + expect(fdt.condition('lastMonth').logic(new Date(2024, 10, 30)) && + !fdt.condition('lastMonth').logic(new Date(2024, 11, 1))) + .toBeTruthy('dateTime lastMonth in December'); + } finally { + jasmine.clock().uninstall(); + } + }); + it('tests time conditions', () => { + const ft = IgxTimeFilteringOperand.instance(); + const at = new Date(2024, 4, 17, 10, 30, 30); + const earlierHours = new Date(2024, 4, 17, 9, 30, 30); + const earlierMinutes = new Date(2024, 4, 17, 10, 15, 30); + const earlierSeconds = new Date(2024, 4, 17, 10, 30, 15); + const laterHours = new Date(2024, 4, 17, 11, 30, 30); + const laterMinutes = new Date(2024, 4, 17, 10, 45, 30); + const laterSeconds = new Date(2024, 4, 17, 10, 30, 45); + // The date part is deliberately different - only the time part participates. + const sameTimeOtherDay = new Date(2020, 0, 1, 10, 30, 30); + + expect(ft.condition('at_before').logic(at, at) && + ft.condition('at_before').logic(sameTimeOtherDay, at) && + ft.condition('at_before').logic(earlierHours, at) && + ft.condition('at_before').logic(earlierMinutes, at) && + ft.condition('at_before').logic(earlierSeconds, at)) + .toBeTruthy('at_before matches the exact time and everything before it'); + expect(!ft.condition('at_before').logic(laterHours, at) && + !ft.condition('at_before').logic(laterMinutes, at) && + !ft.condition('at_before').logic(laterSeconds, at) && + !ft.condition('at_before').logic(null, at)) + .toBeTruthy('at_before does not match later times'); + + expect(ft.condition('at_after').logic(at, at) && + ft.condition('at_after').logic(sameTimeOtherDay, at) && + ft.condition('at_after').logic(laterHours, at) && + ft.condition('at_after').logic(laterMinutes, at) && + ft.condition('at_after').logic(laterSeconds, at)) + .toBeTruthy('at_after matches the exact time and everything after it'); + expect(!ft.condition('at_after').logic(earlierHours, at) && + !ft.condition('at_after').logic(earlierMinutes, at) && + !ft.condition('at_after').logic(earlierSeconds, at) && + !ft.condition('at_after').logic(null, at)) + .toBeTruthy('at_after does not match earlier times'); + + // `in` matches on the locale time string, so the date part is irrelevant here as well. + const times = new Set([at.toLocaleTimeString()]); + expect(ft.condition('in').logic(at, times) && + ft.condition('in').logic(sameTimeOtherDay, times) && + !ft.condition('in').logic(laterHours, times) && + !ft.condition('in').logic(null, times)) + .toBeTruthy('in'); + }); + it('tests the shared date-time helpers', () => { + const date = new Date(2024, 4, 17, 13, 24, 35, 678); + + // Without a date, or without a format, every part stays null. + const noDate = IgxDateFilteringOperand.getDateParts(null, 'yMdhmsf'); + const noFormat = IgxDateFilteringOperand.getDateParts(date); + expect(Object.values(noDate).every(part => part === null)).toBeTruthy('no date'); + expect(Object.values(noFormat).every(part => part === null)).toBeTruthy('no format'); + + // Each part is resolved only when the format asks for it. + expect(IgxDateFilteringOperand.getDateParts(date, 'yMdhmsf')).toEqual({ + year: 2024, month: 4, day: 17, hours: 13, minutes: 24, seconds: 35, milliseconds: 678 + }); + const dateOnly = IgxDateFilteringOperand.getDateParts(date, 'yMd'); + expect(dateOnly.hours === null && dateOnly.minutes === null && + dateOnly.seconds === null && dateOnly.milliseconds === null) + .toBeTruthy('parts outside of the format stay null'); + + // Each operand matches against the set through its own string form - the full ISO string for + // dateTime, the date part only for date, and the locale time for time. + const fdt = IgxDateTimeFilteringOperand.instance(); + expect(fdt.condition('in').logic(date, new Set([date.toISOString()])) && + !fdt.condition('in').logic(date, new Set([new Date(2020, 0, 1).toISOString()])) && + fdt.condition('in').logic('plain value', new Set(['plain value'])) && + !fdt.condition('in').logic(null, new Set([date.toISOString()]))) + .toBeTruthy('dateTime in'); + + const fd = IgxDateFilteringOperand.instance(); + expect(fd.condition('in').logic(date, new Set([date.toDateString()])) && + !fd.condition('in').logic(date, new Set([new Date(2020, 0, 1).toDateString()])) && + !fd.condition('in').logic(null, new Set([date.toDateString()]))) + .toBeTruthy('date in'); + + expect(() => fd.condition('before').logic('not a date', date)) + .toThrowError( + 'Could not perform filtering on \'date\' column because the datasource object type is not \'Date\'.'); + }); + it('tests nested query conditions', () => { + const f = IgxStringFilteringOperand.instance(); + const values = new Set(['a', 'b']); + + expect(f.condition('inQuery').logic('a', values) && !f.condition('inQuery').logic('c', values)) + .toBeTruthy('inQuery'); + expect(f.condition('notInQuery').logic('c', values) && !f.condition('notInQuery').logic('a', values)) + .toBeTruthy('notInQuery'); + // Nested query conditions are hidden from the plain condition list, but not from the extended one. + expect(f.conditionList()).not.toContain('inQuery'); + expect(f.extendedConditionList()).toContain('inQuery'); + expect(f.extendedConditionList()).toContain('notInQuery'); + }); it('tests custom conditions', () => { const f = CustomFilter.instance(); expect(f.condition('Custom').logic('Asd', 'asd')).toBeFalsy(); diff --git a/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts new file mode 100644 index 00000000000..33eb9ad25f5 --- /dev/null +++ b/projects/igniteui-angular/core/src/data-operations/merge-strategy.spec.ts @@ -0,0 +1,127 @@ +import { ByLevelTreeGridMergeStrategy, DefaultMergeStrategy, DefaultTreeGridMergeStrategy } from './merge-strategy'; + +describe('Unit testing MergeStrategy', () => { + // `merge` asks the grid to classify every record before it considers it for merging. + const gridStub = { + isDetailRecord: () => false, + isGroupByRecord: () => false, + isChildGridRecord: () => false, + isSummaryRow: () => false, + isGhostRecord: () => false + } as any; + + it('tests the default `comparer`', () => { + const strategy = DefaultMergeStrategy.instance(); + + expect(strategy).toBe(DefaultMergeStrategy.instance(), 'the strategy is a singleton'); + + expect(strategy.comparer({ name: 'a' }, { name: 'a' }, 'name')) + .toBeTruthy('equal values'); + expect(strategy.comparer({ name: 'a' }, { name: 'b' }, 'name')) + .toBeFalsy('different values'); + // Two missing values are considered the same, a missing one next to a present one is not. + expect(strategy.comparer({ name: null }, { name: undefined }, 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer({ name: null }, { name: 'a' }, 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer({ name: 'a' }, { name: null }, 'name')) + .toBeFalsy('only the current value nullish'); + }); + + it('tests the date and time flags of the default `comparer`', () => { + const strategy = DefaultMergeStrategy.instance(); + const morning = { date: new Date(2024, 4, 17, 8, 30) }; + const sameMoment = { date: new Date(2024, 4, 17, 8, 30) }; + const evening = { date: new Date(2024, 4, 17, 20, 45) }; + const nextDaySameTime = { date: new Date(2024, 4, 18, 8, 30) }; + + // Date and time - the whole timestamp has to match. + expect(strategy.comparer(morning, sameMoment, 'date', true, true)) + .toBeTruthy('date + time, same moment'); + expect(strategy.comparer(morning, evening, 'date', true, true)) + .toBeFalsy('date + time, different time'); + + // Date only - the time part is dropped, so the same day merges regardless of the hour. + expect(strategy.comparer(morning, evening, 'date', true, false)) + .toBeTruthy('date only, same day'); + expect(strategy.comparer(morning, nextDaySameTime, 'date', true, false)) + .toBeFalsy('date only, different day'); + + // Time only - the date part is dropped, so the same hour merges across days. + expect(strategy.comparer(morning, nextDaySameTime, 'date', false, true)) + .toBeTruthy('time only, same time'); + expect(strategy.comparer(morning, evening, 'date', false, true)) + .toBeFalsy('time only, different time'); + + // Values that are not `Date` instances yet are parsed first. + expect(strategy.comparer({ date: '2024-05-17T08:30:00' }, { date: '2024-05-17T20:45:00' }, 'date', true, false)) + .toBeTruthy('parsed date only, same day'); + expect(strategy.comparer({ date: '2024-05-17T08:30:00' }, { date: '2024-05-18T08:30:00' }, 'date', true, false)) + .toBeFalsy('parsed date only, different day'); + }); + + it('tests `merge`', () => { + const strategy = DefaultMergeStrategy.instance(); + const data = [{ name: 'a' }, { name: 'a' }, { name: 'b' }, { name: 'b' }, { name: 'b' }]; + + // The optional arguments are left out on purpose - `merge` has to fall back to its own comparer. + const result = strategy.merge(data, 'name', undefined, [], [], undefined, undefined, gridStub); + + expect(result.length).toBe(5); + expect(result[0].cellMergeMeta.get('name').rowSpan).toBe(2, 'the first group spans two rows'); + expect(result[1].cellMergeMeta.get('name').root).toBe(result[0], 'the second record points at the group root'); + expect(result[2].cellMergeMeta.get('name').rowSpan).toBe(3, 'the second group spans three rows'); + expect(result[0].cellMergeMeta.get('name').childRecords).toEqual([result[1]]); + }); + + it('tests `merge` with an active row breaking the sequence', () => { + const strategy = DefaultMergeStrategy.instance(); + const data = [{ name: 'a' }, { name: 'a' }, { name: 'b' }, { name: 'b' }, { name: 'b' }]; + + const result = strategy.merge(data, 'name', undefined, [], [1], undefined, undefined, gridStub); + + // The active row is added untouched and resets the merging sequence around it. + expect(result[1]).toBe(data[1], 'the active row is kept as-is'); + expect(result[0].cellMergeMeta.get('name').rowSpan).toBe(1, 'the row before the active one no longer merges'); + expect(result[2].cellMergeMeta.get('name').rowSpan).toBe(3, 'the group after it is unaffected'); + }); + + it('tests the tree grid `comparer`', () => { + const strategy = new DefaultTreeGridMergeStrategy(); + const record = (name: any, level = 0) => ({ data: { name }, level }); + + expect(strategy.comparer(record('a'), record('a'), 'name')) + .toBeTruthy('equal values'); + expect(strategy.comparer(record('a'), record('b'), 'name')) + .toBeFalsy('different values'); + // The tree grid strategy reads the values off `data`, but treats missing ones the same way. + expect(strategy.comparer(record(null), record(undefined), 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer(record(null), record('a'), 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer(record('a'), record(null), 'name')) + .toBeFalsy('only the current value nullish'); + // The level is irrelevant here - only the value decides. + expect(strategy.comparer(record('a', 1), record('a', 2), 'name')) + .toBeTruthy('equal values on different levels'); + }); + + it('tests the by-level tree grid `comparer`', () => { + const strategy = new ByLevelTreeGridMergeStrategy(); + const record = (name: any, level = 0) => ({ data: { name }, level }); + + expect(strategy.comparer(record('a', 1), record('a', 1), 'name')) + .toBeTruthy('equal values on the same level'); + // Unlike the plain tree grid strategy, records on different levels never merge. + expect(strategy.comparer(record('a', 1), record('a', 2), 'name')) + .toBeFalsy('equal values on different levels'); + expect(strategy.comparer(record('a', 1), record('b', 1), 'name')) + .toBeFalsy('different values'); + expect(strategy.comparer(record(null, 1), record(undefined, 2), 'name')) + .toBeTruthy('both nullish'); + expect(strategy.comparer(record(null, 1), record('a', 1), 'name')) + .toBeFalsy('only the previous value nullish'); + expect(strategy.comparer(record('a', 1), record(null, 1), 'name')) + .toBeFalsy('only the current value nullish'); + }); +}); diff --git a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts index f4c1948e49f..47452304fe4 100644 --- a/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts +++ b/projects/igniteui-angular/core/src/data-operations/sorting-strategy.spec.ts @@ -1,5 +1,6 @@ import { DataGenerator } from './test-util/data-generator'; -import { DefaultSortingStrategy, SortingDirection } from './sorting-strategy'; +import { DefaultSortingStrategy, FormattedValuesSortingStrategy, GroupMemberCountSortingStrategy, + SortingDirection } from './sorting-strategy'; import { IgxSorting } from './grid-sorting-strategy'; describe('Unit testing SortingStrategy', () => { @@ -66,4 +67,93 @@ describe('Unit testing SortingStrategy', () => { .toEqual([3, 1, 4, 0, 2]); }); + it('tests `compareObjects` of the default strategy', () => { + const strategy = new TestSortingStrategy(); + const resolver = (obj: any, key: string) => obj[key]; + + // 'ROW' sorts before 'row' while the case matters, and equals it once it does not. + expect(strategy.compare({ string: 'ROW' }, { string: 'row' }, 'string', 1, false, resolver)).toBe(-1); + expect(strategy.compare({ string: 'ROW' }, { string: 'row' }, 'string', 1, true, resolver)).toBe(0); + // Values without `toLowerCase` are passed through untouched even when the case is ignored. + expect(strategy.compare({ number: 2 }, { number: 1 }, 'number', 1, true, resolver)).toBe(1); + // A reversed comparison flips the result. + expect(strategy.compare({ number: 2 }, { number: 1 }, 'number', -1, false, resolver)).toBe(-1); + }); + + it('tests `GroupMemberCountSortingStrategy`', () => { + const strategy = GroupMemberCountSortingStrategy.instance(); + const records = [ + { brand: 'Ford' }, { brand: 'BMW' }, { brand: 'Ford' }, + { brand: 'Audi' }, { brand: 'BMW' }, { brand: 'Ford' } + ]; + + expect(strategy).toBe(GroupMemberCountSortingStrategy.instance(), 'the strategy is a singleton'); + + const grouped = strategy.groupBy(records, 'brand'); + expect(Object.keys(grouped).sort()).toEqual(['Audi', 'BMW', 'Ford']); + expect(grouped.Ford.length === 3 && grouped.BMW.length === 2 && grouped.Audi.length === 1) + .toBeTruthy('every record ends up in the group of its field value'); + + // Ascending orders the groups from the smallest to the largest member count, + // with the members of equally sized groups kept in alphabetical order. + expect(strategy.sort([...records], 'brand', SortingDirection.Asc).map(r => r.brand)) + .toEqual(['Audi', 'BMW', 'BMW', 'Ford', 'Ford', 'Ford']); + expect(strategy.sort([...records], 'brand', SortingDirection.Desc).map(r => r.brand)) + .toEqual(['Ford', 'Ford', 'Ford', 'BMW', 'BMW', 'Audi']); + }); + + it('tests `FormattedValuesSortingStrategy`', () => { + const strategy = FormattedValuesSortingStrategy.instance(); + const records = [{ status: 1 }, { status: 3 }, { status: 2 }]; + const resolver = (obj: any, key: string) => obj[key]; + const labels = { 1: 'cancelled', 2: 'Delivered', 3: 'ON HOLD' }; + const gridWithFormatter = { + getColumnByName: () => ({ formatter: (value: number) => labels[value] }) + } as any; + + expect(strategy).toBe(FormattedValuesSortingStrategy.instance(), 'the strategy is a singleton'); + + // Without a grid there is nothing to format, so the raw values decide the order. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver).map(r => r.status)) + .toEqual([1, 2, 3]); + + // With a grid the formatted values decide it: 'Delivered' < 'ON HOLD' < 'cancelled'. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([2, 3, 1]); + // Ignoring the case reorders them again: 'cancelled' < 'delivered' < 'on hold'. + expect(strategy.sort([...records], 'status', SortingDirection.Asc, true, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([1, 2, 3]); + expect(strategy.sort([...records], 'status', SortingDirection.Desc, false, resolver, false, false, gridWithFormatter) + .map(r => r.status)).toEqual([1, 3, 2]); + + // A column without a formatter, or no column at all, falls back to the raw values. + const gridWithoutFormatter = { getColumnByName: () => ({}) } as any; + const gridWithoutColumn = { getColumnByName: () => null } as any; + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithoutFormatter) + .map(r => r.status)).toEqual([1, 2, 3]); + expect(strategy.sort([...records], 'status', SortingDirection.Asc, false, resolver, false, false, gridWithoutColumn) + .map(r => r.status)).toEqual([1, 2, 3]); + }); + }); + +/** + * Exposes the protected `compareObjects` of the default strategy so that it can be tested directly - + * the public `sort` no longer routes through it since it prepares the sort values up front. + */ +class TestSortingStrategy extends DefaultSortingStrategy { + constructor() { + super(); + } + + public compare( + obj1: any, + obj2: any, + key: string, + reverse: number, + ignoreCase: boolean, + valueResolver: (obj: any, key: string) => any + ): number { + return this.compareObjects(obj1, obj2, key, reverse, ignoreCase, valueResolver, false, false); + } +} diff --git a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts index a8188308b1a..e4ae907a401 100644 --- a/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/scroll-inertia/scroll_inertia.directive.spec.ts @@ -310,6 +310,112 @@ describe('Scroll Inertia Directive - Scrolling', () => { }); }); +describe('Scroll Inertia Directive - Child scrolling', () => { + let fix: ComponentFixture; + let directive: IgxTestScrollInertiaDirective; + const elements: HTMLElement[] = []; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + IgxTestScrollInertiaDirective, + ScrollInertiaComponent + ] + }).compileComponents(); + })); + + beforeEach(() => { + fix = TestBed.createComponent(ScrollInertiaComponent); + fix.detectChanges(); + directive = fix.componentInstance.scrInertiaDir; + }); + + afterEach(() => { + elements.forEach(element => element.remove()); + elements.length = 0; + fix = null; + }); + + /** + * Creates a real element in the document - `didChildScroll` reads both the layout box and the + * computed overflow of every element on the event path, so a detached node would not do. + */ + const createElement = (styles: string, innerStyles: string, tagName = 'div') => { + const element = document.createElement(tagName); + element.style.cssText = styles; + const inner = document.createElement('div'); + inner.style.cssText = innerStyles; + element.appendChild(inner); + document.body.appendChild(element); + elements.push(element); + return element; + }; + + const wheelEventOver = (...path: HTMLElement[]) => ({ composedPath: () => path }) as any; + + it('should report a child that can still scroll vertically', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 50px; height: 500px;'); + const evt = wheelEventOver(scrollable); + + // At the top there is room to scroll down, but none to scroll up. + expect(directive.didChildScroll(evt, 0, 10)).toBeTruthy('scrolling down from the top'); + expect(directive.didChildScroll(evt, 0, -10)).toBeFalsy('scrolling up from the top'); + + // At the bottom it is the other way round. + scrollable.scrollTop = scrollable.scrollHeight - scrollable.clientHeight; + expect(directive.didChildScroll(evt, 0, 10)).toBeFalsy('scrolling down from the bottom'); + expect(directive.didChildScroll(evt, 0, -10)).toBeTruthy('scrolling up from the bottom'); + + // A wheel event with no vertical delta never consults the vertical axis. + expect(directive.didChildScroll(evt, 0, 0)).toBeFalsy('no vertical delta'); + }); + + it('should report a child that can still scroll horizontally', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow-x: auto; overflow-y: hidden;', 'width: 500px; height: 50px;'); + const evt = wheelEventOver(scrollable); + + expect(directive.didChildScroll(evt, 10, 0)).toBeTruthy('scrolling right from the start'); + expect(directive.didChildScroll(evt, -10, 0)).toBeFalsy('scrolling left from the start'); + + scrollable.scrollLeft = scrollable.scrollWidth - scrollable.clientWidth; + expect(directive.didChildScroll(evt, 10, 0)).toBeFalsy('scrolling right from the end'); + expect(directive.didChildScroll(evt, -10, 0)).toBeTruthy('scrolling left from the end'); + + expect(directive.didChildScroll(evt, 0, 0)).toBeFalsy('no horizontal delta'); + }); + + it('should ignore children that cannot scroll', () => { + // Overflowing content that is clipped rather than scrolled. + const hidden = createElement( + 'width: 100px; height: 100px; overflow: hidden;', 'width: 500px; height: 500px;'); + expect(directive.didChildScroll(wheelEventOver(hidden), 10, 10)) + .toBeFalsy('overflow is neither auto nor scroll'); + + // Content that fits, so there is no overflow to begin with. + const fits = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 10px; height: 10px;'); + expect(directive.didChildScroll(wheelEventOver(fits), 10, 10)) + .toBeFalsy('nothing overflows'); + + // An empty path has nothing to look at. + expect(directive.didChildScroll(wheelEventOver(), 10, 10)).toBeFalsy('empty path'); + }); + + it('should stop looking once it reaches the display container', () => { + const scrollable = createElement( + 'width: 100px; height: 100px; overflow: auto;', 'width: 500px; height: 500px;'); + const displayContainer = createElement('width: 100px; height: 100px;', '', 'igx-display-container'); + + // Anything below the display container belongs to the virtualized grid itself and is skipped. + expect(directive.didChildScroll(wheelEventOver(displayContainer, scrollable), 0, 10)) + .toBeFalsy('the scrollable ancestor is above the display container'); + expect(directive.didChildScroll(wheelEventOver(scrollable, displayContainer), 0, 10)) + .toBeTruthy('the scrollable child is below it'); + }); +}); + /** igxScroll inertia for testing */ @Directive({ selector: '[igxTestScrollInertia]', @@ -334,6 +440,10 @@ export class IgxTestScrollInertiaDirective extends IgxScrollInertiaDirective { public override _inertiaInit(speedX, speedY) { super._inertiaInit(speedX, speedY); } + + public override didChildScroll(evt, scrollDeltaX, scrollDeltaY) { + return super.didChildScroll(evt, scrollDeltaX, scrollDeltaY); + } } /** igxScroll inertia component */ diff --git a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts new file mode 100644 index 00000000000..b85fd22b596 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts @@ -0,0 +1,186 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; + +import { IgxGridDragSelectDirective } from './drag-select.directive'; + +describe('IgxGridDragSelectDirective', () => { + let fix: ComponentFixture; + let component: DragSelectTestComponent; + let directive: IgxGridDragSelectDirective; + let element: HTMLElement; + let deltas: { left: number; top: number }[]; + let stops: boolean[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [DragSelectTestComponent] + }).compileComponents(); + })); + + /** + * The fixture is created inside the test itself so that the change detection triggered by + * `detectChanges` runs in the same fake async zone as the assertions that follow it. + */ + const setup = () => { + fix = TestBed.createComponent(DragSelectTestComponent); + fix.detectChanges(); + + component = fix.componentInstance; + directive = component.dragSelect; + element = directive.nativeElement; + + deltas = []; + stops = []; + directive.dragScroll.subscribe(delta => deltas.push(delta)); + directive.dragStop.subscribe(state => stops.push(state)); + }; + + /** + * The element is 200x100 and pinned to the top left corner of the viewport, so the client + * coordinates below are also the offsets inside it. The directive treats the outer 15% of + * each side as a scroll zone - x <= 30 / x >= 170 and y <= 15 / y >= 85. + */ + const pointerOver = (x: number, y: number) => { + element.dispatchEvent(new PointerEvent('pointerover', { clientX: x, clientY: y })); + tick(16); + }; + + const lastDelta = () => deltas[deltas.length - 1]; + + it('should emit the matching scroll delta for every edge and corner', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + expect(lastDelta()).toEqual({ left: -1, top: -1 }, 'top left'); + + pointerOver(190, 5); + expect(lastDelta()).toEqual({ left: 1, top: -1 }, 'top right'); + + pointerOver(10, 95); + expect(lastDelta()).toEqual({ left: -1, top: 1 }, 'bottom left'); + + pointerOver(190, 95); + expect(lastDelta()).toEqual({ left: 1, top: 1 }, 'bottom right'); + + pointerOver(100, 5); + expect(lastDelta()).toEqual({ left: 0, top: -1 }, 'top'); + + pointerOver(100, 95); + expect(lastDelta()).toEqual({ left: 0, top: 1 }, 'bottom'); + + pointerOver(10, 50); + expect(lastDelta()).toEqual({ left: -1, top: 0 }, 'left'); + + pointerOver(190, 50); + expect(lastDelta()).toEqual({ left: 1, top: 0 }, 'right'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should keep emitting the same delta while the pointer stays in the same zone', fakeAsync(() => { + setup(); + + pointerOver(10, 50); + const afterFirstFrame = deltas.length; + expect(afterFirstFrame).toBeGreaterThan(0, 'the subscription starts emitting'); + + // Moving inside the same zone must not resubscribe, but the interval keeps running. + pointerOver(20, 60); + expect(deltas.length).toBeGreaterThan(afterFirstFrame, 'the interval is still emitting'); + expect(deltas.every(delta => delta.left === -1 && delta.top === 0)).toBeTruthy('same delta throughout'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should not scroll while the pointer is in the middle of the element', fakeAsync(() => { + setup(); + + pointerOver(100, 50); + + expect(deltas.length).toBe(0, 'no scrolling in the neutral zone'); + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should stop scrolling when the pointer leaves the element', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + const beforeLeave = deltas.length; + expect(beforeLeave).toBeGreaterThan(0); + + element.dispatchEvent(new PointerEvent('pointerleave')); + tick(16); + + expect(stops).toEqual([false], 'dragStop reports the drag as over'); + expect(deltas.length).toBe(beforeLeave, 'no further emissions after leaving'); + + // The direction is reset, so re-entering the very same zone starts scrolling again. + pointerOver(10, 5); + expect(deltas.length).toBeGreaterThan(beforeLeave, 'the same zone is picked up again'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should ignore pointer events while the drag is not active', fakeAsync(() => { + setup(); + + // The directive is set directly rather than through the host binding - it is the very + // same setter the `igxGridDragSelect` input writes to. + directive.activeDrag = false; + + pointerOver(10, 5); + element.dispatchEvent(new PointerEvent('pointerleave')); + tick(16); + + expect(deltas.length).toBe(0, 'no scrolling'); + expect(stops.length).toBe(0, 'no dragStop'); + + fix.destroy(); + discardPeriodicTasks(); + })); + + it('should stop scrolling when the drag is deactivated or the directive is destroyed', fakeAsync(() => { + setup(); + + pointerOver(10, 5); + const whileActive = deltas.length; + expect(whileActive).toBeGreaterThan(0); + + // The directive is set directly rather than through the host binding - it is the very + // same setter the `igxGridDragSelect` input writes to. + directive.activeDrag = false; + tick(16); + expect(deltas.length).toBe(whileActive, 'deactivating the drag unsubscribes'); + + directive.activeDrag = true; + pointerOver(190, 50); + const whileActiveAgain = deltas.length; + expect(whileActiveAgain).toBeGreaterThan(whileActive, 'reactivating it resumes'); + + fix.destroy(); + tick(16); + expect(deltas.length).toBe(whileActiveAgain, 'destroying the directive unsubscribes'); + + // The listeners are detached on destroy, so the element no longer reacts at all. + element.dispatchEvent(new PointerEvent('pointerover', { clientX: 10, clientY: 5 })); + tick(16); + expect(deltas.length).toBe(whileActiveAgain, 'no listeners left on the element'); + discardPeriodicTasks(); + })); +}); + +@Component({ + template: `
`, + imports: [IgxGridDragSelectDirective] +}) +class DragSelectTestComponent { + @ViewChild(IgxGridDragSelectDirective, { static: true }) + public dragSelect: IgxGridDragSelectDirective; + + public activeDrag = true; +} 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 faf2b0d0d2a..a62830324d2 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 @@ -569,6 +569,369 @@ describe('PDF Exporter', () => { }); }); + /** + * `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).exportGridRecordsData(records); + }; + + const pivotOwner = (columns: IColumnInfo[]): IColumnList => ({ + columns, + columnWidths: columns.map(() => 200), + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 1 + }); + + it('should export a pivot grid with a single row dimension', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', London: 100, Paris: 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + }, + { + data: { Product: 'Product B', London: 150, Paris: 250 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product'] + } + ]; + + (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: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Paris', field: 'Paris', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should resolve row dimension values through every fallback', (done) => { + // Only the first dimension has a column of its own. The remaining ones have to be + // resolved straight from the record data - by name, by a fuzzy name match, and finally + // by position among the simple keys of the record. + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', Category: 'Tools', London: 100, Paris: 200 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Product', 'Category', 'Cat', 'Missing'] + } + ]; + + (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: 'London', field: 'London', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Paris', field: 'Paris', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should infer the row dimensions from the record data when there are no dimension keys', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', London: 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + // The row dimension column carries a matching field, so the dimension is picked up from it. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Product A', field: 'Product', skip: false, + headerType: ExportHeaderType.MultiRowHeader, level: 0, startIndex: 0, 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(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should fall back to the simple record keys when no row dimension column matches', (done) => { + const records: IExportRecord[] = [ + { + data: { Product: 'Product A', 'City-London-Sum': 100 }, + level: 0, + type: ExportRecordType.PivotGridRecord + } + ]; + + // Neither the field nor the column group of the row dimension column appears in the + // record data, so the exporter has to guess the dimensions from the simple keys. + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Unmatched', field: 'Unmatched', skip: false, + headerType: ExportHeaderType.PivotMergedHeader, level: 0, startIndex: 0, + columnSpan: 1, columnGroup: 'AlsoUnmatched' + }, + { + 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); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should export a hierarchical grid with child and grandchild islands', (done) => { + // Every record names the island it belongs to, and the exporter keeps one column list per island. + const rootOwner = 'root'; + const childIsland = 'childIsland'; + const grandChildIsland = 'grandChildIsland'; + + const records: IExportRecord[] = [ + { + data: { Id: 1, Name: 'Parent 1' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + }, + { + // Header records carry the header captions as a plain array and are skipped + // rather than drawn as data rows. + data: ['ChildId', 'Title'], + references: [ + { header: 'ChildId', field: 'ChildId', skip: false }, + { header: 'Title', field: 'Title', skip: false } + ] as IColumnInfo[], + level: 1, type: ExportRecordType.HeaderRecord, owner: childIsland + }, + { + data: { ChildId: 11, Title: 'Child A' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland + }, + { + data: { GrandId: 111, Label: 'Grandchild of A' }, + level: 2, type: ExportRecordType.HierarchicalGridRecord, owner: grandChildIsland + }, + { + data: { ChildId: 12, Title: 'Child B' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland + }, + { + // Collapsed rows are not rendered at all. + data: { ChildId: 13, Title: 'Collapsed child' }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: childIsland, hidden: true + }, + { + data: { Id: 2, Name: 'Parent 2' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + } + ]; + + const ownerFor = (fields: string[]): IColumnList => ({ + columns: fields.map((field, index) => ({ + header: field, field, skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: index, columnSpan: 1 + })), + columnWidths: fields.map(() => 200), + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + }); + + (exporter as any)._ownersMap.set(rootOwner, ownerFor(['Id', 'Name'])); + (exporter as any)._ownersMap.set(childIsland, ownerFor(['ChildId', 'Title'])); + (exporter as any)._ownersMap.set(grandChildIsland, ownerFor(['GrandId', 'Label'])); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should skip a child island that has no columns of its own', (done) => { + const rootOwner = 'root'; + const emptyIsland = 'islandWithoutColumns'; + + const records: IExportRecord[] = [ + { + data: { Id: 1, Name: 'Parent 1' }, + level: 0, type: ExportRecordType.HierarchicalGridRecord, owner: rootOwner + }, + { + data: { ChildId: 11 }, + level: 1, type: ExportRecordType.HierarchicalGridRecord, owner: emptyIsland + } + ]; + + (exporter as any)._ownersMap.set(rootOwner, { + columns: [ + { + header: 'Id', field: 'Id', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Name', field: 'Name', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + (exporter as any)._ownersMap.set(emptyIsland, { + columns: [], + columnWidths: [], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should render every shape of summary result', (done) => { + const records: IExportRecord[] = [ + { + data: { Name: 'John', Age: 30 }, + level: 0, + type: ExportRecordType.DataRecord + }, + { + // Both a label and a value - rendered as `label: value`. + data: { Name: { label: 'Count', value: 2 }, Age: { label: 'Avg', value: 27.5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + }, + { + // Only one of the two, and an empty pair that renders as nothing. + data: { Name: { label: 'Count' }, Age: { value: 27.5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + }, + { + data: { Name: { label: '', value: '' }, Age: { summaryResult: 5 } }, + level: 0, + type: ExportRecordType.SummaryRecord + } + ]; + + (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, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + + it('should truncate headers and cell values that do not fit their column', (done) => { + const longText = 'A very long value that has no chance of fitting inside the column it is drawn in'.repeat(4); + const records: IExportRecord[] = [ + { + data: { Description: longText, Note: longText }, + level: 0, + type: ExportRecordType.DataRecord + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, { + columns: [ + { + header: longText, field: 'Description', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: longText, field: 'Note', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + } + ], + columnWidths: [200, 200], + indexOfLastPinnedColumn: -1, + maxLevel: 0, + maxRowLevel: 0 + } as IColumnList); + + exporter.exportEnded.pipe(first()).subscribe((args) => { + expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); + expect(args.pdf).toBeDefined(); + done(); + }); + + exportRecords(records); + }); + }); + describe('Pivot Grid Export', () => { it('should export pivot grid with single dimension', (done) => { const pivotData: IExportRecord[] = [ diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts index 7c2caa4887d..2f5d5b3a189 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid-keyboard-nav.spec.ts @@ -583,4 +583,93 @@ describe('IgxPivotGrid - Keyboard navigation #pivotGrid', () => { expect(IgxGridNavigationService.prototype.headerNavigation).toHaveBeenCalled(); }); }); + + describe('Row header navigation for the vertical row layout', () => { + let fixture: ComponentFixture; + let pivotGrid: IgxPivotGridComponent; + let pivotNav: IgxPivotGridNavigationService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxPivotGridMultipleRowComponent + ], + providers: [ + IgxGridNavigationService + ] + }).compileComponents(); + })); + + beforeEach(async () => { + fixture = TestBed.createComponent(IgxPivotGridMultipleRowComponent); + fixture.detectChanges(); + pivotGrid = fixture.componentInstance.pivotGrid; + pivotNav = pivotGrid.navigation as IgxPivotGridNavigationService; + await fixture.whenStable(); + fixture.detectChanges(); + }); + + /** + * The row header navigation only runs while `isRowHeaderActive` is set, which the grid does + * when a row dimension cell takes focus. The key is dispatched at the service so that the + * navigation is exercised without depending on which cell the browser happens to focus. + */ + const pressKey = async (key: string, ctrlKey = false) => { + pivotNav.isRowHeaderActive = true; + await pivotNav.handleNavigation(new KeyboardEvent('keydown', { key, ctrlKey })); + fixture.detectChanges(); + }; + + it('should move up through the row headers and then out to the row dimension headers', async () => { + pivotNav.activeNode = { row: 2, column: 0 }; + + await pressKey('ArrowUp'); + expect(pivotNav.activeNode.row).toBe(1, 'one row up'); + + await pressKey('ArrowUp', true); + expect(pivotNav.activeNode.row).toBe(0, 'ctrl jumps to the first row'); + + // Moving up from the first row leaves the body and activates the dimension headers. + await pressKey('ArrowUp'); + expect(pivotNav.activeNode.row).toBe(-1, 'the active node leaves the body'); + expect(pivotNav.activeNode.column).toBe(0, 'the column falls back to the first dimension'); + expect(pivotNav.isRowDimensionHeaderActive).toBeTrue(); + expect(pivotNav.isRowHeaderActive).toBeFalse(); + }); + + it('should move down through the row headers', async () => { + pivotNav.activeNode = { row: 0, column: 0 }; + + await pressKey('ArrowDown'); + expect(pivotNav.activeNode.row).toBe(1, 'one row down'); + + await pressKey('ArrowDown', true); + expect(pivotNav.activeNode.row).toBeGreaterThan(1, 'ctrl jumps to the last row'); + // The focus stays in the body - only moving up past the first row leaves it. + expect(pivotNav.isRowDimensionHeaderActive).toBeFalse(); + }); + + it('should move between the row dimensions and remember the row of each one', async () => { + pivotNav.activeNode = { row: 1, column: 0 }; + + await pressKey('ArrowRight'); + expect(pivotNav.activeNode.column).toBe(1, 'one dimension to the right'); + expect(pivotNav.activeNode.mchCache).toEqual({ visibleIndex: 1, level: 0 }); + + await pressKey('ArrowLeft'); + expect(pivotNav.activeNode.column).toBe(0, 'back to the first dimension'); + // The row of the dimension that was left is restored from the cache. + expect(pivotNav.activeNode.row).toBe(1, 'the previous row is restored'); + }); + + it('should ignore keys that are not navigation keys', async () => { + pivotNav.activeNode = { row: 1, column: 0 }; + + await pressKey('a'); + + expect(pivotNav.activeNode.row).toBe(1, 'the active node is untouched'); + expect(pivotNav.activeNode.column).toBe(0); + }); + }); }); From 537d6c17b6a05c6c918f05c78671f9953dde1e9b Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Thu, 17 Sep 2026 08:59:22 +0300 Subject: [PATCH 02/17] Update pdf-exporter.spec.ts for pivot grid export Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../grids/core/src/services/pdf/pdf-exporter.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 a62830324d2..403e34ed162 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 @@ -577,6 +577,7 @@ describe('PDF Exporter', () => { 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); }; From 5590a029a9fbc2b7132522552594eeaf2502e467 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:00:27 +0000 Subject: [PATCH 03/17] test(data-operations): freeze clock in dateTime conditions spec Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- .../filtering-condition.spec.ts | 106 +++++++++--------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts index 756a4b154ba..d8f3bb0c728 100644 --- a/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts +++ b/projects/igniteui-angular/core/src/data-operations/filtering-condition.spec.ts @@ -147,57 +147,63 @@ describe('Unit testing FilteringCondition', () => { }); it('tests dateTime conditions', () => { const fdt = IgxDateTimeFilteringOperand.instance(); - const now = new Date(); - const yesterday = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); - const lastMonth = ((d) => { - d.setDate(1); return new Date(d.setMonth(d.getMonth() - 1)); -})(new Date()); - const nextMonth = ((d) => { - d.setDate(1); return new Date(d.setMonth(d.getMonth() + 1)); -})(new Date()); - const lastYear = ((d) => new Date(d.setFullYear(d.getFullYear() - 1)))(new Date()); - const nextYear = ((d) => new Date(d.setFullYear(d.getFullYear() + 1)))(new Date()); + jasmine.clock().install(); + try { + jasmine.clock().mockDate(new Date(2024, 5, 15)); + const now = new Date(); + const yesterday = ((d) => new Date(d.setDate(d.getDate() - 1)))(new Date()); + const lastMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() - 1)); + })(new Date()); + const nextMonth = ((d) => { + d.setDate(1); return new Date(d.setMonth(d.getMonth() + 1)); + })(new Date()); + const lastYear = ((d) => new Date(d.setFullYear(d.getFullYear() - 1)))(new Date()); + const nextYear = ((d) => new Date(d.setFullYear(d.getFullYear() + 1)))(new Date()); - expect(fdt.condition('before').logic(yesterday, now) && - !fdt.condition('before').logic(now, yesterday) && - !fdt.condition('before').logic(null, now)) - .toBeTruthy('before'); - expect(fdt.condition('after').logic(now, yesterday) && - !fdt.condition('after').logic(yesterday, now) && - !fdt.condition('after').logic(null, now)) - .toBeTruthy('after'); - expect(fdt.condition('today').logic(now) && - !fdt.condition('today').logic(nextYear) && - !fdt.condition('today').logic(null)) - .toBeTruthy('today'); - expect(fdt.condition('yesterday').logic(yesterday) && - !fdt.condition('yesterday').logic(nextYear) && - !fdt.condition('yesterday').logic(null)) - .toBeTruthy('yesterday'); - expect(fdt.condition('thisMonth').logic(now) && - !fdt.condition('thisMonth').logic(nextYear) && - !fdt.condition('thisMonth').logic(null)) - .toBeTruthy('thisMonth'); - expect(fdt.condition('lastMonth').logic(lastMonth) && - !fdt.condition('lastMonth').logic(now) && - !fdt.condition('lastMonth').logic(null)) - .toBeTruthy('lastMonth'); - expect(fdt.condition('nextMonth').logic(nextMonth) && - !fdt.condition('nextMonth').logic(now) && - !fdt.condition('nextMonth').logic(null)) - .toBeTruthy('nextMonth'); - expect(fdt.condition('thisYear').logic(now) && - !fdt.condition('thisYear').logic(nextYear) && - !fdt.condition('thisYear').logic(null)) - .toBeTruthy('thisYear'); - expect(fdt.condition('lastYear').logic(lastYear) && - !fdt.condition('lastYear').logic(now) && - !fdt.condition('lastYear').logic(null)) - .toBeTruthy('lastYear'); - expect(fdt.condition('nextYear').logic(nextYear) && - !fdt.condition('nextYear').logic(now) && - !fdt.condition('nextYear').logic(null)) - .toBeTruthy('nextYear'); + expect(fdt.condition('before').logic(yesterday, now) && + !fdt.condition('before').logic(now, yesterday) && + !fdt.condition('before').logic(null, now)) + .toBeTruthy('before'); + expect(fdt.condition('after').logic(now, yesterday) && + !fdt.condition('after').logic(yesterday, now) && + !fdt.condition('after').logic(null, now)) + .toBeTruthy('after'); + expect(fdt.condition('today').logic(now) && + !fdt.condition('today').logic(nextYear) && + !fdt.condition('today').logic(null)) + .toBeTruthy('today'); + expect(fdt.condition('yesterday').logic(yesterday) && + !fdt.condition('yesterday').logic(nextYear) && + !fdt.condition('yesterday').logic(null)) + .toBeTruthy('yesterday'); + expect(fdt.condition('thisMonth').logic(now) && + !fdt.condition('thisMonth').logic(nextYear) && + !fdt.condition('thisMonth').logic(null)) + .toBeTruthy('thisMonth'); + expect(fdt.condition('lastMonth').logic(lastMonth) && + !fdt.condition('lastMonth').logic(now) && + !fdt.condition('lastMonth').logic(null)) + .toBeTruthy('lastMonth'); + expect(fdt.condition('nextMonth').logic(nextMonth) && + !fdt.condition('nextMonth').logic(now) && + !fdt.condition('nextMonth').logic(null)) + .toBeTruthy('nextMonth'); + expect(fdt.condition('thisYear').logic(now) && + !fdt.condition('thisYear').logic(nextYear) && + !fdt.condition('thisYear').logic(null)) + .toBeTruthy('thisYear'); + expect(fdt.condition('lastYear').logic(lastYear) && + !fdt.condition('lastYear').logic(now) && + !fdt.condition('lastYear').logic(null)) + .toBeTruthy('lastYear'); + expect(fdt.condition('nextYear').logic(nextYear) && + !fdt.condition('nextYear').logic(now) && + !fdt.condition('nextYear').logic(null)) + .toBeTruthy('nextYear'); + } finally { + jasmine.clock().uninstall(); + } }); it('tests dateTime conditions when the current month rolls over a year boundary', () => { const fdt = IgxDateTimeFilteringOperand.instance(); From a996e3ac627bb21dc4429d9b83b065f847bcf269 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:07:12 +0000 Subject: [PATCH 04/17] test(pdf-exporter): isolate row dimension fallback tests Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- .../src/services/pdf/pdf-exporter.spec.ts | 87 +++++++++++++++---- 1 file changed, 70 insertions(+), 17 deletions(-) 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 403e34ed162..37eb139d6ce 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 @@ -4,6 +4,7 @@ import { IgxPdfExporterOptions } from './pdf-exporter-options'; import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; import { first } from 'rxjs/operators'; import { ExportRecordType, ExportHeaderType, DEFAULT_OWNER, IExportRecord, IColumnInfo, IColumnList, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; +import { jsPDF } from 'jspdf'; describe('PDF Exporter', () => { let exporter: IgxPdfExporterService; @@ -629,37 +630,89 @@ describe('PDF Exporter', () => { exportRecords(records); }); - it('should resolve row dimension values through every fallback', (done) => { - // Only the first dimension has a column of its own. The remaining ones have to be - // resolved straight from the record data - by name, by a fuzzy name match, and finally - // by position among the simple keys of the record. + it('should resolve a row dimension value by an exact name match in the record data', (done) => { + // No dimensionKeys and no RowHeader/MultiRowHeader/PivotMergedHeader column, so the + // primary (column-based) lookup can't resolve anything and the value must come from + // an exact key match against the record data. The measure key uses an underscore so it + // is excluded from the simple-key dimension inference and isn't mistaken for a + // dimension itself. const records: IExportRecord[] = [ + { data: { Category: 'Tools', units_sold: 42 }, level: 0, type: ExportRecordType.PivotGridRecord } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ { - data: { Product: 'Product A', Category: 'Tools', London: 100, Paris: 200 }, + header: 'Units Sold', field: 'units_sold', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + } + ])); + + const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(textSpy.calls.allArgs().some(callArgs => callArgs[0] === 'Tools')).toBeTrue(); + done(); + }); + + exportRecords(records); + }); + + it('should resolve a row dimension value by a fuzzy name match in the record data', (done) => { + // The dimension key differs in case from the actual record data key and there is no + // matching row header column, so resolution must fall through to the fuzzy match. + const records: IExportRecord[] = [ + { + data: { Category: 'Tools', units_sold: 42 }, level: 0, type: ExportRecordType.PivotGridRecord, - dimensionKeys: ['Product', 'Category', 'Cat', 'Missing'] + dimensionKeys: ['category'] } ]; (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: 'London', field: 'London', skip: false, + header: 'Units Sold', field: 'units_sold', skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 - }, + } + ])); + + const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + expect(textSpy.calls.allArgs().some(callArgs => callArgs[0] === 'Tools')).toBeTrue(); + done(); + }); + + exportRecords(records); + }); + + it('should resolve row dimension values by position when no name match is found', (done) => { + // Neither dimension key matches the record data by name, exact or fuzzy, and there is + // no row header column, so resolution must fall back to positional matching among the + // simple keys of the record. A filler column, unrelated to the record data, keeps the + // dimension values from also being rendered through the regular data column path. + const records: IExportRecord[] = [ { - header: 'Paris', field: 'Paris', skip: false, - headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 + data: { Category: 'Tools', SecondDimension: 'Alpha' }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['MissingA', 'MissingB'] + } + ]; + + (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ + { + header: 'Filler', field: 'Filler', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 } ])); - exporter.exportEnded.pipe(first()).subscribe((args) => { - expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect(args.pdf).toBeDefined(); + const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); + + exporter.exportEnded.pipe(first()).subscribe(() => { + const renderedTexts = textSpy.calls.allArgs().map(callArgs => callArgs[0]); + expect(renderedTexts).toContain('Tools'); + expect(renderedTexts).toContain('Alpha'); done(); }); From e0b56bf4312263115ac066d0b3c34dda4f186128 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:28:55 +0000 Subject: [PATCH 05/17] fix(grid): stop neutral drag scrolling Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- .../core/src/selection/drag-select.directive.spec.ts | 8 ++++++-- .../grids/core/src/selection/drag-select.directive.ts | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts index b85fd22b596..2ce581d13e1 100644 --- a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts +++ b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.spec.ts @@ -94,12 +94,16 @@ describe('IgxGridDragSelectDirective', () => { discardPeriodicTasks(); })); - it('should not scroll while the pointer is in the middle of the element', fakeAsync(() => { + it('should stop scrolling when the pointer moves from an edge to the middle of the element', fakeAsync(() => { setup(); + pointerOver(10, 50); + const beforeMiddle = deltas.length; + expect(beforeMiddle).toBeGreaterThan(0, 'scrolling starts at the edge'); + pointerOver(100, 50); - expect(deltas.length).toBe(0, 'no scrolling in the neutral zone'); + expect(deltas.length).toBe(beforeMiddle, 'no scrolling in the neutral zone'); fix.destroy(); discardPeriodicTasks(); })); diff --git a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts index 8e799a4fdec..5e50c2f584f 100644 --- a/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts +++ b/projects/igniteui-angular/grids/core/src/selection/drag-select.directive.ts @@ -95,8 +95,13 @@ export class IgxGridDragSelectDirective implements OnInit, OnDestroy { } this.unsubscribe(); - this._sub = this._interval$.subscribe(() => this.dragScroll.emit(delta)); this.lastDirection = direction; + + if (direction === DragScrollDirection.NONE) { + return; + } + + this._sub = this._interval$.subscribe(() => this.dragScroll.emit(delta)); }; protected stopDragSelection = () => { From 441dd2d178243486909ac76aee6bd493dde4d2cc Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Thu, 17 Sep 2026 14:00:38 +0300 Subject: [PATCH 06/17] test(pdf exporter): fixing pdf exporter tests --- .../src/services/pdf/pdf-exporter.spec.ts | 87 +++++++++++++------ 1 file changed, 61 insertions(+), 26 deletions(-) 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 37eb139d6ce..dbfc48ae10a 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 @@ -4,7 +4,24 @@ import { IgxPdfExporterOptions } from './pdf-exporter-options'; import { SampleTestData } from '../../../../../test-utils/sample-test-data.spec'; import { first } from 'rxjs/operators'; import { ExportRecordType, ExportHeaderType, DEFAULT_OWNER, IExportRecord, IColumnInfo, IColumnList, GRID_LEVEL_COL } from '../exporter-common/base-export-service'; -import { jsPDF } from 'jspdf'; +import type { jsPDF } from 'jspdf'; + +/** + * 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. + */ +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')); +}; describe('PDF Exporter', () => { let exporter: IgxPdfExporterService; @@ -631,26 +648,35 @@ describe('PDF Exporter', () => { }); it('should resolve a row dimension value by an exact name match in the record data', (done) => { - // No dimensionKeys and no RowHeader/MultiRowHeader/PivotMergedHeader column, so the - // primary (column-based) lookup can't resolve anything and the value must come from - // an exact key match against the record data. The measure key uses an underscore so it - // is excluded from the simple-key dimension inference and isn't mistaken for a - // dimension itself. + // There is no RowHeader/MultiRowHeader/PivotMergedHeader column, so the primary + // (column-based) lookup can't resolve anything and the dimension value has to be read + // straight out of the record data under the dimension key. The dimension needs a column + // of its own, because `exportRow` rebuilds the record data out of the owner's columns, + // but as an exact match of a dimension key it is kept out of the regular data columns. const records: IExportRecord[] = [ - { data: { Category: 'Tools', units_sold: 42 }, level: 0, type: ExportRecordType.PivotGridRecord } + { + data: { Category: 'Tools', units_sold: 42 }, + level: 0, + type: ExportRecordType.PivotGridRecord, + dimensionKeys: ['Category'] + } ]; (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ { - header: 'Units Sold', field: 'units_sold', skip: false, + header: 'Category', field: 'Category', skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Units Sold', field: 'units_sold', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 } ])); - const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); - - exporter.exportEnded.pipe(first()).subscribe(() => { - expect(textSpy.calls.allArgs().some(callArgs => callArgs[0] === 'Tools')).toBeTrue(); + exporter.exportEnded.pipe(first()).subscribe((args) => { + // The two column headers, then the row: the dimension cell holding the resolved + // value and the single data cell left once the dimension column is taken out. + expect(getRenderedText(args.pdf)).toEqual(['Category', 'Units Sold', 'Tools', '42']); done(); }); @@ -671,15 +697,20 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ { - header: 'Units Sold', field: 'units_sold', skip: false, + header: 'Category', field: 'Category', skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Units Sold', field: 'units_sold', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 } ])); - const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); - - exporter.exportEnded.pipe(first()).subscribe(() => { - expect(textSpy.calls.allArgs().some(callArgs => callArgs[0] === 'Tools')).toBeTrue(); + exporter.exportEnded.pipe(first()).subscribe((args) => { + // Only an exact match of a dimension key keeps a column out of the data cells, so + // the fuzzily matched value is rendered twice - once in the row's dimension cell + // and once in the data cell of the column it was read from. + expect(getRenderedText(args.pdf)).toEqual(['Category', 'Units Sold', 'Tools', 'Tools', '42']); done(); }); @@ -689,8 +720,7 @@ describe('PDF Exporter', () => { it('should resolve row dimension values by position when no name match is found', (done) => { // Neither dimension key matches the record data by name, exact or fuzzy, and there is // no row header column, so resolution must fall back to positional matching among the - // simple keys of the record. A filler column, unrelated to the record data, keeps the - // dimension values from also being rendered through the regular data column path. + // simple keys of the record. const records: IExportRecord[] = [ { data: { Category: 'Tools', SecondDimension: 'Alpha' }, @@ -702,17 +732,22 @@ describe('PDF Exporter', () => { (exporter as any)._ownersMap.set(DEFAULT_OWNER, pivotOwner([ { - header: 'Filler', field: 'Filler', skip: false, + header: 'Category', field: 'Category', skip: false, headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 0, columnSpan: 1 + }, + { + header: 'Second Dimension', field: 'SecondDimension', skip: false, + headerType: ExportHeaderType.ColumnHeader, level: 0, startIndex: 1, columnSpan: 1 } ])); - const textSpy = spyOn(jsPDF.prototype, 'text').and.callThrough(); - - exporter.exportEnded.pipe(first()).subscribe(() => { - const renderedTexts = textSpy.calls.allArgs().map(callArgs => callArgs[0]); - expect(renderedTexts).toContain('Tools'); - expect(renderedTexts).toContain('Alpha'); + exporter.exportEnded.pipe(first()).subscribe((args) => { + const renderedText = getRenderedText(args.pdf); + // The two dimension cells take the record's simple keys in order, and the same + // values show up again in the data cells of the columns they were read from. + expect(renderedText).toEqual([ + 'Category', 'Second Dimension', 'Tools', 'Alpha', 'Tools', 'Alpha' + ]); done(); }); From ace7a7c7c08a73ec3e6dc11b4c5e5c7d039dcd73 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Thu, 17 Sep 2026 16:31:15 +0300 Subject: [PATCH 07/17] test(pdf exporter): adding more thorough checks fix a stack overflow with groups --- .../src/services/pdf/pdf-exporter.spec.ts | 1406 ++++++++++++++--- .../core/src/services/pdf/pdf-exporter.ts | 20 +- 2 files changed, 1205 insertions(+), 221 deletions(-) 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..32351b43d71 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,23 +6,196 @@ 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'; +/** A single `text()` call, as recovered from the content stream of the page it was drawn on. */ +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. */ +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; +} + /** - * 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. + * `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 getRenderedText = (pdf: jsPDF | undefined): string[] => { +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'] +]; + +/** 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'; + +/** The page dimensions jsPDF produces for the page sizes and orientations the exporter offers. */ +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. */ +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. + */ +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 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 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. */ +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. + */ +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. */ +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. */ +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]), + // `rect()` is given the top edge, which jsPDF turns into the bottom one - flip it + // back so that it matches the `y` of the cells drawn inside the rectangle. + y: pageHeight - parseFloat(match[2]) - height, + 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. */ +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. */ +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`. */ +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 actually drawn with. */ +const getUsedFontRefs = (pdf: jsPDF | undefined): Set => + new Set(getRenderedCells(pdf).map(cell => cell.font)); + describe('PDF Exporter', () => { let exporter: IgxPdfExporterService; let options: IgxPdfExporterOptions; @@ -38,13 +211,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 +254,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 +268,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 +297,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 +310,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 +323,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 +365,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 +382,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 +404,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 +420,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 +431,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 +445,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 +468,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 +479,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 +506,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 +532,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 +555,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 +572,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 +591,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 +602,22 @@ 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((exporter as any)._currentFontName).toBe('helvetica'); + expect((exporter as any)._currentBoldFontName).toBe('helvetica'); + expect(pdf!.getFontList().TestFont).toBeUndefined(); + 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 +632,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 +646,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 +660,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 +676,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 +692,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(); }); @@ -359,8 +708,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 +725,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 +735,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 +748,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 +779,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 +794,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 +809,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 +822,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 +838,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,14 +854,22 @@ 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); }); + /* + * The bold variant only comes into play once the base font configuration is accepted, so + * with an empty name and data these three exercise the rejection path, whatever shape the + * variant has. Covering the variant itself would take a real, parseable TTF: jsPDF reads + * the font when it is registered and, in a browser, throws out of the first call + * that uses an unparseable one - outside the try/catch the exporter wraps the registration + * in, which takes the whole export down with it. + */ it('should handle customFont with bold variant set to null', (done) => { options.customFont = { name: '', @@ -512,7 +879,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).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -528,7 +896,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).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -544,7 +913,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).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); done(); }); @@ -554,7 +924,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 +934,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 +944,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 +955,10 @@ describe('PDF Exporter', () => { 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), @@ -623,11 +983,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 +1005,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 +1148,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 +1185,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 +1259,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 +1326,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 +1383,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 +1428,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 +1475,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 +1547,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 +1643,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,12 +1665,22 @@ 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. + expect(getRenderedRows(args.pdf)).toEqual([ + ['London', 'Paris'], + ['Product', 'Category'], + ['Sum', 'Sum'], + ['Product A', '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) => { @@ -1290,6 +1751,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 +1773,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 +1837,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 +1897,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 +1988,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', 'Category', 'Sum'], + ['Product A', '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 +2079,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', 'Category', 'Sum'], + ['Product A', 'Category 1', '100'], + ['Product A', 'Category 2', '150'] + ]); done(); }); - exporter.exportData(pivotData, options); + exportRecords(pivotData); }); }); @@ -1687,12 +2183,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 +2293,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 +2321,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.MultiColumnHeader, startIndex: 0, level: 0, - columnSpan: 2 + columnSpan: 2, + columnGroup: 'Location' }, { header: 'City', @@ -1817,7 +2331,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.ColumnHeader, startIndex: 0, level: 1, - columnSpan: 1 + columnSpan: 1, + columnGroupParent: 'Location' }, { header: 'Country', @@ -1826,7 +2341,8 @@ describe('PDF Exporter', () => { headerType: ExportHeaderType.ColumnHeader, startIndex: 1, level: 1, - columnSpan: 1 + columnSpan: 1, + columnGroupParent: 'Location' } ]; @@ -1875,12 +2391,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 +2462,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 +2520,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 +2571,26 @@ 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(); }); - exporter.exportData(summaryData, options); + exportRecords(summaryData); }); }); + /** + * 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 +2612,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 +2636,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 +2688,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 +2718,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 +2780,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 +2811,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 +2844,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 +2912,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 +2929,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 +2992,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 +3065,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 +3120,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) => { @@ -2531,12 +3185,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 simple keys inference', (done) => { @@ -2571,12 +3229,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 +3297,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 +3359,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 +3414,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,12 +3467,17 @@ 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) => { @@ -2809,12 +3494,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 +3543,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 +3576,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 +3609,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 +3649,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 +3703,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 +3754,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 +3806,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 +3849,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 +3908,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 +3969,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 +4021,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 +4097,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 +4142,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 +4180,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 +4246,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 +4299,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 +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 PivotMergedHeader with empty header text', (done) => { @@ -3580,12 +4395,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 +4439,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(); }); - exporter.exportData(data, options); + 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(); + }); + + exportRecords(data); }); it('should handle data with zero total columns', (done) => { @@ -3646,12 +4610,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); + // 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(); }); - exporter.exportData(data, options); + exportRecords(data); }); }); }); 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..04f43ac7993 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 @@ -579,16 +579,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); From 1556331f338ed5b23301fe948da40e447d27aad2 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Thu, 17 Sep 2026 22:32:42 +0300 Subject: [PATCH 08/17] feat(pdf exporter): making passing bad font not silently fail --- .../services/pdf/pdf-exporter-grid.spec.ts | 776 +++++++- .../services/pdf/pdf-exporter-utils.spec.ts | 245 +++ .../src/services/pdf/pdf-exporter.spec.ts | 1608 +++++++++++++++-- .../core/src/services/pdf/pdf-exporter.ts | 111 +- 4 files changed, 2444 insertions(+), 296 deletions(-) create mode 100644 projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-utils.spec.ts 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..563c3c6f175 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,19 @@ 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, and the + // seven records follow. + expect(rows[0]).toEqual(SELLER_COLUMNS); + expect(rows[1].length).toBe(SELLER_COLUMNS.length * 2); + expect(rows.slice(2).length).toBe(7); done(); }); @@ -514,8 +1173,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 +1206,16 @@ 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. + expect(rows[0]).toEqual(SELLER_COLUMNS); + expect(rows[1].length).toBe(3); + expect(rows.slice(3).length).toBe(7); + done(); }); exporter.export(pivotGrid, options); @@ -551,8 +1224,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 +1237,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 +1250,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 +1264,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 +1280,38 @@ 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, and each record carries its own pair + // of them: the totals across all cities first, then each city with the + // products it sold. Every value comes from the record itself, so a grid with + // more records than row header columns still labels each row correctly. + expect(rows.slice(2).map(row => row.slice(0, 2))).toEqual([ + ['All Cities', 'AllProducts'], + ['All Cities', 'Clothing'], + ['All Cities', 'Bikes'], + ['All Cities', 'Accessori...'], + ['All Cities', 'Compone...'], + ['Plovdiv', 'AllProducts'], + ['Plovdiv', 'Clothing'], + ['New York', 'AllProducts'], + ['New York', 'Clothing'], + ['Ciudad d...', 'AllProducts'], + ['Ciudad d...', 'Bikes'], + ['Ciudad d...', 'Clothing'], + ['London', 'AllProducts'], + ['London', 'Accessori...'], + ['Yokohama', 'AllProducts'], + ['Yokohama', 'Compone...'], + ['Sofia', 'AllProducts'], + ['Sofia', '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..8aa671c43c4 --- /dev/null +++ b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter-utils.spec.ts @@ -0,0 +1,245 @@ +/* + * 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; +} + +/** + * `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. + +/** 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. Header cells are the only ones the exporter both fills + * and strokes, so the filled rectangles are exactly the header grid, and the one holding the + * caption is the cell that caption heads. 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 32351b43d71..c4b66d9b329 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,32 +6,10 @@ 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'; -/** A single `text()` call, as recovered from the content stream of the page it was drawn on. */ -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. */ -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; -} +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 @@ -47,154 +25,26 @@ const CONTACTS_ROWS = [ ['Dorothy H. Spencer', '573-394-9254'] ]; -/** 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'; - -/** The page dimensions jsPDF produces for the page sizes and orientations the exporter offers. */ -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. */ -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. - */ -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. */ -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. + * 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 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. */ -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. */ -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]), - // `rect()` is given the top edge, which jsPDF turns into the bottom one - flip it - // back so that it matches the `y` of the cells drawn inside the rectangle. - y: pageHeight - parseFloat(match[2]) - height, - 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. */ -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 -}); +const MINIMAL_TTF = + 'AAEAAAAKAIAAAwAgT1MvMlq2XmgAAACsAAAAamNtYXAADACxAAABGAAAACxnbHlmAAAAAAAAAUQAAAAAaGVhZGL/Qz0AAAFEAAAA' + + 'NmhoZWEHCgEvAAABfAAAACRobXR4A+gAAAAAAaAAAAAIbG9jYQAAAAAAAAGoAAAABm1heHAAAwACAAABsAAAACBuYW1lCj8icQAA' + + 'AdAAAACscG9zdAADAAAAAAJ8AAAAIAAEAfQBkAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + 'AQAAAAAAAAAAAAAAAFRFU1QAQAAgAH4DIP84AMgDIADIAAAAAQAAAAAB9AD6AyAAAAAAAAEAAAAAAAEAAwABAAAADAAEACAAAAAE' + + 'AAQAAQAAAH7//wAAACD////hAAEAAAAAAAEAAAABAAD/pxBFXw889QADA+gAAAAAAAAAAAAAAAAAAAAAAAD/OAPoAyAAAAAIAAIA' + + 'AAAAAAAAAQAAAyD/OAAAAfQAAAAAA+gAAQAAAAAAAAAAAAAAAAAAAAIB9AAAAfQAAAAAAAAAAAAAAAEAAAACAAAAAAAAAAAAAgAA' + + 'AAAAAAAAAAAAAAAAAAAAAAAGAE4AAwABBAkAAQAQAAAAAwABBAkAAgAOABAAAwABBAkAAwAQAB4AAwABBAkABAAQAC4AAwABBAkA' + + 'BQAQAD4AAwABBAkABgAQAE4ATQBpAG4AaQBUAGUAcwB0AFIAZQBnAHUAbABhAHIATQBpAG4AaQBUAGUAcwB0AE0AaQBuAGkAVABl' + + 'AHMAdABNAGkAbgBpAFQAZQBzAHQATQBpAG4AaQBUAGUAcwB0AAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; -/** The number of pages the exporter ended up producing. */ -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`. */ -const getFontRef = (pdf: jsPDF | undefined, name: string, style: string): string => - (pdf as any)?.internal.getFont(name, style).id; +/** 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'; -/** The distinct fonts the text in the document was actually drawn with. */ -const getUsedFontRefs = (pdf: jsPDF | undefined): Set => - new Set(getRenderedCells(pdf).map(cell => cell.font)); describe('PDF Exporter', () => { let exporter: IgxPdfExporterService; @@ -610,7 +460,6 @@ describe('PDF Exporter', () => { const expectHelveticaFallback = (pdf: jsPDF | undefined) => { expect((exporter as any)._currentFontName).toBe('helvetica'); expect((exporter as any)._currentBoldFontName).toBe('helvetica'); - expect(pdf!.getFontList().TestFont).toBeUndefined(); expect(getUsedFontRefs(pdf)).toEqual(new Set([ getFontRef(pdf, 'helvetica', 'normal'), getFontRef(pdf, 'helvetica', 'bold') @@ -694,6 +543,9 @@ describe('PDF Exporter', () => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); 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(); }); @@ -862,25 +714,143 @@ describe('PDF Exporter', () => { 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((exporter as any)._currentFontName).toBe('TestFont'); + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + 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((exporter as any)._currentFontName).toBe('TestFont'); + expect((exporter as any)._currentBoldFontName).toBe('TestFontBold'); + 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 only comes into play once the base font configuration is accepted, so - * with an empty name and data these three exercise the rejection path, whatever shape the - * variant has. Covering the variant itself would take a real, parseable TTF: jsPDF reads - * the font when it is registered and, in a browser, throws out of the first call - * that uses an unparseable one - outside the try/catch the exporter wraps the registration - * in, which takes the whole export down with it. + * 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); - expectHelveticaFallback(args.pdf); - expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -889,15 +859,15 @@ 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); - expectHelveticaFallback(args.pdf); - expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); done(); }); @@ -906,15 +876,37 @@ 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); - expectHelveticaFallback(args.pdf); - expect(console.warn).toHaveBeenCalledWith(INCOMPLETE_FONT_WARNING); + // A variant without a name or data is treated as no variant at all. + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + 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((exporter as any)._currentBoldFontName).toBe('TestFont'); + expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); + expect(args.pdf!.getFontList().TestFontBold).toBeUndefined(); + expect(console.warn).not.toHaveBeenCalled(); done(); }); @@ -956,6 +948,40 @@ describe('PDF Exporter', () => { exporter.exportData(SampleTestData.contactsData(), options); }); + it('should keep a loaded custom font on a later export that configures none', (done) => { + let exportCallCount = 0; + + options.customFont = { name: 'TestFont', data: MINIMAL_TTF }; + + const subscription = exporter.exportEnded.subscribe((args) => { + exportCallCount++; + + if (exportCallCount === 1) { + expect((exporter as any)._currentFontName).toBe('TestFont'); + + options.customFont = undefined as any; + exporter.exportData(SampleTestData.contactsData(), options); + return; + } + + // A custom font that loaded is never cleared. The exporter only resets the font + // when it is handed a configuration it rejects, and leaves the previous one in + // place when there is no configuration at all - so the second document is still + // set in `TestFont`, but no longer carries it, and its text points at a font the + // reader cannot resolve. + expect((exporter as any)._currentFontName).toBe('TestFont'); + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + expect(args.pdf!.getFontList().TestFont).toBeUndefined(); + expect(getUsedFontRefs(args.pdf)).not.toEqual(new Set([ + getFontRef(args.pdf, 'helvetica', 'normal'), + getFontRef(args.pdf, 'helvetica', 'bold') + ])); + subscription.unsubscribe(); + done(); + }); + + exporter.exportData(SampleTestData.contactsData(), options); + }); }); describe('Export record types', () => { @@ -967,6 +993,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[] = [ { @@ -3147,29 +3214,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 } @@ -3187,9 +3256,12 @@ describe('PDF Exporter', () => { 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([ - ['Sum'], - ['100'] + ['Category', 'Sum'], + ['Category 1', '100'] ]); done(); }); @@ -4622,4 +4694,1142 @@ describe('PDF Exporter', () => { 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 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 document + // holds one shaded header cell for the dimension and one for the data column - + // and not a third, blank one between them. + const headerBackgrounds = getDrawnRectangles(args.pdf).filter(rectangle => rectangle.filled); + 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 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(); + }); + + 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 04f43ac7993..196c688a7aa 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 @@ -245,10 +245,19 @@ export class IgxPdfExporterService extends IgxBaseExporter { pdf.addFont(fontFileName, font.name, 'bold'); this._currentBoldFontName = 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, this._currentFontName, 'normal'); + this.verifyFont(pdf, this._currentBoldFontName, 'bold'); } catch (error) { console.warn(`Failed to load custom font '${font.name}', falling back to helvetica:`, error); this._currentFontName = 'helvetica'; this._currentBoldFontName = 'helvetica'; + pdf.setFont(this._currentFontName, 'normal'); } } else if (options.customFont) { console.warn('Custom font configuration is incomplete (missing name or data), falling back to helvetica'); @@ -292,8 +301,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth, headerHeight, usableWidth, - options, - allColumns + options ); } else { // Draw simple single-level headers @@ -358,8 +366,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth, headerHeight, usableWidth, - options, - allColumns + options ); } else { this.drawTableHeaders(pdf, leafColumns, rowDimensionHeaders, margin, yPosition, columnWidth, headerHeight, usableWidth, options); @@ -453,17 +460,16 @@ export class IgxPdfExporterService extends IgxBaseExporter { baseColumnWidth: number, headerHeight: number, _tableWidth: number, - options: IgxPdfExporterOptions, - allColumns?: any[] + options: IgxPdfExporterOptions ): number { let yPosition = yStart; pdf.setFont(this._currentBoldFontName, '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,11 +486,9 @@ 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; } @@ -526,36 +530,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 @@ -676,7 +650,7 @@ 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; @@ -927,13 +901,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; @@ -988,9 +957,19 @@ export class IgxPdfExporterService extends IgxBaseExporter { const xPosition = xStart + (index * columnWidth); let cellValue: any = null; - // Primary approach: Get the value from row dimension columns' header property + // 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[index]; + 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 (isPivotGrid && allColumns) { + if (cellValue === null && isPivotGrid && allColumns) { // Get all row dimension columns sorted by level and startIndex const allRowDimCols = allColumns.filter(col => (col.headerType === ExportHeaderType.RowHeader || @@ -1035,8 +1014,9 @@ export class IgxPdfExporterService extends IgxBaseExporter { } } - // 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 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 (!matchedCol && recordIndex !== undefined) { // For hierarchical dimensions with row spans, we need to account for that // For now, use a simple index-based approach @@ -1044,11 +1024,6 @@ export class IgxPdfExporterService extends IgxBaseExporter { 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') { @@ -1134,13 +1109,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { const rowDimensionOffset = maxRowDimCols * 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]; @@ -1199,6 +1169,17 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); } + /** + * 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); From 99fbfe4307d88a44610a6b0caabb9b14eb37cd7a Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 07:53:50 +0300 Subject: [PATCH 09/17] Fix blank cell handling in PDF exporter tests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../grids/core/src/services/pdf/pdf-exporter-utils.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8aa671c43c4..513dddde84c 100644 --- 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 @@ -39,7 +39,7 @@ export interface IDrawnRectangle { * `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. - + */ /** 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 }, From 3bd1a785dc660c6d405e931915dbe5d6221e233e Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 08:14:07 +0300 Subject: [PATCH 10/17] fix(exporter): do not preserve a loaded custom font on later export --- .../src/services/pdf/pdf-exporter.spec.ts | 21 ++++++++----------- .../core/src/services/pdf/pdf-exporter.ts | 9 ++++++-- 2 files changed, 16 insertions(+), 14 deletions(-) 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 c4b66d9b329..89ea8fc9324 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 @@ -948,7 +948,7 @@ describe('PDF Exporter', () => { exporter.exportData(SampleTestData.contactsData(), options); }); - it('should keep a loaded custom font on a later export that configures none', (done) => { + 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 }; @@ -958,24 +958,21 @@ describe('PDF Exporter', () => { if (exportCallCount === 1) { expect((exporter as any)._currentFontName).toBe('TestFont'); + expect((exporter as any)._currentBoldFontName).toBe('TestFont'); options.customFont = undefined as any; exporter.exportData(SampleTestData.contactsData(), options); return; } - // A custom font that loaded is never cleared. The exporter only resets the font - // when it is handed a configuration it rejects, and leaves the previous one in - // place when there is no configuration at all - so the second document is still - // set in `TestFont`, but no longer carries it, and its text points at a font the - // reader cannot resolve. - expect((exporter as any)._currentFontName).toBe('TestFont'); - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); + // 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(); - expect(getUsedFontRefs(args.pdf)).not.toEqual(new Set([ - getFontRef(args.pdf, 'helvetica', 'normal'), - getFontRef(args.pdf, 'helvetica', 'bold') - ])); subscription.unsubscribe(); done(); }); 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 196c688a7aa..924c2fe7193 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 @@ -226,6 +226,13 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); const font = options.customFont; + // The service is provided in root, so a font loaded for one export outlives the document + // it was registered on. Every export starts back on helvetica and only moves off it for a + // font it registers on its own document - carrying a name over would set a later document + // in a font it does not carry, leaving its text pointing at nothing the reader can resolve. + this._currentFontName = 'helvetica'; + this._currentBoldFontName = 'helvetica'; + // Add custom Unicode font if provided if (typeof font?.name === 'string' && font.name.trim() && typeof font?.data === 'string' && font.data.trim()) { try { @@ -261,8 +268,6 @@ export class IgxPdfExporterService extends IgxBaseExporter { } } 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(); From 489231c4f3fcf15a2733b32bf42a0e40558af938 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 08:48:58 +0300 Subject: [PATCH 11/17] Clean up comments in pdf-exporter-utils.spec.ts Removed unnecessary comments regarding sample test data and jsPDF behavior. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../grids/core/src/services/pdf/pdf-exporter-utils.spec.ts | 5 ----- 1 file changed, 5 deletions(-) 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 index 513dddde84c..e7143aec11e 100644 --- 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 @@ -35,11 +35,6 @@ export interface IDrawnRectangle { filled: boolean; } -/** - * `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. - */ /** 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 }, From e02df269c113ff2375c23e7385b8e2ac2dbda6f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:50:20 +0000 Subject: [PATCH 12/17] fix(exporter): isolate PDF font selections Co-authored-by: kdinev <1472513+kdinev@users.noreply.github.com> --- .../src/services/pdf/pdf-exporter.spec.ts | 13 ---- .../core/src/services/pdf/pdf-exporter.ts | 75 ++++++++++--------- 2 files changed, 40 insertions(+), 48 deletions(-) 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 89ea8fc9324..d9d774a9448 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 @@ -458,8 +458,6 @@ describe('PDF Exporter', () => { * drawn exactly as it would have been without a custom font at all. */ const expectHelveticaFallback = (pdf: jsPDF | undefined) => { - expect((exporter as any)._currentFontName).toBe('helvetica'); - expect((exporter as any)._currentBoldFontName).toBe('helvetica'); expect(getUsedFontRefs(pdf)).toEqual(new Set([ getFontRef(pdf, 'helvetica', 'normal'), getFontRef(pdf, 'helvetica', 'bold') @@ -765,8 +763,6 @@ describe('PDF Exporter', () => { expect(console.warn).not.toHaveBeenCalled(); // No bold variant was given, so the regular font is registered for both styles. - expect((exporter as any)._currentFontName).toBe('TestFont'); - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); // Nothing is left in helvetica: the header row uses the bold registration of the @@ -797,8 +793,6 @@ describe('PDF Exporter', () => { expect(console.warn).not.toHaveBeenCalled(); // Each font is registered for the one style it was given for. - expect((exporter as any)._currentFontName).toBe('TestFont'); - expect((exporter as any)._currentBoldFontName).toBe('TestFontBold'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal']); expect(args.pdf!.getFontList().TestFontBold).toEqual(['bold']); @@ -848,7 +842,6 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); expect(console.warn).not.toHaveBeenCalled(); done(); @@ -866,7 +859,6 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); done(); }); @@ -884,7 +876,6 @@ describe('PDF Exporter', () => { exporter.exportEnded.pipe(first()).subscribe((args) => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); // A variant without a name or data is treated as no variant at all. - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); done(); }); @@ -903,7 +894,6 @@ describe('PDF Exporter', () => { 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((exporter as any)._currentBoldFontName).toBe('TestFont'); expect(args.pdf!.getFontList().TestFont).toEqual(['normal', 'bold']); expect(args.pdf!.getFontList().TestFontBold).toBeUndefined(); expect(console.warn).not.toHaveBeenCalled(); @@ -957,9 +947,6 @@ describe('PDF Exporter', () => { exportCallCount++; if (exportCallCount === 1) { - expect((exporter as any)._currentFontName).toBe('TestFont'); - expect((exporter as any)._currentBoldFontName).toBe('TestFont'); - options.customFont = undefined as any; exporter.exportData(SampleTestData.contactsData(), options); return; 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 924c2fe7193..f1e31c848f4 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,11 @@ export interface IPdfExportEndedEventArgs extends IBaseEventArgs { pdf?: jsPDF; } +interface IPdfFontNames { + normal: string; + bold: string; +} + /** * **Ignite UI for Angular PDF Exporter Service** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/exporter_pdf.html) @@ -47,9 +52,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; @@ -226,12 +228,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); const font = options.customFont; - // The service is provided in root, so a font loaded for one export outlives the document - // it was registered on. Every export starts back on helvetica and only moves off it for a - // font it registers on its own document - carrying a name over would set a later document - // in a font it does not carry, leaving its text pointing at nothing the reader can resolve. - this._currentFontName = 'helvetica'; - this._currentBoldFontName = 'helvetica'; + 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()) { @@ -239,18 +236,18 @@ export class IgxPdfExporterService extends IgxBaseExporter { 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 @@ -258,13 +255,13 @@ export class IgxPdfExporterService extends IgxBaseExporter { // 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, this._currentFontName, 'normal'); - this.verifyFont(pdf, this._currentBoldFontName, 'bold'); + 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'; - pdf.setFont(this._currentFontName, 'normal'); + 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'); @@ -306,16 +303,17 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth, headerHeight, usableWidth, - options + options, + 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); @@ -371,10 +369,11 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth, headerHeight, usableWidth, - options + options, + 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; } } @@ -436,7 +435,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { pageHeight, headerHeight, rowHeight, - options + options, + fontNames ); } @@ -465,10 +465,11 @@ export class IgxPdfExporterService extends IgxBaseExporter { baseColumnWidth: number, headerHeight: number, _tableWidth: number, - options: IgxPdfExporterOptions + options: IgxPdfExporterOptions, + 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 @@ -662,7 +663,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { yPosition = yStart + totalHeaderHeight; } - pdf.setFont(this._currentFontName, 'normal'); + pdf.setFont(fontNames.normal, 'normal'); return yPosition; } @@ -679,7 +680,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); @@ -758,10 +760,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; } @@ -781,10 +784,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; } } @@ -846,7 +849,8 @@ export class IgxPdfExporterService extends IgxBaseExporter { pageHeight, headerHeight, rowHeight, - options + options, + fontNames ); } } @@ -869,9 +873,10 @@ export class IgxPdfExporterService extends IgxBaseExporter { columnWidth: number, headerHeight: number, tableWidth: number, - options: IgxPdfExporterOptions + options: IgxPdfExporterOptions, + fontNames: IPdfFontNames ): void { - pdf.setFont(this._currentBoldFontName, 'bold'); + pdf.setFont(fontNames.bold, 'bold'); pdf.setFillColor(240, 240, 240); if (options.showTableBorders) { @@ -932,7 +937,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { pdf.text(headerText, textX, textY); }); - pdf.setFont(this._currentFontName, 'normal'); + pdf.setFont(fontNames.normal, 'normal'); } private drawDataRow( From 01b6f786661b89a650acd64aa3426a266b3fc70f Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 09:56:56 +0300 Subject: [PATCH 13/17] fix(pdf exporter): addressing copilot's review comment --- .../src/services/pdf/pdf-exporter.spec.ts | 39 +++++++++++++++++++ .../core/src/services/pdf/pdf-exporter.ts | 6 ++- 2 files changed, 43 insertions(+), 2 deletions(-) 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 d9d774a9448..b14870de903 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 @@ -3536,6 +3536,45 @@ describe('PDF Exporter', () => { exportRecords(data); }); + 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[] = [ { 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 f1e31c848f4..29816fc288d 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 @@ -204,8 +204,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({ From a13d90b466782f8b2aa6c97bb7cd49b6ccacb9a7 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 10:20:22 +0300 Subject: [PATCH 14/17] feat(pdf export): exported summaries now have header background --- .../services/pdf/pdf-exporter-utils.spec.ts | 8 +- .../src/services/pdf/pdf-exporter.spec.ts | 83 +++++++++++++++++++ .../core/src/services/pdf/pdf-exporter.ts | 47 +++++++++-- 3 files changed, 125 insertions(+), 13 deletions(-) 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 index e7143aec11e..03d54ecbb35 100644 --- 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 @@ -215,10 +215,10 @@ export const getTextDrawCount = (pdf: jsPDF | undefined): number => { }; /** - * The cell a header caption was drawn in. Header cells are the only ones the exporter both fills - * and strokes, so the filled rectangles are exactly the header grid, and the one holding the - * caption is the cell that caption heads. Use it to check that a column group is drawn over the - * columns it spans rather than merely on the right level. + * 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); 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 b14870de903..8ad2b9a4333 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 @@ -2633,6 +2633,89 @@ describe('PDF Exporter', () => { 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(); + }); + + exportRecords(recordAndSummary()); + }); }); /** 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 29816fc288d..d0de05b6b33 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 @@ -501,7 +501,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { } // 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); @@ -628,7 +628,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); @@ -879,7 +879,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { fontNames: IPdfFontNames ): void { pdf.setFont(fontNames.bold, 'bold'); - pdf.setFillColor(240, 240, 240); + this.setShadedFill(pdf); if (options.showTableBorders) { pdf.rect(xStart, yPosition, tableWidth, headerHeight, 'F'); @@ -1099,9 +1099,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); } // Truncate text if it's too long @@ -1157,9 +1155,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 @@ -1181,6 +1177,39 @@ export class IgxPdfExporterService extends IgxBaseExporter { }); } + /** + * Selects the shade a header cell is filled with. Summary rows take the same one - a summary + * closes the rows above it the way the header opens them, so it reads as a footer rather than + * as one more record. + */ + 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 belongs to a summary row. + */ + 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 From 1265d843adf78b9fd8966c5446fbba3d72491f79 Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 10:56:37 +0300 Subject: [PATCH 15/17] feat(pdf export): merging pivot row dimension headers in export --- .../services/pdf/pdf-exporter-grid.spec.ts | 71 +-- .../src/services/pdf/pdf-exporter.spec.ts | 278 ++++++++++- .../core/src/services/pdf/pdf-exporter.ts | 439 +++++++++++------- 3 files changed, 576 insertions(+), 212 deletions(-) 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 563c3c6f175..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 @@ -1159,11 +1159,24 @@ describe('PDF Grid Exporter', () => { expect(ExportUtilities.saveBlobToFile).toHaveBeenCalledTimes(1); const rows = getRenderedRows(args.pdf); - // The sellers head the table, each over its own pair of aggregations, and the - // seven records follow. + // 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); - expect(rows.slice(2).length).toBe(7); + + // 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(); }); @@ -1211,10 +1224,13 @@ describe('PDF Grid Exporter', () => { 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. + // 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).length).toBe(7); + 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(); }); @@ -1288,29 +1304,28 @@ describe('PDF Grid Exporter', () => { expect(rows[0]).toEqual(['Bulgaria', 'US', 'Uruguay', 'UK', 'Japan']); expect(rows[1]).toEqual(rows[0].flatMap(() => ['UnitsSold', 'Amount ...'])); - // Two row dimensions, city over product, and each record carries its own pair - // of them: the totals across all cities first, then each city with the - // products it sold. Every value comes from the record itself, so a grid with - // more records than row header columns still labels each row correctly. - expect(rows.slice(2).map(row => row.slice(0, 2))).toEqual([ - ['All Cities', 'AllProducts'], - ['All Cities', 'Clothing'], - ['All Cities', 'Bikes'], - ['All Cities', 'Accessori...'], - ['All Cities', 'Compone...'], - ['Plovdiv', 'AllProducts'], - ['Plovdiv', 'Clothing'], - ['New York', 'AllProducts'], - ['New York', 'Clothing'], - ['Ciudad d...', 'AllProducts'], - ['Ciudad d...', 'Bikes'], - ['Ciudad d...', 'Clothing'], - ['London', 'AllProducts'], - ['London', 'Accessori...'], - ['Yokohama', 'AllProducts'], - ['Yokohama', 'Compone...'], - ['Sofia', 'AllProducts'], - ['Sofia', 'Compone...'] + // 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.spec.ts b/projects/igniteui-angular/grids/core/src/services/pdf/pdf-exporter.spec.ts index 8ad2b9a4333..f71b06b7db7 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 @@ -1719,13 +1719,15 @@ describe('PDF Exporter', () => { 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. + // 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'], - ['Product A', 'Category 1', '100', '200'], - ['Product A', 'Category 2', '150', '250'], + ['Category 1', '100', '200'], + ['Product A'], + ['Category 2', '150', '250'], ['Product B', 'Category 1', '120', '220'] ]); done(); @@ -1734,6 +1736,192 @@ describe('PDF Exporter', () => { exportRecords(pivotData); }); + it('should merge a row dimension value that repeats down consecutive records', (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 A', Category: 'Category 2', 'City-London-Sum': 150 }, + level: 0, + 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[] = [ { @@ -2041,10 +2229,14 @@ describe('PDF Exporter', () => { 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'], - ['Product A', 'Category 1', '100'], - ['Product A', 'Category 2', '150'] + ['Category 1', '100'], + ['Product A'], + ['Category 2', '150'] ]); done(); }); @@ -2132,10 +2324,14 @@ describe('PDF Exporter', () => { 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'], - ['Product A', 'Category 1', '100'], - ['Product A', 'Category 2', '150'] + ['Category 1', '100'], + ['Product A'], + ['Category 2', '150'] ]); done(); }); @@ -5279,10 +5475,12 @@ describe('PDF Exporter', () => { ['Product', 'Sum'], ['100'] ]); - // The empty one is passed over before anything is drawn for it, so the document - // holds one shaded header cell for the dimension and one for the data column - - // and not a third, blank one between them. - const headerBackgrounds = getDrawnRectangles(args.pdf).filter(rectangle => rectangle.filled); + // 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(); @@ -5337,6 +5535,64 @@ describe('PDF Exporter', () => { 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[] = [ { 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 d0de05b6b33..3b7ac9304d1 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 @@ -14,6 +14,14 @@ interface IPdfFontNames { 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) @@ -320,28 +328,15 @@ export class IgxPdfExporterService extends IgxBaseExporter { // 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) { @@ -357,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) { @@ -393,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 @@ -946,165 +948,36 @@ export class IgxPdfExporterService extends IgxBaseExporter { 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 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[index]; - 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 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, 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 (!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[index]; - if (fieldName) { - cellValue = record.data[fieldName]; - } + // 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; } - // 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]]; - } - } - } - - // 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) { - this.drawBodyCell(pdf, xPosition, yPosition, columnWidth, rowHeight, isSummaryRecord); + // 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) { @@ -1113,11 +986,13 @@ 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 - GRID_LEVEL_COL is already out of the list by now columns.forEach((col, index) => { @@ -1178,9 +1053,227 @@ export class IgxPdfExporterService extends IgxBaseExporter { } /** - * Selects the shade a header cell is filled with. Summary rows take the same one - a summary - * closes the rows above it the way the header opens them, so it reads as a footer rather than - * as one more record. + * 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; + + // 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, 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 (!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); @@ -1188,7 +1281,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { /** * Draws the border of a cell below the header row, together with the shaded background that - * goes with it when the cell belongs to a summary row. + * goes with it when the cell heads its row rather than holding one of its values. */ private drawBodyCell( pdf: jsPDF, From 3c01bc8fc10eb97f5f5b533f63acab4320581a3e Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 11:19:09 +0300 Subject: [PATCH 16/17] docs(pdf export): adding changelog entry --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d51df20e15..8f8b4ad8714 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ All notable changes for each version of this project will be documented in this - `igx-checkbox`, `igx-switch` and `igx-radio-group` now report `required` and `aria-required` for `Validators.requiredTrue`, as `igxInput` already did. - `igx-radio-group` implements `setDisabledState`, so `control.disable()` / `enable()` and the Signal Forms `disabled` rule reach the radio buttons. Buttons disabled in the template stay disabled after `enable()`. +### 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. + ## 22.2.0 ### New Features From 142afa2d2c919e742628f44f957740b26490fd9d Mon Sep 17 00:00:00 2001 From: Konstantin Dinev Date: Fri, 18 Sep 2026 11:44:03 +0300 Subject: [PATCH 17/17] chore(pdf exporter): addressing copilot's comment --- .../src/services/pdf/pdf-exporter.spec.ts | 41 +++++++++++++++++++ .../core/src/services/pdf/pdf-exporter.ts | 11 +++-- 2 files changed, 49 insertions(+), 3 deletions(-) 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 f71b06b7db7..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 @@ -5048,6 +5048,47 @@ describe('PDF Exporter', () => { 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. 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 3b7ac9304d1..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 @@ -1179,9 +1179,14 @@ export class IgxPdfExporterService extends IgxBaseExporter { 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 + // 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) { - matchedCol = col; + cellValue = record.data[colField]; break; } // Check if column header matches a value in record data @@ -1198,7 +1203,7 @@ export class IgxPdfExporterService extends IgxBaseExporter { // 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 (!matchedCol && recordIndex !== undefined) { + 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);