mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix(preview): budget every start tag ExcelJS will parse
Admission counted cells, rows, merges and styles only in worksheets, styles.xml and workbook.xml, so the objects ExcelJS builds per element elsewhere (shared-string runs, fonts, fills, borders, comments, drawings, VML, tables) were bounded only by the inflated-byte caps, and empty stored deflate blocks pad a stream past the ratio cap. createXmlCounter now counts every start tag in every part except the pure-bytes xl/media/<name>.<ext> entries into counts.elements and refuses above LIMITS.maxElements (2,000,000) as element-limit. Parts that read no attributes carry only a trailing '<' between chunks, so long text or binary is never taken for an oversized tag. Very tall sheets now scale only the scroll position: the scroll range maps onto the sheet's whole range and the tile is laid out at real row heights and column widths, with spans clipped at the spacer, instead of dividing every cell and heading by the scale.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -20,7 +20,7 @@
|
|||||||
(function initSpreadsheetPreview(global) {
|
(function initSpreadsheetPreview(global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const SPREADSHEET_ASSET_VERSION = '4d543b11c25c';
|
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||||
const DEFAULT_TIMEOUT_MS = 20000;
|
const DEFAULT_TIMEOUT_MS = 20000;
|
||||||
const MAX_SCROLL_PX = 8000000;
|
const MAX_SCROLL_PX = 8000000;
|
||||||
@@ -62,9 +62,8 @@
|
|||||||
let headingsLayer = null;
|
let headingsLayer = null;
|
||||||
let emptySheetState = null;
|
let emptySheetState = null;
|
||||||
let resizeObserver = null;
|
let resizeObserver = null;
|
||||||
let scaleX = 1;
|
|
||||||
let scaleY = 1;
|
|
||||||
let latestRange = null;
|
let latestRange = null;
|
||||||
|
let latestAxes = null;
|
||||||
let scrollFrame = null;
|
let scrollFrame = null;
|
||||||
|
|
||||||
const current = () => !disposed && isCurrent();
|
const current = () => !disposed && isCurrent();
|
||||||
@@ -126,17 +125,37 @@
|
|||||||
return low;
|
return low;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
|
||||||
|
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
|
||||||
|
// range, so the last row stays reachable) and the tile is laid out at real
|
||||||
|
// sizes from there. `shift` is the logical offset minus the scroll offset,
|
||||||
|
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
|
||||||
|
function scrollAxis(logical, scroll, viewport, heading) {
|
||||||
|
const shown = Math.min(MAX_SCROLL_PX, logical);
|
||||||
|
const scrollRange = Math.max(0, heading + shown - viewport);
|
||||||
|
const logicalRange = Math.max(0, heading + logical - viewport);
|
||||||
|
const virtual =
|
||||||
|
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
|
||||||
|
return { virtual, shift: virtual - scroll, end: heading + shown };
|
||||||
|
}
|
||||||
|
|
||||||
function requestTile() {
|
function requestTile() {
|
||||||
if (!current() || !worker || !grid) return;
|
if (!current() || !worker || !grid) return;
|
||||||
const sheet = sheetMetadata();
|
const sheet = sheetMetadata();
|
||||||
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
||||||
scaleY = Math.max(
|
const viewHeight = grid.clientHeight || 500;
|
||||||
1,
|
const viewWidth = grid.clientWidth || 800;
|
||||||
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1) / MAX_SCROLL_PX
|
const y = scrollAxis(
|
||||||
|
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
|
||||||
|
grid.scrollTop,
|
||||||
|
viewHeight,
|
||||||
|
COLUMN_HEADING_HEIGHT
|
||||||
);
|
);
|
||||||
scaleX = Math.max(
|
const x = scrollAxis(
|
||||||
1,
|
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
|
||||||
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1) / MAX_SCROLL_PX
|
grid.scrollLeft,
|
||||||
|
viewWidth,
|
||||||
|
ROW_HEADING_WIDTH
|
||||||
);
|
);
|
||||||
const r1 = Math.max(
|
const r1 = Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -144,7 +163,7 @@
|
|||||||
sheet.rows,
|
sheet.rows,
|
||||||
sheet.defaultRowHeight,
|
sheet.defaultRowHeight,
|
||||||
sheet.rowOverrides,
|
sheet.rowOverrides,
|
||||||
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT) * scaleY
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
|
||||||
) - 2
|
) - 2
|
||||||
);
|
);
|
||||||
const c1 = Math.max(
|
const c1 = Math.max(
|
||||||
@@ -153,7 +172,7 @@
|
|||||||
sheet.cols,
|
sheet.cols,
|
||||||
sheet.defaultColumnWidth,
|
sheet.defaultColumnWidth,
|
||||||
sheet.columnOverrides,
|
sheet.columnOverrides,
|
||||||
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH) * scaleX
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
|
||||||
) - 2
|
) - 2
|
||||||
);
|
);
|
||||||
const r2 = Math.min(
|
const r2 = Math.min(
|
||||||
@@ -162,7 +181,7 @@
|
|||||||
sheet.rows,
|
sheet.rows,
|
||||||
sheet.defaultRowHeight,
|
sheet.defaultRowHeight,
|
||||||
sheet.rowOverrides,
|
sheet.rowOverrides,
|
||||||
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT + (grid.clientHeight || 500)) * scaleY
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
|
||||||
) + 2
|
) + 2
|
||||||
);
|
);
|
||||||
const c2 = Math.min(
|
const c2 = Math.min(
|
||||||
@@ -171,11 +190,12 @@
|
|||||||
sheet.cols,
|
sheet.cols,
|
||||||
sheet.defaultColumnWidth,
|
sheet.defaultColumnWidth,
|
||||||
sheet.columnOverrides,
|
sheet.columnOverrides,
|
||||||
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH + (grid.clientWidth || 800)) * scaleX
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
|
||||||
) + 2
|
) + 2
|
||||||
);
|
);
|
||||||
latestRequestId += 1;
|
latestRequestId += 1;
|
||||||
latestRange = { r1, c1, r2, c2 };
|
latestRange = { r1, c1, r2, c2 };
|
||||||
|
latestAxes = { y, x };
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
type: 'tile',
|
type: 'tile',
|
||||||
requestId: latestRequestId,
|
requestId: latestRequestId,
|
||||||
@@ -207,7 +227,16 @@
|
|||||||
function renderTile(tile) {
|
function renderTile(tile) {
|
||||||
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
|
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
|
||||||
const sheet = sheetMetadata();
|
const sheet = sheetMetadata();
|
||||||
if (!sheet || !cellsLayer || !headingsLayer || !latestRange) return;
|
if (!sheet || !cellsLayer || !headingsLayer || !latestRange || !latestAxes) return;
|
||||||
|
const { y, x } = latestAxes;
|
||||||
|
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
|
||||||
|
// it never grows the scroll area.
|
||||||
|
const rowTop = (row) =>
|
||||||
|
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
|
||||||
|
const colLeft = (col) =>
|
||||||
|
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
|
||||||
|
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
|
||||||
|
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
|
||||||
cellsLayer.textContent = '';
|
cellsLayer.textContent = '';
|
||||||
headingsLayer.textContent = '';
|
headingsLayer.textContent = '';
|
||||||
const mergeByAnchor = new Map();
|
const mergeByAnchor = new Map();
|
||||||
@@ -227,13 +256,11 @@
|
|||||||
element.dataset.row = String(cell.row);
|
element.dataset.row = String(cell.row);
|
||||||
element.dataset.col = String(cell.col);
|
element.dataset.col = String(cell.col);
|
||||||
element.textContent = String(cell.text ?? '');
|
element.textContent = String(cell.text ?? '');
|
||||||
element.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, cell.row) / scaleY}px`;
|
element.style.top = `${rowTop(cell.row)}px`;
|
||||||
element.style.left = `${ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col) / scaleX}px`;
|
element.style.left = `${colLeft(cell.col)}px`;
|
||||||
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
||||||
const finalRow = merge?.r2 || cell.row;
|
element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`;
|
||||||
const finalCol = merge?.c2 || cell.col;
|
element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`;
|
||||||
element.style.height = `${Math.max(0, (axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, finalRow + 1) - axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, cell.row)) / scaleY)}px`;
|
|
||||||
element.style.width = `${Math.max(0, (axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, finalCol + 1) - axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col)) / scaleX)}px`;
|
|
||||||
cellsLayer.appendChild(element);
|
cellsLayer.appendChild(element);
|
||||||
}
|
}
|
||||||
// Headings take their size from the same axis math as the cells, so custom
|
// Headings take their size from the same axis math as the cells, so custom
|
||||||
@@ -241,23 +268,20 @@
|
|||||||
// do not count against the heading caps.
|
// do not count against the heading caps.
|
||||||
let rowHeadings = 0;
|
let rowHeadings = 0;
|
||||||
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
||||||
const top = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row);
|
const height = rowSpan(row, row);
|
||||||
const height = (axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row + 1) - top) / scaleY;
|
|
||||||
if (height <= 0) continue;
|
if (height <= 0) continue;
|
||||||
rowHeadings += 1;
|
rowHeadings += 1;
|
||||||
const heading = document.createElement('div');
|
const heading = document.createElement('div');
|
||||||
heading.className = 'spreadsheet-row-heading';
|
heading.className = 'spreadsheet-row-heading';
|
||||||
heading.textContent = String(row);
|
heading.textContent = String(row);
|
||||||
heading.style.top = `${COLUMN_HEADING_HEIGHT + top / scaleY}px`;
|
heading.style.top = `${rowTop(row)}px`;
|
||||||
heading.style.height = `${height}px`;
|
heading.style.height = `${height}px`;
|
||||||
heading.style.left = `${grid.scrollLeft}px`;
|
heading.style.left = `${grid.scrollLeft}px`;
|
||||||
headingsLayer.appendChild(heading);
|
headingsLayer.appendChild(heading);
|
||||||
}
|
}
|
||||||
let columnHeadings = 0;
|
let columnHeadings = 0;
|
||||||
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||||
const left = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col);
|
const width = colSpan(col, col);
|
||||||
const width =
|
|
||||||
(axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col + 1) - left) / scaleX;
|
|
||||||
if (width <= 0) continue;
|
if (width <= 0) continue;
|
||||||
columnHeadings += 1;
|
columnHeadings += 1;
|
||||||
const heading = document.createElement('div');
|
const heading = document.createElement('div');
|
||||||
@@ -266,7 +290,7 @@
|
|||||||
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
||||||
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
|
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
|
||||||
heading.textContent = label;
|
heading.textContent = label;
|
||||||
heading.style.left = `${ROW_HEADING_WIDTH + left / scaleX}px`;
|
heading.style.left = `${colLeft(col)}px`;
|
||||||
heading.style.width = `${width}px`;
|
heading.style.width = `${width}px`;
|
||||||
heading.style.top = `${grid.scrollTop}px`;
|
heading.style.top = `${grid.scrollTop}px`;
|
||||||
headingsLayer.appendChild(heading);
|
headingsLayer.appendChild(heading);
|
||||||
@@ -279,6 +303,7 @@
|
|||||||
activeSheetId = String(sheetId);
|
activeSheetId = String(sheetId);
|
||||||
latestRequestId += 1;
|
latestRequestId += 1;
|
||||||
latestRange = null;
|
latestRange = null;
|
||||||
|
latestAxes = null;
|
||||||
if (cellsLayer) cellsLayer.textContent = '';
|
if (cellsLayer) cellsLayer.textContent = '';
|
||||||
if (headingsLayer) headingsLayer.textContent = '';
|
if (headingsLayer) headingsLayer.textContent = '';
|
||||||
container.querySelectorAll('[role="tab"]').forEach((tab) => {
|
container.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||||
@@ -294,8 +319,6 @@
|
|||||||
if (sheet && spacer) {
|
if (sheet && spacer) {
|
||||||
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
|
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
|
||||||
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
|
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
|
||||||
scaleY = Math.max(1, logicalHeight / MAX_SCROLL_PX);
|
|
||||||
scaleX = Math.max(1, logicalWidth / MAX_SCROLL_PX);
|
|
||||||
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
|
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
|
||||||
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
|
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
// its whole string to the page (a 1 MB shared string over a 60 x 20 block
|
// its whole string to the page (a 1 MB shared string over a 60 x 20 block
|
||||||
// froze the page's main thread at 9.6 GB).
|
// froze the page's main thread at 9.6 GB).
|
||||||
maxCellTextChars: 1000,
|
maxCellTextChars: 1000,
|
||||||
|
// Start tags across every part ExcelJS parses (all but xl/media). ExcelJS
|
||||||
|
// builds an object per element, and outside worksheets nothing else bounded
|
||||||
|
// them: one shared string of 8.3M empty `<r/>` runs (a 375 KB file, padded
|
||||||
|
// past the ratio cap with empty stored blocks) cost 570 MB of heap. A
|
||||||
|
// three-sheet 249k-cell workbook with rich text has about 589k.
|
||||||
|
maxElements: 2000000,
|
||||||
});
|
});
|
||||||
const MAX_ROW = 1048576;
|
const MAX_ROW = 1048576;
|
||||||
const MAX_COL = 16384;
|
const MAX_COL = 16384;
|
||||||
@@ -281,6 +287,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exactly the names ExcelJS hands to `_processMediaEntry` and to no parser:
|
||||||
|
// its other patterns are unanchored, so `xl/media/xl/drawings/a.xml` is still
|
||||||
|
// parsed as a drawing, and only a single `<name>.<ext>` segment is pure bytes.
|
||||||
|
const EXCELJS_MEDIA = /^xl\/media\/[a-zA-Z0-9]+[.][a-zA-Z0-9]{3,4}$/;
|
||||||
|
const START_TAG = /<[A-Za-z_]/g;
|
||||||
|
|
||||||
|
function countStartTags(text) {
|
||||||
|
let count = 0;
|
||||||
|
START_TAG.lastIndex = 0;
|
||||||
|
while (START_TAG.exec(text)) count += 1;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
function createXmlCounter(name, counts, limits) {
|
function createXmlCounter(name, counts, limits) {
|
||||||
let tail = '';
|
let tail = '';
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -291,6 +310,9 @@
|
|||||||
const worksheet = isWorksheetName(excelJsName);
|
const worksheet = isWorksheetName(excelJsName);
|
||||||
const styles = excelJsName === 'xl/styles.xml';
|
const styles = excelJsName === 'xl/styles.xml';
|
||||||
const workbook = excelJsName === 'xl/workbook.xml';
|
const workbook = excelJsName === 'xl/workbook.xml';
|
||||||
|
const media = EXCELJS_MEDIA.test(excelJsName);
|
||||||
|
// Only these parts read tag attributes, so only they carry a whole tag.
|
||||||
|
const readsTags = worksheet || styles || workbook;
|
||||||
const addCells = (cells) => {
|
const addCells = (cells) => {
|
||||||
sheetCells += cells;
|
sheetCells += cells;
|
||||||
counts.cells += cells;
|
counts.cells += cells;
|
||||||
@@ -299,13 +321,19 @@
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
push(chunk, final) {
|
push(chunk, final) {
|
||||||
if (!worksheet && !styles && !workbook) return;
|
if (media) return;
|
||||||
const text = tail + decoder.decode(chunk, { stream: !final });
|
const text = tail + decoder.decode(chunk, { stream: !final });
|
||||||
// `<` can never appear inside an attribute value, so every tag before the
|
// `<` can never appear inside an attribute value, so every tag before the
|
||||||
// last `<` is complete. Carry everything from that `<` into the next scan
|
// last `<` is complete. Carry everything from that `<` into the next scan
|
||||||
// so a tag cut by a chunk edge is always read in one piece.
|
// so a tag cut by a chunk edge is always read in one piece. Any other part
|
||||||
const safeEnd = final ? text.length : Math.max(0, text.lastIndexOf('<'));
|
// only needs its start tags counted, so it carries at most a trailing `<`
|
||||||
|
// (a long text node or a binary part is never mistaken for a huge tag).
|
||||||
|
let safeEnd = text.length;
|
||||||
|
if (!final && readsTags) safeEnd = Math.max(0, text.lastIndexOf('<'));
|
||||||
|
else if (!final && text.endsWith('<')) safeEnd = text.length - 1;
|
||||||
const scan = text.slice(0, safeEnd);
|
const scan = text.slice(0, safeEnd);
|
||||||
|
counts.elements += countStartTags(scan);
|
||||||
|
if (counts.elements > limits.maxElements) fail('element-limit', 'Workbook exceeds the XML elements limit');
|
||||||
if (worksheet) {
|
if (worksheet) {
|
||||||
addCells((scan.match(/<c(?:\s|>)/g) || []).length);
|
addCells((scan.match(/<c(?:\s|>)/g) || []).length);
|
||||||
// ExcelJS keeps a Row object for every <row>, with or without cells.
|
// ExcelJS keeps a Row object for every <row>, with or without cells.
|
||||||
@@ -366,7 +394,7 @@
|
|||||||
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
|
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
|
||||||
const streamedEntries = new Map();
|
const streamedEntries = new Map();
|
||||||
const inflatedEntries = Object.create(null);
|
const inflatedEntries = Object.create(null);
|
||||||
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0 };
|
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||||
const features = new Set();
|
const features = new Set();
|
||||||
let totalInflated = 0;
|
let totalInflated = 0;
|
||||||
let seenEntries = 0;
|
let seenEntries = 0;
|
||||||
|
|||||||
@@ -1008,3 +1008,65 @@ describe('spreadsheet preview worker: the notice bar stays bounded', () => {
|
|||||||
expect(tile.warnings).toEqual(['800 unsupported number formats']);
|
expect(tile.warnings).toEqual(['800 unsupported number formats']);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-cell workbook plus an `xl/sharedStrings.xml` whose one string holds
|
||||||
|
* `runs` empty `<r/>` runs. Its deflate stream is prefixed with empty stored
|
||||||
|
* blocks (5 bytes each, inflating to nothing) until the compressed size clears
|
||||||
|
* the 100:1 ratio cap, so only an element budget can refuse it.
|
||||||
|
*/
|
||||||
|
async function emptyRunsWorkbook(runs: number): Promise<Uint8Array> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.addWorksheet('Data').getCell('A1').value = 1;
|
||||||
|
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
||||||
|
delete entries['xl/sharedStrings.xml'];
|
||||||
|
const strings = fflate.strToU8(`<sst count="1" uniqueCount="1"><si>${'<r/>'.repeat(runs)}</si></sst>`);
|
||||||
|
const deflated = fflate.deflateSync(strings, { level: 9 });
|
||||||
|
const emptyStoredBlock = Uint8Array.from([0x00, 0x00, 0x00, 0xff, 0xff]);
|
||||||
|
const blocks = Math.ceil((strings.length / 50 - deflated.length) / emptyStoredBlock.length);
|
||||||
|
const padded = concatBytes([...Array.from({ length: blocks }, () => emptyStoredBlock), deflated]);
|
||||||
|
expect(fflate.inflateSync(padded)).toEqual(strings);
|
||||||
|
const parts = Object.keys(entries).map((name) => zipPart(name, entries[name], 8));
|
||||||
|
parts.push({
|
||||||
|
name: 'xl/sharedStrings.xml',
|
||||||
|
data: padded,
|
||||||
|
method: 8,
|
||||||
|
size: strings.length,
|
||||||
|
crc: crc32(strings) >>> 0,
|
||||||
|
});
|
||||||
|
const locals: Uint8Array[] = [];
|
||||||
|
const central: Uint8Array[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
central.push(centralHeader(part, offset));
|
||||||
|
const chunk = concatBytes([localHeader(part), part.data]);
|
||||||
|
locals.push(chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
const directory = concatBytes(central);
|
||||||
|
const eocd = new Uint8Array(22);
|
||||||
|
const view = new DataView(eocd.buffer);
|
||||||
|
view.setUint32(0, 0x06054b50, true);
|
||||||
|
view.setUint16(8, central.length, true);
|
||||||
|
view.setUint16(10, central.length, true);
|
||||||
|
view.setUint32(12, directory.length, true);
|
||||||
|
view.setUint32(16, offset, true);
|
||||||
|
return concatBytes([...locals, directory, eocd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('spreadsheet preview worker: element budget', () => {
|
||||||
|
// ExcelJS builds one object per <r> run in sharedStrings.xml, which no
|
||||||
|
// worksheet counter sees; 8.3M empty runs took 570 MB of heap to load.
|
||||||
|
it('refuses a shared string of 2,000,001 empty runs before ExcelJS loads', async () => {
|
||||||
|
const crafted = await emptyRunsWorkbook(2_000_001);
|
||||||
|
const harness = createHarness();
|
||||||
|
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
||||||
|
admitXlsx(bytes: Uint8Array, zip: typeof fflate, overrides?: Record<string, number>): unknown;
|
||||||
|
};
|
||||||
|
// Every other cap admits it: only the element budget is in the way.
|
||||||
|
expect(() => core.admitXlsx(crafted, fflate, { maxElements: Infinity })).not.toThrow();
|
||||||
|
await harness.send({ type: 'load', bytes: toArrayBuffer(crafted) });
|
||||||
|
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'element-limit' });
|
||||||
|
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -291,6 +291,81 @@ describe('spreadsheet preview renderer', () => {
|
|||||||
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX only the scroll position may be scaled: dividing every
|
||||||
|
// cell and heading by the scale drew a 1,048,576-row sheet 7.6 px a row.
|
||||||
|
it('lays rows out at their real height on a sheet taller than the scroll cap', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const cellAt = (row: number) => document.querySelector(`.spreadsheet-cell[data-row="${row}"]`) as HTMLElement;
|
||||||
|
const rowHeading = (row: number) =>
|
||||||
|
[...document.querySelectorAll('.spreadsheet-row-heading')].find(
|
||||||
|
(element) => element.textContent === String(row)
|
||||||
|
) as HTMLElement;
|
||||||
|
const px = (value: string) => Number.parseFloat(value);
|
||||||
|
|
||||||
|
const first = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(first.range.r1).toBe(1);
|
||||||
|
const cell = (row: number) => ({ row, col: 1, text: `A${row}`, styleId: 0 });
|
||||||
|
worker.emit({ type: 'tile', requestId: first.requestId, sheetId: '1', cells: [cell(1), cell(2)], warnings: [] });
|
||||||
|
expect(cellAt(1).style.top).toBe('20px');
|
||||||
|
expect(cellAt(1).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(2).style.top) - px(cellAt(1).style.top)).toBe(20);
|
||||||
|
expect(rowHeading(2).style.height).toBe('20px');
|
||||||
|
// At scale 1 the first tile asks for about a viewport of rows, not a scaled one.
|
||||||
|
expect(first.range.r2).toBeLessThan(40);
|
||||||
|
|
||||||
|
// Scrolled to the end, the last row is requested, drawn at its real height,
|
||||||
|
// and ends exactly at the bottom of the scroll area.
|
||||||
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
||||||
|
const spacerHeight = px((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height);
|
||||||
|
grid.scrollTop = spacerHeight - 500;
|
||||||
|
grid.dispatchEvent(new window.Event('scroll'));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(worker.postMessage.mock.calls.at(-1)?.[0].requestId).toBeGreaterThan(first.requestId)
|
||||||
|
);
|
||||||
|
const last = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(last.range.r2).toBe(1_048_576);
|
||||||
|
expect(last.range.r2 - last.range.r1).toBeLessThan(40);
|
||||||
|
worker.emit({ type: 'tile', requestId: last.requestId, sheetId: '1', cells: [cell(1_048_576)], warnings: [] });
|
||||||
|
expect(cellAt(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(rowHeading(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(1_048_576).style.top) + 20).toBeCloseTo(spacerHeight, 6);
|
||||||
|
expect(px(rowHeading(1_048_575).style.top)).toBeCloseTo(px(cellAt(1_048_576).style.top) - 20, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a merge spanning a too-tall sheet inside the scroll area', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 1, col: 1, text: 'whole column', styleId: 0 }],
|
||||||
|
merges: ['A1:A1048576'],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
const merged = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||||
|
const spacerHeight = Number.parseFloat(
|
||||||
|
(document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(merged.style.top) + Number.parseFloat(merged.style.height)).toBeLessThanOrEqual(
|
||||||
|
spacerHeight
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('emits colour and background together or not at all', async () => {
|
it('emits colour and background together or not at all', async () => {
|
||||||
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
const renderer = loadRenderer(fetchMock);
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ describe('spreadsheet XLSX core', () => {
|
|||||||
features: string[];
|
features: string[];
|
||||||
};
|
};
|
||||||
// The 2x2 merge costs its four covered cells on top of the one real cell.
|
// The 2x2 merge costs its four covered cells on top of the one real cell.
|
||||||
expect(result.counts).toEqual({ worksheets: 1, cells: 5, rows: 0, merges: 1, styles: 1 });
|
// `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']));
|
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,6 +328,91 @@ describe('spreadsheet XLSX core', () => {
|
|||||||
expect(() => other.push(fflate.strToU8(one('30000000')), true)).not.toThrow();
|
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 <r> run and <si> in sharedStrings.xml, each <font>, <fill>
|
||||||
|
// and <border> 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 = '<sst><si>' + '<r/>'.repeat(50) + '</si></sst>';
|
||||||
|
const fonts = '<styleSheet><fonts>' + '<font/>'.repeat(50) + '</fonts></styleSheet>';
|
||||||
|
const cases: Array<[string, string, number]> = [
|
||||||
|
['xl/sharedStrings.xml', runs, 52],
|
||||||
|
['xl/comments1.xml', '<comments>' + '<comment/>'.repeat(50) + '</comments>', 51],
|
||||||
|
['xl/drawings/vmlDrawing1.vml', '<xml>' + '<v:shape/>'.repeat(50) + '</xml>', 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 <xf>/<numFmt> 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', '<?xml version="1.0"?><!-- c --><a></a><_b/>'))).toBe(base + 2);
|
||||||
|
expect(elementsOf(withPart('xl/other.xml', '<!-- <a> --><a/>'))).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('<r/>'.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 = '<sst><si>' + '<r/><t>x</t>'.repeat(20) + '</si></sst>';
|
||||||
|
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('<sst><si><t>' + 'x'.repeat(600_000) + '</t></si></sst>');
|
||||||
|
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 <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
|
it('counts every <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
|
||||||
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
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 counter = core.createXmlCounter('xl/styles.xml', counts as never, { ...core.LIMITS, maxStyles: 100 });
|
||||||
|
|||||||
Reference in New Issue
Block a user