mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01: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:
1 parent
c36be7bb94
commit
aad9c248dc
7 files changed
+310
-36
No files matched your search
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '4d543b11c25c';
|
||||
const SPREADSHEET_ASSET_VERSION = '911680fac09d';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
@@ -62,9 +62,8 @@
|
||||
let headingsLayer = null;
|
||||
let emptySheetState = null;
|
||||
let resizeObserver = null;
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
let latestRange = null;
|
||||
let latestAxes = null;
|
||||
let scrollFrame = null;
|
||||
|
||||
const current = () => !disposed && isCurrent();
|
||||
@@ -126,17 +125,37 @@
|
||||
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() {
|
||||
if (!current() || !worker || !grid) return;
|
||||
const sheet = sheetMetadata();
|
||||
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
||||
scaleY = Math.max(
|
||||
1,
|
||||
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1) / MAX_SCROLL_PX
|
||||
const viewHeight = grid.clientHeight || 500;
|
||||
const viewWidth = grid.clientWidth || 800;
|
||||
const y = scrollAxis(
|
||||
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
|
||||
grid.scrollTop,
|
||||
viewHeight,
|
||||
COLUMN_HEADING_HEIGHT
|
||||
);
|
||||
scaleX = Math.max(
|
||||
1,
|
||||
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1) / MAX_SCROLL_PX
|
||||
const x = scrollAxis(
|
||||
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
|
||||
grid.scrollLeft,
|
||||
viewWidth,
|
||||
ROW_HEADING_WIDTH
|
||||
);
|
||||
const r1 = Math.max(
|
||||
1,
|
||||
@@ -144,7 +163,7 @@
|
||||
sheet.rows,
|
||||
sheet.defaultRowHeight,
|
||||
sheet.rowOverrides,
|
||||
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT) * scaleY
|
||||
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
|
||||
) - 2
|
||||
);
|
||||
const c1 = Math.max(
|
||||
@@ -153,7 +172,7 @@
|
||||
sheet.cols,
|
||||
sheet.defaultColumnWidth,
|
||||
sheet.columnOverrides,
|
||||
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH) * scaleX
|
||||
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
|
||||
) - 2
|
||||
);
|
||||
const r2 = Math.min(
|
||||
@@ -162,7 +181,7 @@
|
||||
sheet.rows,
|
||||
sheet.defaultRowHeight,
|
||||
sheet.rowOverrides,
|
||||
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT + (grid.clientHeight || 500)) * scaleY
|
||||
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
|
||||
) + 2
|
||||
);
|
||||
const c2 = Math.min(
|
||||
@@ -171,11 +190,12 @@
|
||||
sheet.cols,
|
||||
sheet.defaultColumnWidth,
|
||||
sheet.columnOverrides,
|
||||
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH + (grid.clientWidth || 800)) * scaleX
|
||||
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
|
||||
) + 2
|
||||
);
|
||||
latestRequestId += 1;
|
||||
latestRange = { r1, c1, r2, c2 };
|
||||
latestAxes = { y, x };
|
||||
worker.postMessage({
|
||||
type: 'tile',
|
||||
requestId: latestRequestId,
|
||||
@@ -207,7 +227,16 @@
|
||||
function renderTile(tile) {
|
||||
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
|
||||
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 = '';
|
||||
headingsLayer.textContent = '';
|
||||
const mergeByAnchor = new Map();
|
||||
@@ -227,13 +256,11 @@
|
||||
element.dataset.row = String(cell.row);
|
||||
element.dataset.col = String(cell.col);
|
||||
element.textContent = String(cell.text ?? '');
|
||||
element.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, cell.row) / scaleY}px`;
|
||||
element.style.left = `${ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col) / scaleX}px`;
|
||||
element.style.top = `${rowTop(cell.row)}px`;
|
||||
element.style.left = `${colLeft(cell.col)}px`;
|
||||
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
||||
const finalRow = merge?.r2 || cell.row;
|
||||
const finalCol = merge?.c2 || cell.col;
|
||||
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`;
|
||||
element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`;
|
||||
element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`;
|
||||
cellsLayer.appendChild(element);
|
||||
}
|
||||
// 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.
|
||||
let rowHeadings = 0;
|
||||
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
||||
const top = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row);
|
||||
const height = (axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row + 1) - top) / scaleY;
|
||||
const height = rowSpan(row, row);
|
||||
if (height <= 0) continue;
|
||||
rowHeadings += 1;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'spreadsheet-row-heading';
|
||||
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.left = `${grid.scrollLeft}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
let columnHeadings = 0;
|
||||
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||
const left = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col);
|
||||
const width =
|
||||
(axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col + 1) - left) / scaleX;
|
||||
const width = colSpan(col, col);
|
||||
if (width <= 0) continue;
|
||||
columnHeadings += 1;
|
||||
const heading = document.createElement('div');
|
||||
@@ -266,7 +290,7 @@
|
||||
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
||||
label = String.fromCharCode(65 + ((value - 1) % 26)) + 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.top = `${grid.scrollTop}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
@@ -279,6 +303,7 @@
|
||||
activeSheetId = String(sheetId);
|
||||
latestRequestId += 1;
|
||||
latestRange = null;
|
||||
latestAxes = null;
|
||||
if (cellsLayer) cellsLayer.textContent = '';
|
||||
if (headingsLayer) headingsLayer.textContent = '';
|
||||
container.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||
@@ -294,8 +319,6 @@
|
||||
if (sheet && spacer) {
|
||||
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 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.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
|
||||
// froze the page's main thread at 9.6 GB).
|
||||
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_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) {
|
||||
let tail = '';
|
||||
const decoder = new TextDecoder();
|
||||
@@ -291,6 +310,9 @@
|
||||
const worksheet = isWorksheetName(excelJsName);
|
||||
const styles = excelJsName === 'xl/styles.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) => {
|
||||
sheetCells += cells;
|
||||
counts.cells += cells;
|
||||
@@ -299,13 +321,19 @@
|
||||
};
|
||||
return {
|
||||
push(chunk, final) {
|
||||
if (!worksheet && !styles && !workbook) return;
|
||||
if (media) 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
|
||||
// so a tag cut by a chunk edge is always read in one piece.
|
||||
const safeEnd = final ? text.length : Math.max(0, text.lastIndexOf('<'));
|
||||
// so a tag cut by a chunk edge is always read in one piece. Any other part
|
||||
// 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);
|
||||
counts.elements += countStartTags(scan);
|
||||
if (counts.elements > limits.maxElements) fail('element-limit', 'Workbook exceeds the XML elements limit');
|
||||
if (worksheet) {
|
||||
addCells((scan.match(/<c(?:\s|>)/g) || []).length);
|
||||
// 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);
|
||||
const streamedEntries = new Map();
|
||||
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();
|
||||
let totalInflated = 0;
|
||||
let seenEntries = 0;
|
||||
|
||||
Reference in new issue
Block a user