fix(preview): index rows and cells by their present keys, cap theme size

ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only
cell sits in XFD is a dictionary-mode array that `eachCell` and
`hasValues` (behind `eachRow`) walk to index 16,384. The worker walked
each row four times at load and once per tile, so a small file of
far-column rows took seconds to load and to tile.

`worksheetMetadata` now builds each sheet's row and cell index from
`Object.keys(sheet._rows)` and `Object.keys(row._cells)`, sorted
numerically, skipping falsy and Null-type cells exactly as
`eachCell({ includeEmpty: false })` does and keeping a row only when it
holds one such cell (`hasValues`). Styles, extent and row heights come
from that one pass and merges from `sheet._merges`; `sendTile` reads
each row's cells from the index.

`parseThemePalette` returns the default palette for a theme above
64 * 1024 characters, since its patterns are quadratic on unclosed tags.
This commit is contained in:
Aamer Akhter
2026-10-03 17:21:41 -04:00
parent c1811fd716
commit 7d3e27fb6d
7 changed files with 193 additions and 23 deletions
+17
View File
@@ -412,6 +412,23 @@ const themeXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</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);