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 committed 2026-10-03 17:21:41 -04:00
1 parent c1811fd716
commit 7d3e27fb6d
7 files changed
+193 -23

No files matched your search

+55 -20
View File
@@ -24,6 +24,8 @@ importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
// Per sheet: its populated rows in order, each with its populated cells in
// column order, built once at load from the keys that exist (`populatedRowIndex`).
let populatedRowsById = new Map();
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
// which is far too much to pay on every tile.
@@ -78,23 +80,56 @@ function normalizeStyle(cell) {
return id;
}
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
function presentIndices(sparse) {
const indices = [];
for (const key of Object.keys(sparse || [])) {
const index = Number(key);
if (Number.isInteger(index) && index >= 0) indices.push(index);
}
return indices.sort((a, b) => a - b);
}
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
// cell counts when it exists and its type is not `ValueType.Null`, and a row
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
function populatedRowIndex(sheet) {
const nullType = self.ExcelJS.ValueType.Null;
const rows = [];
for (const rowIndex of presentIndices(sheet._rows)) {
const row = sheet._rows[rowIndex];
if (!row) continue;
const cells = [];
for (const cellIndex of presentIndices(row._cells)) {
const cell = row._cells[cellIndex];
if (cell && cell.type !== nullType) cells.push(cell);
}
if (cells.length > 0) rows.push({ number: row.number, row, cells });
}
return rows;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const populatedRows = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
populatedRows.push(row.number);
row.eachCell({ includeEmpty: false }, (cell) => {
const rowOverrides = [];
const populatedRows = populatedRowIndex(sheet);
for (const { number, row, cells } of populatedRows) {
for (const cell of cells) {
cellRefs.push(cell.address);
normalizeStyle(cell);
});
});
const merges = Array.from(sheet.model?.merges || []);
}
if (row.hidden) rowOverrides.push([number, 0]);
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
}
// `sheet.model` rebuilds every row and cell model, so merges come straight
// from ExcelJS's own merge map, in the order the model getter would list them.
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
const extent = core.deriveExtent(cellRefs, merges);
const rowOverrides = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
if (row.hidden) rowOverrides.push([row.number, 0]);
else if (row.height) rowOverrides.push([row.number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
});
const columnOverrides = [];
for (let col = 1; col <= extent.cols; col += 1) {
const column = sheet.getColumn(col);
@@ -207,16 +242,16 @@ function sendTile(message) {
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const rowNumber of populatedRows) {
for (const populated of populatedRows) {
if (truncated) break;
if (rowNumber < range.r1) continue;
if (rowNumber > range.r2) break;
const row = sheet.getRow(rowNumber);
if (row.hidden) continue;
row.eachCell({ includeEmpty: false }, (cell) => {
if (cell.col < range.c1 || cell.col > range.c2) return;
if (populated.number < range.r1) continue;
if (populated.number > range.r2) break;
if (populated.row.hidden) continue;
for (const cell of populated.cells) {
if (cell.col < range.c1) continue;
if (cell.col > range.c2) break;
addCell(cell);
});
}
}
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
for (const merge of merges) {
+1 -1
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '5bd72f3f823f';
const SPREADSHEET_ASSET_VERSION = '6e843e269018';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
+8
View File
@@ -744,9 +744,17 @@
return undefined;
}
// Real theme1.xml files are under 10 KB. The scheme and slot patterns below
// rescan to the end of the text for every opening tag that has no close, so a
// padded theme is quadratic (1 MB of `<a:clrScheme>` took 21.8 s); above this
// many characters (UTF-16 code units of the decoded XML) the default palette
// is used instead.
const MAX_THEME_XML_CHARS = 64 * 1024;
function parseThemePalette(xml) {
const palette = DEFAULT_THEME_PALETTE.slice();
const text = typeof xml === 'string' ? xml : '';
if (text.length > MAX_THEME_XML_CHARS) return palette;
const scheme = /<(?:[A-Za-z0-9_]+:)?clrScheme\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?clrScheme\s*>/.exec(text);
if (!scheme) return palette;
// Fresh pattern per call: a shared /g regex would carry lastIndex across calls.