mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Admission counted cells, rows, merges and styles only in worksheets, styles.xml and workbook.xml, so the objects ExcelJS builds per element elsewhere (shared-string runs, fonts, fills, borders, comments, drawings, VML, tables) were bounded only by the inflated-byte caps, and empty stored deflate blocks pad a stream past the ratio cap. createXmlCounter now counts every start tag in every part except the pure-bytes xl/media/<name>.<ext> entries into counts.elements and refuses above LIMITS.maxElements (2,000,000) as element-limit. Parts that read no attributes carry only a trailing '<' between chunks, so long text or binary is never taken for an oversized tag. Very tall sheets now scale only the scroll position: the scroll range maps onto the sheet's whole range and the tile is laid out at real row heights and column widths, with spans clipped at the spacer, instead of dividing every cell and heading by the scale.
806 lines
42 KiB
TypeScript
806 lines
42 KiB
TypeScript
/** @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;
|
|
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['))).toThrowError(/unclosed bracket/i);
|
|
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
|
expect(styles(fmt('"x"'.repeat(60)))).not.toThrow();
|
|
expect(styles(fmt('&'.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({});
|
|
});
|
|
});
|