Files

930 lines
48 KiB
TypeScript
Raw Permalink Normal View History

/** @fileoverview XLSX admission, formatting, and sparse geometry contracts. */
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as fflate from 'fflate';
type Core = {
LIMITS: Record<string, number>;
XlsxPreviewError: new (code: string, message: string) => Error & { code: string };
inspectZipDirectory(bytes: Uint8Array, limits?: Record<string, number>): { entries: Array<{ name: string }> };
admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record<string, number>): unknown;
createXmlCounter(
name: string,
counts: { cells: number; merges: number; styles: number },
limits: Record<string, number>
): { push(chunk: Uint8Array, final: boolean): void };
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
repairExcelJsModel(model: unknown): unknown;
excelJsEntryName(name: string): string;
parseCellRef(ref: string): { row: number; col: number } | null;
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
createSparseAxis(count: number, defaultSize: number, overrides: Array<[number, number]>): unknown;
axisOffset(axis: unknown, index: number): number;
axisIndexAt(axis: unknown, offset: number): number;
computeViewport(axis: unknown, offset: number, viewportSize: number, overscan?: number): [number, number];
intersectingMerges(merges: string[], range: { r1: number; c1: number; r2: number; c2: number }): string[];
formatCellValue(value: unknown, format: string, date1904?: boolean): { text: string; warning?: string };
foldWarnings(warnings: string[]): string[];
DEFAULT_THEME_PALETTE: string[];
INDEXED_PALETTE: string[];
parseThemePalette(xml?: string): string[];
resolveColor(color: unknown, palette?: string[]): string | undefined;
contrastRatio(a: string, b: string): number;
ensureContrast(foreground: string, background: string, minRatio?: number): string;
resolveCellColors(
fillColor: unknown,
fontColor: unknown,
palette?: string[]
): { background?: string; foreground?: string };
};
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
const context = vm.createContext({ Uint8Array, DataView, TextDecoder, Date, Math, Number, String, Object, Map, Set });
vm.runInContext(source, context);
const core = (context as unknown as { CodemanSpreadsheetXlsxCore: Core }).CodemanSpreadsheetXlsxCore;
const workbookZip = (sheet = '<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells/></worksheet>') =>
fflate.zipSync({
'[Content_Types].xml': fflate.strToU8('<Types/>'),
'xl/workbook.xml': fflate.strToU8('<workbook><sheets><sheet name="Sheet1"/></sheets></workbook>'),
'xl/styles.xml': fflate.strToU8('<styleSheet><cellXfs count="1"><xf/></cellXfs></styleSheet>'),
'xl/worksheets/sheet1.xml': fflate.strToU8(sheet),
});
describe('spreadsheet XLSX core', () => {
it('rejects encrypted OLE files, malformed ZIPs, and ZIP64 sentinels', () => {
expect(() => core.inspectZipDirectory(Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0]))).toThrowError(/encrypted/i);
expect(() => core.inspectZipDirectory(Uint8Array.from([1, 2, 3]))).toThrowError(/malformed/i);
const zip = workbookZip();
const eocd = zip.length - 22;
zip[eocd + 10] = 0xff;
zip[eocd + 11] = 0xff;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/ZIP64/i);
});
it('rejects divergent local-header and central-directory entry names', () => {
const zip = workbookZip();
const localNameLength = new DataView(zip.buffer, zip.byteOffset, zip.byteLength).getUint16(26, true);
expect(localNameLength).toBeGreaterThan(0);
zip[30] = zip[30] === 120 ? 121 : 120;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/names do not match/i);
});
it('rejects central-directory entries that were not streamed through admission', () => {
class IncompleteUnzip {
constructor(private readonly onFile: (file: any) => void) {}
register() {}
push(_bytes: Uint8Array, final: boolean) {
if (!final) return;
const file: Record<string, any> = {
name: '[Content_Types].xml',
start: () => file.ondata(null, new Uint8Array(), true),
};
this.onFile(file);
}
}
const incomplete = { Unzip: IncompleteUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
expect(() => core.admitXlsx(workbookZip(), incomplete)).toThrowError(/not streamed/i);
});
it('uses actual streamed output for entry, total, ratio, and XML-count limits', () => {
const inflated = 'x'.repeat(20_000);
const bomb = fflate.zipSync({ 'xl/worksheets/sheet1.xml': fflate.strToU8(inflated) }, { level: 9 });
expect(() => core.admitXlsx(bomb, fflate, { maxEntryBytes: 10_000 })).toThrowError(/entry/i);
expect(() => core.admitXlsx(bomb, fflate, { maxInflatedBytes: 10_000 })).toThrowError(/inflated/i);
expect(() => core.admitXlsx(bomb, fflate, { maxCompressionRatio: 2 })).toThrowError(/compression ratio/i);
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
expect(() => core.admitXlsx(workbookZip(cells), fflate, { maxCellsPerSheet: 3 })).toThrowError(/cells/i);
expect(() => core.admitXlsx(workbookZip(), fflate, { maxEntries: 2 })).toThrowError(/entries/i);
});
it('detects OOXML feature parts and counts worksheets, merges, styles, and cells', () => {
const zip = fflate.zipSync({
...fflate.unzipSync(
workbookZip('<worksheet><sheetData><c r="A1"/></sheetData><mergeCell ref="A1:B2"/></worksheet>')
),
'xl/charts/chart1.xml': fflate.strToU8('<chart/>'),
'xl/externalLinks/externalLink1.xml': fflate.strToU8('<externalLink/>'),
});
const result = core.admitXlsx(zip, fflate) as {
counts: { worksheets: number; cells: number; merges: number; styles: number };
features: string[];
};
// The 2x2 merge costs its four covered cells on top of the one real cell.
// `elements` is every start tag across all six parts: 1 + 3 + 3 + 4 + 1 + 1.
expect(result.counts).toEqual({ worksheets: 1, cells: 5, rows: 0, merges: 1, styles: 1, elements: 13 });
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
});
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
const zip = workbookZip();
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
const rebuilt = core.buildAdmittedArchive(admission, fflate);
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
name: string;
compressedSize: number;
declaredSize: number;
}>;
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
const roundTrip = fflate.unzipSync(rebuilt);
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
});
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
const zip = workbookZip();
class DuplicateUnzip {
constructor(private readonly onFile: (file: any) => void) {}
register() {}
push(_bytes: Uint8Array, final: boolean) {
if (!final) return;
for (let i = 0; i < 2; i += 1) {
const file: Record<string, any> = {
name: 'xl/workbook.xml',
start: () => file.ondata(null, new Uint8Array(1), true),
};
this.onFile(file);
}
}
}
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
});
it('names every entry the way JSZip and ExcelJS will see it', () => {
expect(core.excelJsEntryName('xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
expect(core.excelJsEntryName('/xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
expect(core.excelJsEntryName('xl/./worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
expect(core.excelJsEntryName('xl//worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
expect(core.excelJsEntryName('xl/foo/../worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
expect(core.excelJsEntryName('../xl/styles.xml')).toBe('xl/styles.xml');
expect(core.excelJsEntryName('//xl/styles.xml')).toBe('xl/styles.xml');
expect(core.excelJsEntryName('xl/media/')).toBe('xl/media/');
});
it('refuses two entries that resolve to the same name', () => {
const entries = fflate.unzipSync(workbookZip());
const big = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
for (const twin of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'xl/x/../worksheets/sheet1.xml']) {
const zip = fflate.zipSync({ ...entries, [twin]: fflate.strToU8(big) });
expect(() => core.admitXlsx(zip, fflate), twin).toThrowError(/same name/i);
}
});
it('counts and rebuilds entries under the names ExcelJS will see', () => {
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
const renamed = (name: string) => {
const entries = fflate.unzipSync(workbookZip(cells));
const sheet = entries['xl/worksheets/sheet1.xml'];
delete entries['xl/worksheets/sheet1.xml'];
return fflate.zipSync({ ...entries, [name]: sheet });
};
for (const name of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'a/xl/worksheets/sheet2.xml.x']) {
expect(() => core.admitXlsx(renamed(name), fflate, { maxCellsPerSheet: 3 }), name).toThrowError(/cells/i);
expect(() => core.admitXlsx(renamed(name), fflate, { maxWorksheets: 0 }), name).toThrowError(/worksheet/i);
}
const admission = core.admitXlsx(renamed('/xl/./worksheets/sheet1.xml'), fflate) as {
entries: Record<string, Uint8Array>;
};
expect(Object.keys(admission.entries)).toContain('xl/worksheets/sheet1.xml');
expect(Object.keys(fflate.unzipSync(core.buildAdmittedArchive(admission, fflate)))).toContain(
'xl/worksheets/sheet1.xml'
);
// `/xl/styles.xml` is ExcelJS's `xl/styles.xml`, so its <xf> tags count.
const styles = fflate.unzipSync(workbookZip());
const xf = styles['xl/styles.xml'];
delete styles['xl/styles.xml'];
const leading = fflate.zipSync({ ...styles, '/xl/styles.xml': xf });
expect(() => core.admitXlsx(leading, fflate, { maxStyles: 0 })).toThrowError(/styles limit/i);
});
it('charges a merged range its full area and refuses one that does not parse', () => {
const merged = (ref: string) =>
workbookZip(
`<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells><mergeCell ref="${ref}"/></mergeCells></worksheet>`
);
expect(() => core.admitXlsx(merged('A1:CV30000'), fflate)).toThrowError(/cells limit/i);
// Reversed corners describe the same area.
expect(() => core.admitXlsx(merged('CV30000:A1'), fflate)).toThrowError(/cells limit/i);
expect(() => core.admitXlsx(merged('A1:XFE2'), fflate)).toThrowError(/merged range/i);
expect(() => core.admitXlsx(merged('not-a-range'), fflate)).toThrowError(/merged range/i);
const admitted = core.admitXlsx(merged('A1:J10'), fflate) as { counts: { cells: number } };
expect(admitted.counts.cells).toBe(101);
});
it('refuses a <col> whose min or max is past the last Excel column', () => {
const cols = (attrs: string) =>
workbookZip(
`<worksheet><cols><col ${attrs} width="9"/></cols><sheetData><row><c r="A1"/></row></sheetData></worksheet>`
);
expect(() => core.admitXlsx(cols('min="1" max="3000000"'), fflate)).toThrowError(/column max/i);
expect(() => core.admitXlsx(cols('min="16385" max="16385"'), fflate)).toThrowError(/column min/i);
expect(() => core.admitXlsx(cols('min="1" max="1e9"'), fflate)).toThrowError(/column max/i);
expect(() => core.admitXlsx(cols('min="1" max="16384"'), fflate)).not.toThrow();
});
it('reads a merge or col tag whole even when a stream chunk boundary cuts through it', () => {
// Markup on both sides, so a cut can land 128+ bytes past the tag's start.
const pad = '<sheetView workbookViewId="0"/>'.repeat(12);
const cases: Array<[string, RegExp]> = [
[`<worksheet>${pad}<cols><col min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
[`<worksheet>${pad}<cols><col x=">" min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
[`<worksheet>${pad}<mergeCells><mergeCell ref="A1:CV30000"/></mergeCells>${pad}</worksheet>`, /cells limit/i],
];
for (const [xml, pattern] of cases) {
const bytes = fflate.strToU8(xml);
for (let cut = 1; cut < bytes.length; cut += 1) {
const counter = core.createXmlCounter(
'xl/worksheets/sheet1.xml',
{ cells: 0, merges: 0, styles: 0 },
core.LIMITS
);
expect(() => {
counter.push(bytes.subarray(0, cut), false);
counter.push(bytes.subarray(cut), true);
}, `cut at ${cut}`).toThrowError(pattern);
}
}
});
it('reads attributes in order, so a quoted value cannot hide or fake one', () => {
const counter = () =>
core.createXmlCounter(
'xl/worksheets/sheet1.xml',
{ cells: 0, merges: 0, styles: 0, rows: 0 } as never,
core.LIMITS
);
const push = (xml: string) => counter().push(fflate.strToU8(xml), true);
// A raw `>` or the other quote character is legal inside a value.
expect(() => push(`<mergeCell x=' ref="A1"' ref="A1:CV30000"/>`)).toThrowError(/cells limit/i);
expect(() => push('<col x=">" min="1" max="3000000"/>')).toThrowError(/column max/i);
expect(() => push(`<col x=' max="1"' min="1" max="3000000"/>`)).toThrowError(/column max/i);
// Anything the walk cannot read up to `>` is refused, as is a repeated name.
expect(() => push('<mergeCell ref="A1:B2" junk/>')).toThrowError(/do not parse/i);
expect(() => push('<mergeCell ref="A1" ref="A1:CV30000"/>')).toThrowError(/do not parse/i);
expect(() => push('<cols><col min="1" max="3" width="9"></col></cols><mergeCell ref="A1:B2" />')).not.toThrow();
});
it('counts every <row>, empty or not, against per-sheet and total caps', () => {
const rows = (n: number) => '<worksheet><sheetData>' + '<row r="1"/>'.repeat(n) + '</sheetData></worksheet>';
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRowsPerSheet: 3 })).toThrowError(/rows limit/i);
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRows: 3 })).toThrowError(/rows limit/i);
const admitted = core.admitXlsx(workbookZip(rows(3)), fflate, { maxRowsPerSheet: 3 }) as {
counts: { rows: number };
};
expect(admitted.counts.rows).toBe(3);
// <rowBreaks>/<rows...> style names are not rows.
expect(
(core.admitXlsx(workbookZip('<worksheet><rowBreaks/></worksheet>'), fflate) as { counts: { rows: number } })
.counts.rows
).toBe(0);
});
// ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest
// index on every eachRow and `sheet.model`, so the index a row CLAIMS is cost.
it('refuses a <row r> that is not plain digits in 1-1048576 and a <row> whose attributes do not parse', () => {
const sheet = (rowTag: string) =>
workbookZip(`<worksheet><sheetData><row r="1"><c r="A1"/></row>${rowTag}</sheetData></worksheet>`);
for (const r of ['50000000', '1048577', '0', '1a', '-1', '1e3', ' 2', '']) {
expect(() => core.admitXlsx(sheet(`<row r="${r}"/>`), fflate), r).toThrowError(/row index/i);
}
expect(() => core.admitXlsx(sheet('<row r="2" junk/>'), fflate)).toThrowError(/do not parse/i);
// A quoted fake `r` cannot stand in for the real one.
expect(() => core.admitXlsx(sheet(`<row x=' r="2"' r="50000000"/>`), fflate)).toThrowError(/row index/i);
for (const ok of ['<row r="1048576"/>', '<row/>', '<row spans="1:1"><c r="A2"/></row>']) {
expect(() => core.admitXlsx(sheet(ok), fflate), ok).not.toThrow();
}
});
// ExcelJS stores a sheet at `_worksheets[sheetId]`, and the `worksheets`
// getter slices and sorts that array, so a large id allocates a huge array.
it('refuses a <sheet sheetId> in xl/workbook.xml that is not plain digits or is above the cap', () => {
expect(core.LIMITS.maxSheetId).toBe(65535);
const book = (sheets: string, name = 'xl/workbook.xml') => {
const entries = fflate.unzipSync(workbookZip());
delete entries['xl/workbook.xml'];
entries[name] = fflate.strToU8(`<workbook><sheets>${sheets}</sheets></workbook>`);
return fflate.zipSync(entries);
};
const one = (id: string) => `<sheet name="S" sheetId="${id}" r:id="rId1"/>`;
for (const id of ['30000000', '65536', '1a', '-1', '1e3', ' 1', '']) {
expect(() => core.admitXlsx(book(one(id)), fflate), id).toThrowError(/sheetId/i);
}
// Every <sheet> is read, not just the first; the name is the one ExcelJS sees.
expect(() => core.admitXlsx(book(one('1') + one('30000000')), fflate)).toThrowError(/sheetId/i);
expect(() => core.admitXlsx(book(one('30000000'), '/xl/./workbook.xml'), fflate)).toThrowError(/sheetId/i);
expect(() => core.admitXlsx(book('<sheet name="S" sheetId="1" junk/>'), fflate)).toThrowError(/do not parse/i);
expect(() => core.admitXlsx(book(`<sheet x=' sheetId="1"' sheetId="30000000"/>`), fflate)).toThrowError(/sheetId/i);
// `<sheets>` is the container, never a sheet; an absent sheetId is harmless.
expect(() => core.admitXlsx(book(one('1') + one('65535') + '<sheet name="S"/>'), fflate)).not.toThrow();
// Only the workbook part is read this way.
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
const other = core.createXmlCounter('xl/other.xml', counts as never, core.LIMITS);
expect(() => other.push(fflate.strToU8(one('30000000')), true)).not.toThrow();
});
// ExcelJS builds an object per element in every part it parses, not only
// worksheets: each <r> run and <si> in sharedStrings.xml, each <font>, <fill>
// and <border> in styles, comments, drawings, VML and tables.
it('budgets every start tag outside xl/media, whatever part it sits in', () => {
expect(core.LIMITS.maxElements).toBe(2_000_000);
const withPart = (name: string, xml: string) =>
fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: fflate.strToU8(xml) });
const elementsOf = (zip: Uint8Array) =>
(core.admitXlsx(zip, fflate) as { counts: { elements: number } }).counts.elements;
const base = elementsOf(workbookZip());
const runs = '<sst><si>' + '<r/>'.repeat(50) + '</si></sst>';
const fonts = '<styleSheet><fonts>' + '<font/>'.repeat(50) + '</fonts></styleSheet>';
const cases: Array<[string, string, number]> = [
['xl/sharedStrings.xml', runs, 52],
['xl/comments1.xml', '<comments>' + '<comment/>'.repeat(50) + '</comments>', 51],
['xl/drawings/vmlDrawing1.vml', '<xml>' + '<v:shape/>'.repeat(50) + '</xml>', 51],
// The name ExcelJS sees, so `/xl/./` cannot slip a part past the budget.
['/xl/./sharedStrings.xml', runs, 52],
];
for (const [name, xml, added] of cases) {
expect(elementsOf(withPart(name, xml)), name).toBe(base + added);
let thrown: unknown;
try {
core.admitXlsx(withPart(name, xml), fflate, { maxElements: base + added - 1 });
} catch (error) {
thrown = error;
}
expect((thrown as { code?: string })?.code, name).toBe('element-limit');
}
// styles.xml is counted on top of its own <xf>/<numFmt> checks.
const styled = fflate.unzipSync(workbookZip());
styled['xl/styles.xml'] = fflate.strToU8(fonts);
expect(() => core.admitXlsx(fflate.zipSync(styled), fflate, { maxElements: 50 })).toThrowError(/element/i);
// Processing instructions, comments and end tags are not elements; a tag
// INSIDE a comment is still counted, which errs toward refusing.
expect(elementsOf(withPart('xl/other.xml', '<?xml version="1.0"?><!-- c --><a></a><_b/>'))).toBe(base + 2);
expect(elementsOf(withPart('xl/other.xml', '<!-- <a> --><a/>'))).toBe(base + 2);
});
it('never budgets an xl/media part, which ExcelJS keeps as bytes', () => {
// Stored, so the compression-ratio cap stays out of the way.
const tags = fflate.strToU8('<r/>'.repeat(500));
for (const name of ['xl/media/image1.png', '/xl/./media/image1.jpeg']) {
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).not.toThrow();
}
// A name under xl/media/ that ExcelJS parses as XML (its patterns are
// unanchored) is budgeted like any other part.
for (const name of ['xl/media/xl/drawings/drawing1.xml', 'xl/media/xl/worksheets/sheet2.xml']) {
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).toThrowError(/element/i);
}
});
it('counts start tags exactly when a chunk boundary cuts through one', () => {
const xml = '<sst><si>' + '<r/><t>x</t>'.repeat(20) + '</si></sst>';
const bytes = fflate.strToU8(xml);
for (const name of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml', 'xl/styles.xml']) {
for (let cut = 1; cut < bytes.length; cut += 1) {
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
const counter = core.createXmlCounter(name, counts as never, core.LIMITS);
counter.push(bytes.subarray(0, cut), false);
counter.push(bytes.subarray(cut), true);
expect(counts.elements, `${name} cut at ${cut}`).toBe(42);
}
}
});
it('does not carry long text or binary in a non-worksheet part as an oversized tag', () => {
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
const text = fflate.strToU8('<sst><si><t>' + 'x'.repeat(600_000) + '</t></si></sst>');
const strings = core.createXmlCounter('xl/sharedStrings.xml', counts as never, core.LIMITS);
const binary = core.createXmlCounter('xl/embeddings/oleObject1.bin', counts as never, core.LIMITS);
const zeros = new Uint8Array(600_000);
expect(() => {
for (let at = 0; at < text.length; at += 65_536) {
strings.push(text.subarray(at, at + 65_536), at + 65_536 >= text.length);
}
for (let at = 0; at < zeros.length; at += 65_536) {
binary.push(zeros.subarray(at, at + 65_536), at + 65_536 >= zeros.length);
}
}).not.toThrow();
expect(counts.elements).toBe(3);
});
it('counts every <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
const counter = core.createXmlCounter('xl/styles.xml', counts as never, { ...core.LIMITS, maxStyles: 100 });
const xml = '<styleSheet><cellXfs><!-- </cellXfs> -->' + '<xf/>'.repeat(200) + '</cellXfs></styleSheet>';
expect(() => counter.push(fflate.strToU8(xml), true)).toThrowError(/styles limit/i);
});
// ExcelJS's `_mergeCellsInternal` checks every new merge against every earlier
// one on its sheet, so a sheet costs the SQUARE of its merge count: one sheet
// at the old 5,000 cap took 1.6 s, and 20 such sheets ran past the page timeout.
it('caps merges per sheet and across the workbook', () => {
expect(core.LIMITS.maxMergesPerSheet).toBe(2000);
expect(core.LIMITS.maxMerges).toBe(10000);
const merges = (n: number, row = 1) =>
Array.from({ length: n }, (_, i) => `<mergeCell ref="A${row + i * 2}:A${row + i * 2 + 1}"/>`).join('');
const sheetXml = (n: number) => `<worksheet><sheetData/><mergeCells>${merges(n)}</mergeCells></worksheet>`;
expect(() => core.admitXlsx(workbookZip(sheetXml(4)), fflate, { maxMergesPerSheet: 3 })).toThrowError(
/merged ranges limit/i
);
expect(() => core.admitXlsx(workbookZip(sheetXml(3)), fflate, { maxMergesPerSheet: 3 })).not.toThrow();
// Two sheets each under the per-sheet cap still trip the workbook-wide one.
const twoSheets = fflate.zipSync({
...fflate.unzipSync(workbookZip(sheetXml(3))),
'xl/worksheets/sheet2.xml': fflate.strToU8(sheetXml(3)),
});
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
/merged ranges limit/i
);
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 6 })).not.toThrow();
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
expect.objectContaining({ code: 'merge-limit' })
);
});
// ExcelJS runs `isDateFmt` once per numeric cell, and its first step,
// `fmt.replace(/\[[^\]]*]/g, '')`, rescans to the end of the code for every
// `[` with no later `]`. The code is also echoed into the notice bar.
it('refuses a <numFmt> formatCode over 255 characters or with a [ after its last ]', () => {
const styles = (numFmts: string) => {
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
const xml = `<styleSheet><numFmts count="1">${numFmts}</numFmts><cellXfs count="1"><xf/></cellXfs></styleSheet>`;
return () => counter.push(fflate.strToU8(xml), true);
};
const fmt = (code: string) => `<numFmt numFmtId="164" formatCode="${code}"/>`;
expect(styles(fmt('0'.repeat(256)))).toThrowError(/longer than 255/i);
expect(styles(fmt('0'.repeat(255)))).not.toThrow();
for (const code of ['[', '0[', '[Red]0[', '[[[[', '[Red]0.00;[']) {
expect(styles(fmt(code)), code).toThrowError(/unclosed bracket/i);
}
for (const code of ['[Red]0.00', '[$-409]mmm d, yyyy', '[h]:mm:ss', '0.00', '#,##0;[Red]-#,##0', ']', '[[]']) {
expect(styles(fmt(code)), code).not.toThrow();
}
// The code is checked as ExcelJS decodes it: an entity cannot hide a `[`,
// and an entity-heavy code is measured by its decoded length.
expect(styles(fmt('0&#91;'))).toThrowError(/unclosed bracket/i);
expect(styles(fmt('0&#x5B;'))).toThrowError(/unclosed bracket/i);
expect(styles(fmt('&quot;x&quot;'.repeat(60)))).not.toThrow();
expect(styles(fmt('&amp;'.repeat(256)))).toThrowError(/longer than 255/i);
// Attributes are read in order, so a quoted fake formatCode cannot mask the real one.
expect(styles(`<numFmt x=' formatCode="0"' numFmtId="164" formatCode="0["/>`)).toThrowError(/unclosed bracket/i);
expect(styles('<numFmt numFmtId="164" formatCode="0" junk/>')).toThrowError(/do not parse/i);
// `<numFmts>` is the container, not a format.
expect(styles('')).not.toThrow();
// The refusal never echoes the code itself.
for (const code of ['QZJX[', `${'QZJX'.repeat(64)}0`]) {
expect(styles(fmt(code))).toThrowError(
expect.objectContaining({ code: 'number-format', message: expect.not.stringContaining('QZJX') })
);
}
// Every <numFmt> in the file is read, wherever it sits (dxfs carry them too).
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
const dxf = `<styleSheet><dxfs count="1"><dxf>${fmt('0[')}</dxf></dxfs></styleSheet>`;
expect(() => counter.push(fflate.strToU8(dxf), true)).toThrowError(/unclosed bracket/i);
});
it('caps cell display text at 1,000 characters for every value shape', () => {
const long = 'x'.repeat(50_000);
const shapes: Array<[string, unknown]> = [
['string', long],
['rich text', { richText: [{ text: long }, { text: long }] }],
['hyperlink', { text: long, hyperlink: 'https://example.invalid/' }],
['rich hyperlink', { text: { richText: [{ text: long }] }, hyperlink: 'https://example.invalid/' }],
['formula source', { formula: long }],
['formula result', { formula: 'A1', result: long }],
['error', { error: long }],
];
for (const [label, value] of shapes) {
const { text } = core.formatCellValue(value, 'General');
expect(text.length, label).toBeLessThanOrEqual(1000);
expect(text.endsWith('…'), label).toBe(true);
}
expect(core.formatCellValue('y'.repeat(1000), 'General').text).toBe('y'.repeat(1000));
expect(core.formatCellValue({ richText: [{ text: 'a' }, { text: 'b' }] }, 'General').text).toBe('ab');
// A cut never leaves half of a surrogate pair.
// 'aa' puts a high surrogate at index 998, exactly where a naive cut lands.
const emoji = core.formatCellValue('aa' + '😀'.repeat(600), 'General').text;
expect(emoji.length).toBeLessThanOrEqual(1000);
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
});
// An empty run adds no text, so stopping on the text length alone still
// visited every run, once per cell referencing the shared string, per tile.
it('visits at most maxCellTextChars + 1 rich-text runs, however many are empty', () => {
const bound = core.LIMITS.maxCellTextChars + 1;
const runs: Array<{ text: string }> = Array.from({ length: 5_000 }, () => ({ text: '' }));
runs[0] = { text: 'head' };
for (let index = bound; index < runs.length; index += 1) {
Object.defineProperty(runs, index, {
get() {
throw new Error(`rich-text run ${index} visited`);
},
});
}
expect(core.formatCellValue({ richText: runs }, 'General')).toEqual({ text: 'head' });
// A run inside the bound still contributes.
runs[bound - 1] = { text: 'tail' };
expect(core.formatCellValue({ richText: runs }, 'General').text).toBe('headtail');
});
it('refuses an entry whose declared compressed size runs past the file', () => {
const zip = workbookZip();
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
const eocd = zip.length - 22;
const centralStart = view.getUint32(eocd + 16, true);
view.setUint32(centralStart + 20, 0x7fffffff, true);
expect(() => core.inspectZipDirectory(zip)).toThrowError(/compressed bytes/i);
});
it('derives bounded extents from real cells and merges', () => {
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
expect(core.parseCellRef('XFE1')).toBeNull();
expect(core.deriveExtent(['B3'], ['D5:F9'])).toEqual({ rows: 9, cols: 6 });
});
it('maps sparse axes and virtual viewports without dense allocation', () => {
const axis = core.createSparseAxis(1_000_000, 20, [
[2, 0],
[10, 40],
]);
expect(core.axisOffset(axis, 3)).toBe(20);
expect(core.axisIndexAt(axis, 20)).toBe(3);
const [start, end] = core.computeViewport(axis, 199, 60, 1);
expect(start).toBeLessThanOrEqual(9);
expect(end - start).toBeLessThan(10);
});
it('matches a plain walk over the overrides at every index', () => {
const overrides: Array<[number, number]> = [];
for (let index = 3; index <= 400; index += 7) overrides.push([index, (index * 13) % 50]);
const axis = core.createSparseAxis(500, 20, overrides);
for (let index = 0; index <= 502; index += 1) {
const bounded = Math.max(1, Math.min(501, index));
let expected = (bounded - 1) * 20;
for (const [at, size] of overrides) if (at < bounded) expected += size - 20;
expect(core.axisOffset(axis, index), `index ${index}`).toBe(expected);
}
});
it('returns intersecting merges even when their anchor is offscreen', () => {
expect(core.intersectingMerges(['A1:D4', 'Z1:Z2'], { r1: 3, c1: 3, r2: 6, c2: 6 })).toEqual(['A1:D4']);
});
it('formats common values and safely flags unknown formats', () => {
expect(core.formatCellValue(0.125, '0.0%').text).toBe('12.5%');
expect(core.formatCellValue(1234.5, '#,##0.00').text).toBe('1,234.50');
expect(core.formatCellValue(10, '$#,##0.00').text).toBe('$10.00');
expect(core.formatCellValue(1, 'yyyy-mm-dd').text).toBe('1900-01-01');
expect(core.formatCellValue(0, 'yyyy-mm-dd', true).text).toBe('1904-01-01');
expect(core.formatCellValue(0.5, 'hh:mm:ss').text).toBe('12:00:00');
expect(core.formatCellValue(1.5, 'yyyy-mm-dd hh:mm').text).toBe('1900-01-01 12:00');
expect(core.formatCellValue(7, '[Red][<0]0.0')).toMatchObject({ text: '7', warning: expect.any(String) });
expect(core.formatCellValue({ formula: 'SUM(A1:A2)' }, 'General')).toMatchObject({ text: '=SUM(A1:A2)' });
});
// Excel displays at most 15 significant digits; String() printed the binary noise.
it('shows General and unsupported-format numbers at 15 significant digits, as Excel does', () => {
expect(core.formatCellValue(0.1 + 0.2, 'General').text).toBe('0.3');
expect(core.formatCellValue(10.1 * 3, 'General').text).toBe('30.3');
expect(core.formatCellValue({ formula: '0.1+0.2', result: 0.1 + 0.2 }, 'General').text).toBe('0.3');
expect(core.formatCellValue(0.1 + 0.2, '0.00E+00')).toEqual({
text: '0.3',
warning: 'Unsupported number format: 0.00E+00',
});
expect(core.formatCellValue(1234.5, 'General').text).toBe('1234.5');
expect(core.formatCellValue(-42, 'General').text).toBe('-42');
});
it('renders a time-only AM/PM format as a 12-hour time, not a date', () => {
expect(core.formatCellValue(14.5 / 24, 'h:mm AM/PM')).toEqual({ text: '2:30 PM' });
expect(core.formatCellValue(0, 'h:mm AM/PM').text).toBe('12:00 AM');
expect(core.formatCellValue(0.5, 'h:mm AM/PM').text).toBe('12:00 PM');
expect(core.formatCellValue(9.25 / 24, 'hh:mm:ss AM/PM').text).toBe('09:15:00 AM');
// ExcelJS loads a time-formatted cell as a Date on the 1899-12-31 epoch day.
expect(core.formatCellValue(new Date(Date.UTC(1899, 11, 31, 14, 30)), 'h:mm AM/PM')).toEqual({
text: '2:30 PM',
});
});
it('folds unsupported number format warnings into one counted entry', () => {
const many = Array.from({ length: 800 }, (_, index) => `Unsupported number format: 0.0${'0'.repeat(index)}E+0`);
const folded = core.foldWarnings(['charts', ...many, 'Formula has no cached result']);
expect(folded).toEqual(['charts', '800 unsupported number formats', 'Formula has no cached result']);
expect(core.foldWarnings(['Unsupported number format: 0.00E+00'])).toEqual(['Unsupported number format: 0.00E+00']);
expect(core.foldWarnings(['charts'])).toEqual(['charts']);
});
// toLocaleString throws a RangeError above 100 fraction digits; Excel caps at 30.
it('caps a number format at 30 decimals instead of throwing', () => {
expect(core.formatCellValue(1.5, `0.${'0'.repeat(120)}`).text).toBe(`1.5${'0'.repeat(29)}`);
});
});
const themeXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom"><a:themeElements>
<a:clrScheme name="Custom">
<a:dk1><a:sysClr val="windowText" lastClr="102030"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="F0F1F2"/></a:lt1>
<a:dk2><a:srgbClr val="203040"/></a:dk2>
<a:lt2><a:srgbClr val="E0E1E2"/></a:lt2>
<a:accent1><a:srgbClr val="FF0000"/></a:accent1>
<a:accent2><a:srgbClr val="00FF00"/></a:accent2>
<a:accent3><a:srgbClr val="0000FF"/></a:accent3>
<a:accent4><a:srgbClr val="010203"/></a:accent4>
<a:accent5><a:srgbClr val="040506"/></a:accent5>
<a:accent6><a:srgbClr val="070809"/></a:accent6>
<a:hlink><a:srgbClr val="123456"/></a:hlink>
<a:folHlink><a:srgbClr val="654321"/></a:folHlink>
</a:clrScheme></a:themeElements></a:theme>`;
describe('spreadsheet XLSX colour resolution', () => {
// The clrScheme and slot patterns rescan to the end of the text for every
// unclosed opening tag, so a padded theme is quadratic.
it('uses the default palette for a theme above 64 KB, even a 1 MB run of unclosed clrScheme tags', () => {
const padded = '<a:clrScheme>'.repeat(Math.ceil((1024 * 1024) / 13));
expect(padded.length).toBeGreaterThanOrEqual(1024 * 1024);
const started = performance.now();
expect(core.parseThemePalette(padded)).toEqual(core.DEFAULT_THEME_PALETTE);
expect(performance.now() - started).toBeLessThan(1_000);
// The cap is on characters: a real theme padded to exactly 64 KB still parses, one more does not.
const fill = (length: number) =>
themeXml.replace('</a:theme>', `<!--${' '.repeat(length - themeXml.length - 7)}--></a:theme>`);
expect(fill(64 * 1024)).toHaveLength(64 * 1024);
expect(core.parseThemePalette(fill(64 * 1024))[4]).toBe('#ff0000');
expect(core.parseThemePalette(fill(64 * 1024 + 1))).toEqual(core.DEFAULT_THEME_PALETTE);
});
it('parses a theme palette into styles.xml index order, swapping lt/dk against clrScheme order', () => {
const palette = core.parseThemePalette(themeXml);
expect(palette).toHaveLength(12);
// styles.xml order is lt1, dk1, lt2, dk2 while clrScheme lists dk1, lt1, dk2, lt2.
expect(palette[0]).toBe('#f0f1f2');
expect(palette[1]).toBe('#102030');
expect(palette[2]).toBe('#e0e1e2');
expect(palette[3]).toBe('#203040');
expect(palette.slice(4)).toEqual([
'#ff0000',
'#00ff00',
'#0000ff',
'#010203',
'#040506',
'#070809',
'#123456',
'#654321',
]);
expect(core.resolveColor({ theme: 0 }, palette)).toBe('#f0f1f2');
expect(core.resolveColor({ theme: 1 }, palette)).toBe('#102030');
expect(core.resolveColor({ theme: 12 }, palette)).toBeUndefined();
});
it('reads sysClr entries from lastClr and srgbClr entries from val', () => {
const sysOnly = core.parseThemePalette(
themeXml.replace('<a:srgbClr val="203040"/>', '<a:sysClr val="windowText" lastClr="ABCDEF"/>')
);
expect(sysOnly[3]).toBe('#abcdef');
// A sysClr with no lastClr is unusable and must not poison the whole palette.
const missing = core.parseThemePalette(
themeXml.replace('<a:sysClr val="window" lastClr="F0F1F2"/>', '<a:sysClr val="window"/>')
);
expect(missing[0]).toBe(core.DEFAULT_THEME_PALETTE[0]);
expect(missing[1]).toBe('#102030');
});
it('applies Excel tint to HSL luminance in both directions', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveColor({ theme: 4, tint: 0.5 }, palette)).toBe('#ff8080');
expect(core.resolveColor({ theme: 4, tint: -0.5 }, palette)).toBe('#800000');
expect(core.resolveColor({ theme: 4, tint: 0 }, palette)).toBe('#ff0000');
expect(core.resolveColor({ theme: 4, tint: 1 }, palette)).toBe('#ffffff');
expect(core.resolveColor({ theme: 4, tint: -1 }, palette)).toBe('#000000');
});
it('falls back to the default Office palette when theme XML is missing or unparseable', () => {
expect(core.DEFAULT_THEME_PALETTE).toHaveLength(12);
expect(core.DEFAULT_THEME_PALETTE[0]).toBe('#ffffff');
expect(core.DEFAULT_THEME_PALETTE[1]).toBe('#000000');
expect(core.DEFAULT_THEME_PALETTE[2]).toBe('#eeece1');
expect(core.DEFAULT_THEME_PALETTE[3]).toBe('#1f497d');
expect(core.DEFAULT_THEME_PALETTE[4]).toBe('#4f81bd');
expect(core.DEFAULT_THEME_PALETTE[11]).toBe('#800080');
expect(core.parseThemePalette('')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette(undefined)).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette('<html>not a theme</html>')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.resolveColor({ theme: 1 })).toBe('#000000');
expect(core.resolveColor({ theme: 0 })).toBe('#ffffff');
});
it('resolves legacy indexed palette entries and ignores the auto sentinels', () => {
expect(core.INDEXED_PALETTE).toHaveLength(64);
expect(core.resolveColor({ indexed: 0 })).toBe('#000000');
expect(core.resolveColor({ indexed: 9 })).toBe('#ffffff');
expect(core.resolveColor({ indexed: 13 })).toBe('#ffff00');
expect(core.resolveColor({ indexed: 22 })).toBe('#c0c0c0');
expect(core.resolveColor({ indexed: 63 })).toBe('#333333');
expect(core.resolveColor({ indexed: 64 })).toBeUndefined();
expect(core.resolveColor({ indexed: 65 })).toBeUndefined();
expect(core.resolveColor({ argb: 'FF1F497D' })).toBe('#1f497d');
expect(core.resolveColor({ argb: 'nope' })).toBeUndefined();
expect(core.resolveColor(undefined)).toBeUndefined();
});
it('computes WCAG contrast ratios and overrides with the correct polarity', () => {
expect(core.contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5);
expect(core.contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
expect(core.contrastRatio('#ffff00', '#ffffff')).toBeLessThan(4.5);
// White on yellow is unreadable -> black wins.
expect(core.ensureContrast('#ffffff', '#ffff00')).toBe('#000000');
// Near-black on black -> white wins.
expect(core.ensureContrast('#11111b', '#1a1a2e')).toBe('#ffffff');
// Already-legible pairs are preserved verbatim.
expect(core.ensureContrast('#1f497d', '#ffffff')).toBe('#1f497d');
});
it('pins an implicit white sheet background for a dark font with no resolvable fill', () => {
expect(core.resolveCellColors(undefined, { argb: 'FF000000' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 1 })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
});
it('pins black text when a fill resolves but the font colour does not', () => {
expect(core.resolveCellColors({ theme: 0 }, undefined)).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFF8E7' }, undefined)).toEqual({
background: '#fff8e7',
foreground: '#000000',
});
});
it('overrides light-on-light and dark-on-dark pairs while keeping legible authored pairs', () => {
expect(core.resolveCellColors({ indexed: 13 }, { indexed: 9 })).toEqual({
background: '#ffff00',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FFF5F5F5' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FF1A1A2E' }, { argb: 'FF11111B' })).toEqual({
background: '#1a1a2e',
foreground: '#ffffff',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FF1F497D' })).toEqual({
background: '#ffffff',
foreground: '#1f497d',
});
});
it('keeps theme fills and theme fonts instead of dropping them', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveCellColors({ theme: 4, tint: 0.6 }, { theme: 1 }, palette)).toEqual({
background: '#ff9999',
foreground: '#102030',
});
});
it('emits neither colour when nothing resolves, so the skin tokens stay paired', () => {
expect(core.resolveCellColors(undefined, undefined)).toEqual({});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 99 })).toEqual({});
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
});
});
// ExcelJS 4.4.0's parsed model at the moment it reconciles, reduced to the parts
// repairExcelJsModel reads. `excelShaped()` is what Excel itself writes (and
// ExcelJS links cleanly); the openpyxl cases below are what made it throw.
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
const rel = (Id: string, type: string, Target: string) => ({ Id, Type: REL + type, Target });
type Model = Record<string, any>;
function excelShaped(): Model {
return {
worksheets: [
{
sheetNo: '1',
tables: [{ rId: 'rId3' }],
drawing: { rId: 'rId4' },
hyperlinks: [{ rId: 'rId5', address: 'A1' }, { address: 'B2' }],
},
],
worksheetRels: {
'1': [
rel('rId1', 'comments', '../comments1.xml'),
rel('rId2', 'vmlDrawing', '../drawings/vmlDrawing1.vml'),
rel('rId3', 'table', '../tables/table1.xml'),
rel('rId4', 'drawing', '../drawings/drawing1.xml'),
rel('rId5', 'hyperlink', 'https://example.com'),
],
},
comments: { '../comments1.xml': { comments: [{ ref: 'B2' }] } },
vmlDrawings: { '../drawings/vmlDrawing1.vml': { comments: [{}] } },
tables: { '../tables/table1.xml': { name: 'Sales' } },
drawings: { drawing1: { anchors: [] } },
drawingRels: { drawing1: [] },
};
}
describe('repairExcelJsModel: links ExcelJS would dereference unchecked', () => {
it('leaves a model Excel itself wrote untouched', () => {
const model = excelShaped();
const before = structuredClone(model);
core.repairExcelJsModel(model);
expect(model).toEqual(before);
});
it("re-points openpyxl's absolute table Target to the key ExcelJS stored that table under", () => {
const model = excelShaped();
model.worksheetRels['1'][2].Target = '/xl/tables/table1.xml';
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'][2].Target).toBe('../tables/table1.xml');
expect(model.worksheets[0].tables).toEqual([{ rId: 'rId3' }]);
});
it('drops comment and note links that resolve to nothing (openpyxl names the parts its own way)', () => {
const model = excelShaped();
model.worksheetRels['1'][0].Target = '/xl/comments/comment1.xml';
model.worksheetRels['1'][1].Target = '/xl/drawings/commentsDrawing1.vml';
model.comments = {};
model.vmlDrawings = {};
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId3', 'rId4', 'rId5']);
});
it('drops a note link whose VML has no comments list, as ExcelJS would index into it', () => {
const model = excelShaped();
model.vmlDrawings['../drawings/vmlDrawing1.vml'] = {};
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId2')).toBe(false);
});
it('drops a drawing ExcelJS parsed to nothing and unhooks the sheet from it (openpyxl charts)', () => {
const model = excelShaped();
model.drawings.drawing1 = undefined;
model.worksheetRels['1'][3].Target = '/xl/drawings/drawing1.xml';
core.repairExcelJsModel(model);
expect(model.drawings).toEqual({});
expect(model.drawingRels).toEqual({});
expect(model.worksheets[0].drawing).toBeUndefined();
});
it('keeps a drawing link whose Target ExcelJS does not try to resolve', () => {
const model = excelShaped();
model.worksheetRels['1'][3].Target = 'drawing-without-extension';
core.repairExcelJsModel(model);
expect(model.worksheets[0].drawing).toEqual({ rId: 'rId4' });
});
it('unhooks a tablePart, drawing or hyperlink whose rId names no relationship', () => {
const model = excelShaped();
model.worksheets[0].tables = [{ rId: 'missing' }, { rId: 'rId5' }];
model.worksheets[0].drawing = { rId: 'missing' };
model.worksheets[0].hyperlinks = [{ rId: 'missing', address: 'A1' }, { address: 'B2' }];
core.repairExcelJsModel(model);
expect(model.worksheets[0].tables).toEqual([]);
expect(model.worksheets[0].drawing).toBeUndefined();
expect(model.worksheets[0].hyperlinks).toEqual([{ address: 'B2' }]);
});
it('drops a table rel whose part ExcelJS never stored, and its tablePart with it', () => {
const model = excelShaped();
model.worksheetRels['1'][2].Target = '/xl/somewhere/else.xml';
model.tables = { '../tables/table1.xml': undefined };
core.repairExcelJsModel(model);
expect(model.tables).toEqual({});
expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId3')).toBe(false);
expect(model.worksheets[0].tables).toEqual([]);
});
it('never resolves a Target or rId through Object.prototype', () => {
const model = excelShaped();
model.worksheetRels['1'][0].Target = '__proto__';
model.worksheetRels['1'][2].Target = 'constructor';
model.worksheets[0].tables = [{ rId: 'toString' }];
model.worksheets[0].hyperlinks = [{ rId: 'hasOwnProperty', address: 'A1' }];
core.repairExcelJsModel(model);
expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId2', 'rId4', 'rId5']);
expect(model.worksheets[0].tables).toEqual([]);
expect(model.worksheets[0].hyperlinks).toEqual([]);
});
it('tolerates a model with no worksheets, rels or part maps', () => {
expect(() => core.repairExcelJsModel({})).not.toThrow();
expect(() => core.repairExcelJsModel({ worksheets: [null, { sheetNo: '9' }] })).not.toThrow();
expect(core.repairExcelJsModel(null)).toBeNull();
});
});