/** @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; XlsxPreviewError: new (code: string, message: string) => Error & { code: string }; inspectZipDirectory(bytes: Uint8Array, limits?: Record): { entries: Array<{ name: string }> }; admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record): unknown; createXmlCounter( name: string, counts: { cells: number; merges: number; styles: number }, limits: Record ): { 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 = '') => fflate.zipSync({ '[Content_Types].xml': fflate.strToU8(''), 'xl/workbook.xml': fflate.strToU8(''), 'xl/styles.xml': fflate.strToU8(''), '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 = { 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 = '' + ''.repeat(4) + ''; 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('') ), 'xl/charts/chart1.xml': fflate.strToU8(''), 'xl/externalLinks/externalLink1.xml': fflate.strToU8(''), }); 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; 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 = { 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 = '' + ''.repeat(4) + ''; 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 = '' + ''.repeat(4) + ''; 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; }; 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 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( `` ); 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 whose min or max is past the last Excel column', () => { const cols = (attrs: string) => workbookZip( `` ); 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 = ''.repeat(12); const cases: Array<[string, RegExp]> = [ [`${pad}${pad}`, /column max/i], [`${pad}${pad}`, /column max/i], [`${pad}${pad}`, /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(``)).toThrowError(/cells limit/i); expect(() => push('')).toThrowError(/column max/i); expect(() => push(``)).toThrowError(/column max/i); // Anything the walk cannot read up to `>` is refused, as is a repeated name. expect(() => push('')).toThrowError(/do not parse/i); expect(() => push('')).toThrowError(/do not parse/i); expect(() => push('')).not.toThrow(); }); it('counts every , empty or not, against per-sheet and total caps', () => { const rows = (n: number) => '' + ''.repeat(n) + ''; 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); // / style names are not rows. expect( (core.admitXlsx(workbookZip(''), 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 that is not plain digits in 1-1048576 and a whose attributes do not parse', () => { const sheet = (rowTag: string) => workbookZip(`${rowTag}`); for (const r of ['50000000', '1048577', '0', '1a', '-1', '1e3', ' 2', '']) { expect(() => core.admitXlsx(sheet(``), fflate), r).toThrowError(/row index/i); } expect(() => core.admitXlsx(sheet(''), fflate)).toThrowError(/do not parse/i); // A quoted fake `r` cannot stand in for the real one. expect(() => core.admitXlsx(sheet(``), fflate)).toThrowError(/row index/i); for (const ok of ['', '', '']) { 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 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(`${sheets}`); return fflate.zipSync(entries); }; const one = (id: string) => ``; for (const id of ['30000000', '65536', '1a', '-1', '1e3', ' 1', '']) { expect(() => core.admitXlsx(book(one(id)), fflate), id).toThrowError(/sheetId/i); } // Every 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(''), fflate)).toThrowError(/do not parse/i); expect(() => core.admitXlsx(book(``), fflate)).toThrowError(/sheetId/i); // `` is the container, never a sheet; an absent sheetId is harmless. expect(() => core.admitXlsx(book(one('1') + one('65535') + ''), 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 run and in sharedStrings.xml, each , // and 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 = '' + ''.repeat(50) + ''; const fonts = '' + ''.repeat(50) + ''; const cases: Array<[string, string, number]> = [ ['xl/sharedStrings.xml', runs, 52], ['xl/comments1.xml', '' + ''.repeat(50) + '', 51], ['xl/drawings/vmlDrawing1.vml', '' + ''.repeat(50) + '', 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 / 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', '<_b/>'))).toBe(base + 2); expect(elementsOf(withPart('xl/other.xml', ''))).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(''.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 = '' + 'x'.repeat(20) + ''; 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('' + 'x'.repeat(600_000) + ''); 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 in styles.xml, so a 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 = '' + ''.repeat(200) + ''; 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) => ``).join(''); const sheetXml = (n: number) => `${merges(n)}`; 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 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 = `${numFmts}`; return () => counter.push(fflate.strToU8(xml), true); }; const fmt = (code: string) => ``; 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(``)).toThrowError(/unclosed bracket/i); expect(styles('')).toThrowError(/do not parse/i); // `` 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 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 = `${fmt('0[')}`; 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 = ` `; 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 = ''.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('', ``); 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('', '') ); 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('', '') ); 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('not a theme')).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({}); }); });