Files
Codeman/src/web/public/spreadsheet-preview-worker.js
T
Aamer Akhter d65ee4f89d fix(preview): parse admission tag attributes in order and count empty rows
XML allows a raw `>` and the other quote character inside an attribute
value, so a first-match search for `ref=`/`max=` could be fed a fake
value from an earlier attribute while saxes read the real one:

- <mergeCell>/<col> attributes are now read in order from the tag name
  with a sticky regex that consumes each quoted value whole. A tag whose
  attributes do not parse up to `>`, or that repeats a name, is refused.
- The chunk carry keeps everything from the last `<`, which can never
  appear inside an attribute value, instead of comparing against the
  last `>`.

ExcelJS keeps a Row object for every <row>, cells or not, so <row> tags
now count against per-sheet (100k) and total (250k) caps with their own
row-limit code, and the worker passes maxRows as a per-sheet backstop.

styles.xml counts every <xf> without tracking which list it sits in,
since a </cellXfs> inside a comment desynced that state.
2026-10-01 21:39:26 -04:00

240 lines
8.9 KiB
JavaScript

/**
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
*
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). ExcelJS is then
* given a STORE-only archive rebuilt from the entries admission inflated, never
* the fetched bytes, so it can only parse what admission counted. The page never
* loads either vendor file. Cell values are sent back as plain strings; the
* renderer writes them with `textContent`. Formulas are never evaluated (the
* cached result is shown, else the formula text), and nothing here fetches:
* external links, images and drawings are reported as unsupported features.
*
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
* keeps a reverse-proxy `--base-url` mount working.
*/
'use strict';
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
let populatedRowsById = new Map();
let normalizedStyles = [];
let styleIds = new Map();
let themePalette = core.DEFAULT_THEME_PALETTE;
function postError(error) {
self.postMessage({
type: 'error',
code: error?.code || 'parse-failed',
message: error?.message || 'Spreadsheet preview failed',
});
}
// Maximum cells in one tile reply; the renderer draws at most this many too.
const MAX_TILE_CELLS = 2500;
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
// entry is the fallback and the default Office palette is the last resort.
function readThemeXml(loadedWorkbook, admittedEntries) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
const theme = admittedEntries?.['xl/theme/theme1.xml'];
return theme ? new TextDecoder().decode(theme) : '';
}
function normalizeStyle(cell) {
// Colours are resolved and contrast-checked as a PAIR. Emitting a
// font colour without its background lets workbook text land on the skin's
// `var(--bg-primary)` and disappear.
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
const style = {
font: {
bold: Boolean(cell.font?.bold),
italic: Boolean(cell.font?.italic),
color: colors.foreground,
},
fill: colors.background,
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
wrapText: Boolean(cell.alignment?.wrapText),
};
const key = JSON.stringify(style);
if (styleIds.has(key)) return styleIds.get(key);
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
}
const id = normalizedStyles.length;
normalizedStyles.push(style);
styleIds.set(key, id);
return id;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const populatedRows = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
populatedRows.push(row.number);
row.eachCell({ includeEmpty: false }, (cell) => {
cellRefs.push(cell.address);
normalizeStyle(cell);
});
});
const merges = Array.from(sheet.model?.merges || []);
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);
if (column.hidden) columnOverrides.push([col, 0]);
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
}
return {
populatedRows,
metadata: {
id: String(sheet.id),
name: sheet.name,
rows: extent.rows,
cols: extent.cols,
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
rowOverrides,
columnOverrides,
merges,
},
};
}
function cellDisplay(cell, date1904, warnings) {
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
if (formatted.warning) warnings.add(formatted.warning);
return formatted.text;
}
async function loadWorkbook(bytes) {
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
const admitted = core.buildAdmittedArchive(admission, self.fflate);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
// object (a whole-column dropdown is a million), and the preview never shows
// validations, so they are not parsed at all. `maxRows` is a per-sheet
// backstop behind admission's row count, which also caps the workbook total.
await nextWorkbook.xlsx.load(admitted, {
ignoreNodes: ['dataValidations'],
maxRows: core.LIMITS.maxRowsPerSheet,
});
const nextSheets = new Map();
const nextRows = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
const sheets = [];
for (const sheet of nextWorkbook.worksheets) {
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
const sheetResult = worksheetMetadata(sheet);
const metadata = sheetResult.metadata;
nextSheets.set(metadata.id, sheet);
nextRows.set(metadata.id, sheetResult.populatedRows);
sheets.push(metadata);
}
workbook = nextWorkbook;
sheetsById = nextSheets;
populatedRowsById = nextRows;
self.postMessage({
type: 'metadata',
sheets,
styles: normalizedStyles,
date1904: Boolean(workbook.properties?.date1904),
empty: sheets.length === 0,
warnings: admission.features,
});
}
function sendTile(message) {
if (!workbook) throw new Error('Workbook is not loaded');
const sheet = sheetsById.get(String(message.sheetId));
if (!sheet) throw new Error('Worksheet is unavailable');
const range = message.range;
const warnings = new Set();
const cells = [];
const seenCells = new Set();
let truncated = false;
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
// (a filtered sheet); they are never drawn, so never sent.
const hiddenColumns = new Map();
const columnHidden = (col) => {
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
return hiddenColumns.get(col);
};
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
if (cells.length >= MAX_TILE_CELLS) {
truncated = true;
return;
}
seenCells.add(key);
cells.push({
row: cell.row,
col: cell.col,
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
styleId: normalizeStyle(cell),
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const rowNumber 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;
addCell(cell);
});
}
const merges = core.intersectingMerges(Array.from(sheet.model?.merges || []), range);
for (const merge of merges) {
const anchor = core.parseRange(merge);
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
}
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
self.postMessage({
type: 'tile',
requestId: message.requestId,
sheetId: String(message.sheetId),
cells,
merges,
warnings: Array.from(warnings),
});
}
self.onmessage = async (event) => {
try {
const message = event.data || {};
if (message.type === 'load') await loadWorkbook(message.bytes);
else if (message.type === 'tile') sendTile(message);
else if (message.type === 'dispose') {
workbook = null;
sheetsById = new Map();
populatedRowsById = new Map();
themePalette = core.DEFAULT_THEME_PALETTE;
}
} catch (error) {
postError(error);
}
};
self.postMessage({ type: 'ready' });