fix(preview): bound what ExcelJS expands during XLSX admission

ExcelJS 4.4.0 expands three constructs into one object per cell or column
at load time, so a few KB admitted as one cell could cost a gigabyte:

- a <mergeCell> now costs its full area against the per-sheet and total
  cell caps, and a ref that does not parse is refused
- a <col> whose min or max is past 16384 is refused
- the worker loads with ignoreNodes: ['dataValidations']; the preview
  never shows validations, and a whole-column dropdown took 5 s

The XML counter now scans up to the last complete tag and carries the
rest, so a merge or col tag cut by an inflate-chunk edge is read whole.
A central-directory compressedSize that runs past the file is refused,
since the ratio cap divides by it.

The renderer and core axis offsets use prefix sums with a binary search
instead of walking every override per call.
This commit is contained in:
Aamer Akhter
2026-10-01 09:18:35 -04:00
parent 4edb7b8f80
commit bfab172608
7 changed files with 259 additions and 27 deletions
+29 -6
View File
@@ -20,7 +20,7 @@
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '91b615278d5b';
const SPREADSHEET_ASSET_VERSION = '078d54b1055a';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
@@ -82,14 +82,37 @@
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
// Prefix sums per override list, built once per sheet's axis: renderTile
// asks for several offsets per cell, so a linear walk over every override
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
const axisDeltas = new WeakMap();
function overrideDeltas(overrides, defaultSize) {
let entry = axisDeltas.get(overrides);
if (!entry || entry.defaultSize !== defaultSize) {
const deltas = [];
let delta = 0;
for (const [, size] of overrides) {
delta += size - defaultSize;
deltas.push(delta);
}
entry = { defaultSize, deltas };
axisDeltas.set(overrides, entry);
}
return entry.deltas;
}
function axisOffset(count, defaultSize, overrides, index) {
const bounded = Math.max(1, Math.min(count + 1, index));
let value = (bounded - 1) * defaultSize;
for (const [overrideIndex, size] of overrides || []) {
if (overrideIndex >= bounded) break;
value += size - defaultSize;
const list = overrides || [];
let low = 0;
let high = list.length;
while (low < high) {
const mid = (low + high) >> 1;
if (list[mid][0] < bounded) low = mid + 1;
else high = mid;
}
return value;
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
}
function axisIndex(count, defaultSize, overrides, offset) {