mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 10:49:41 +02:00
fix(preview): bound row and sheet indices before ExcelJS, reuse merges per tile, cap format decimals
ExcelJS stores a row at _rows[r - 1] and a sheet at _worksheets[sheetId], and walks or slices those arrays up to the largest index, so the index a row or sheet claims is a cost of its own. Admission now reads each <row> tag's attributes in order and refuses an r outside 1-1048576 (absent r is fine), and a counter for the resolved xl/workbook.xml reads every <sheet> tag and refuses one that does not parse or whose sheetId is not plain digits up to LIMITS.maxSheetId (65535). sendTile no longer reads sheet.model, which rebuilt every row and cell model on each tile: the merges read in worksheetMetadata are kept in mergesById next to populatedRowsById, replaced on load and cleared on dispose. Number formats cap decimals at 30, as Excel does; toLocaleString throws a RangeError above 100 and the whole grid was replaced by the error. Docs: CLAUDE.md and architecture-invariants describe both bounds and the merge reuse. SPREADSHEET_ASSET_VERSION is recomputed for the edited worker and core.
This commit is contained in:
@@ -25,6 +25,9 @@ const core = self.CodemanSpreadsheetXlsxCore;
|
||||
let workbook = null;
|
||||
let sheetsById = new Map();
|
||||
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.
|
||||
let mergesById = new Map();
|
||||
let normalizedStyles = [];
|
||||
let styleIds = new Map();
|
||||
let themePalette = core.DEFAULT_THEME_PALETTE;
|
||||
@@ -100,6 +103,7 @@ function worksheetMetadata(sheet) {
|
||||
}
|
||||
return {
|
||||
populatedRows,
|
||||
merges,
|
||||
metadata: {
|
||||
id: String(sheet.id),
|
||||
name: sheet.name,
|
||||
@@ -126,8 +130,8 @@ async function loadWorkbook(bytes) {
|
||||
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
|
||||
const nextWorkbook = new self.ExcelJS.Workbook();
|
||||
// ExcelJS's DefinedNames model setter expands every range into one object per
|
||||
// cell (a whole-sheet name exhausts the heap), and admission never scans
|
||||
// xl/workbook.xml. The preview never shows defined names, so they are not
|
||||
// cell (a whole-sheet name exhausts the heap), and admission reads only the
|
||||
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
|
||||
// stored at all; print areas and titles are split off before this setter runs.
|
||||
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||
// than silently expanding again.
|
||||
@@ -142,6 +146,7 @@ async function loadWorkbook(bytes) {
|
||||
});
|
||||
const nextSheets = new Map();
|
||||
const nextRows = new Map();
|
||||
const nextMerges = new Map();
|
||||
normalizedStyles = [];
|
||||
styleIds = new Map();
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||
@@ -152,11 +157,13 @@ async function loadWorkbook(bytes) {
|
||||
const metadata = sheetResult.metadata;
|
||||
nextSheets.set(metadata.id, sheet);
|
||||
nextRows.set(metadata.id, sheetResult.populatedRows);
|
||||
nextMerges.set(metadata.id, sheetResult.merges);
|
||||
sheets.push(metadata);
|
||||
}
|
||||
workbook = nextWorkbook;
|
||||
sheetsById = nextSheets;
|
||||
populatedRowsById = nextRows;
|
||||
mergesById = nextMerges;
|
||||
self.postMessage({
|
||||
type: 'metadata',
|
||||
sheets,
|
||||
@@ -211,7 +218,7 @@ function sendTile(message) {
|
||||
addCell(cell);
|
||||
});
|
||||
}
|
||||
const merges = core.intersectingMerges(Array.from(sheet.model?.merges || []), range);
|
||||
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
|
||||
for (const merge of merges) {
|
||||
const anchor = core.parseRange(merge);
|
||||
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
|
||||
@@ -236,6 +243,7 @@ self.onmessage = async (event) => {
|
||||
workbook = null;
|
||||
sheetsById = new Map();
|
||||
populatedRowsById = new Map();
|
||||
mergesById = new Map();
|
||||
themePalette = core.DEFAULT_THEME_PALETTE;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '2bb0eff45e39';
|
||||
const SPREADSHEET_ASSET_VERSION = '5bd72f3f823f';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
maxRowsPerSheet: 100000,
|
||||
maxMergesPerSheet: 5000,
|
||||
maxStyles: 5000,
|
||||
// ExcelJS stores each sheet at `_worksheets[sheetId]`, so the id is an array
|
||||
// length. Excel numbers sheets from 1 and never reuses an id, so real ids
|
||||
// stay small; 65535 leaves room for heavy editing at a negligible cost.
|
||||
maxSheetId: 65535,
|
||||
});
|
||||
const MAX_ROW = 1048576;
|
||||
const MAX_COL = 16384;
|
||||
@@ -216,6 +220,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ExcelJS stores each row at `_rows[r - 1]`, and eachRow and `sheet.model`
|
||||
// walk every index up to the largest, so the index a row CLAIMS is a cost.
|
||||
function checkRowIndex(attributes) {
|
||||
const value = attributes.get('r');
|
||||
if (value === undefined) return;
|
||||
if (!/^[0-9]+$/.test(value) || Number(value) < 1 || Number(value) > MAX_ROW) {
|
||||
fail('malformed', `Worksheet row index is outside 1-${MAX_ROW}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ExcelJS stores each sheet at `_worksheets[sheetId]` (an absent id parses to
|
||||
// NaN, a plain property, so it is harmless).
|
||||
function checkSheetId(attributes, limits) {
|
||||
const value = attributes.get('sheetId');
|
||||
if (value === undefined) return;
|
||||
if (!/^[0-9]+$/.test(value) || Number(value) > limits.maxSheetId) {
|
||||
fail('malformed', `Workbook sheetId is not a number up to ${limits.maxSheetId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createXmlCounter(name, counts, limits) {
|
||||
let tail = '';
|
||||
const decoder = new TextDecoder();
|
||||
@@ -225,6 +249,7 @@
|
||||
const excelJsName = excelJsEntryName(name);
|
||||
const worksheet = isWorksheetName(excelJsName);
|
||||
const styles = excelJsName === 'xl/styles.xml';
|
||||
const workbook = excelJsName === 'xl/workbook.xml';
|
||||
const addCells = (cells) => {
|
||||
sheetCells += cells;
|
||||
counts.cells += cells;
|
||||
@@ -233,7 +258,7 @@
|
||||
};
|
||||
return {
|
||||
push(chunk, final) {
|
||||
if (!worksheet && !styles) return;
|
||||
if (!worksheet && !styles && !workbook) return;
|
||||
const text = tail + decoder.decode(chunk, { stream: !final });
|
||||
// `<` can never appear inside an attribute value, so every tag before the
|
||||
// last `<` is complete. Carry everything from that `<` into the next scan
|
||||
@@ -248,9 +273,13 @@
|
||||
counts.rows += rows;
|
||||
if (sheetRows > limits.maxRowsPerSheet || counts.rows > limits.maxRows)
|
||||
fail('row-limit', 'Workbook exceeds the rows limit');
|
||||
for (const match of scan.matchAll(/<(mergeCell|col)(?=[\s/>])/g)) {
|
||||
for (const match of scan.matchAll(/<(mergeCell|col|row)(?=[\s/>])/g)) {
|
||||
const attributes = readTagAttributes(scan, match.index + match[0].length);
|
||||
if (!attributes) fail('malformed', `Worksheet has a <${match[1]}> whose attributes do not parse`);
|
||||
if (match[1] === 'row') {
|
||||
checkRowIndex(attributes);
|
||||
continue;
|
||||
}
|
||||
if (match[1] === 'col') {
|
||||
checkColumnSpan(attributes);
|
||||
continue;
|
||||
@@ -262,6 +291,14 @@
|
||||
addCells(mergeArea(attributes));
|
||||
}
|
||||
}
|
||||
if (workbook) {
|
||||
// `<sheets>` is the container; the lookahead keeps it from matching.
|
||||
for (const match of scan.matchAll(/<sheet(?=[\s/>])/g)) {
|
||||
const attributes = readTagAttributes(scan, match.index + match[0].length);
|
||||
if (!attributes) fail('malformed', 'Workbook has a <sheet> whose attributes do not parse');
|
||||
checkSheetId(attributes, limits);
|
||||
}
|
||||
}
|
||||
if (styles) {
|
||||
// Every <xf> counts, cellStyleXfs included: tracking which list a tag
|
||||
// sits in can be desynced by a closing tag inside an XML comment.
|
||||
@@ -548,7 +585,8 @@
|
||||
return { text: `${yyyy}-${mm}-${dd} ${hh}:${minutes}` };
|
||||
}
|
||||
const percent = code.includes('%');
|
||||
const decimals = code.match(/\.([0#]+)/)?.[1].length || 0;
|
||||
// toLocaleString throws above 100 fraction digits; Excel itself caps at 30.
|
||||
const decimals = Math.min(code.match(/\.([0#]+)/)?.[1].length || 0, 30);
|
||||
const numericPattern = /^[€£¥$]?[#,0]+(?:\.[0#]+)?%?$/;
|
||||
if (numericPattern.test(code)) {
|
||||
const currency = /^[€£¥$]/.exec(code)?.[0] || '';
|
||||
|
||||
Reference in New Issue
Block a user