feat(preview): render XLSX spreadsheets in the file-preview overlay

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
Aamer Akhter committed 2026-09-26 23:13:21 -04:00
1 parent 45ea2e1d32
commit 0b122e2c76
27 files changed
+3765 -13

No files matched your search

+2 -2
View File
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/
const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g;
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() {
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/
const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
('png jpg jpeg gif webp bmp svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
);
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+1
View File
@@ -3766,5 +3766,6 @@
<script defer src="ultracode-windows.js"></script>
<script defer src="session-lineage.js"></script>
<script defer src="image-input.js"></script>
<script defer src="spreadsheet-preview.js"></script>
</body>
</html>
+21
View File
@@ -3937,3 +3937,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
touch-scrollable grid on phones. */
@media (max-width: 700px) {
.spreadsheet-sheet-tabs {
padding-inline: 4px;
scroll-snap-type: x proximity;
}
.spreadsheet-sheet-tab {
min-width: 96px;
min-height: 40px;
scroll-snap-align: start;
}
.spreadsheet-grid {
min-height: 55vh;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
}
+44
View File
@@ -4083,6 +4083,8 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'xlsx') {
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
} else {
try {
// Bounded like the workspace text preview: a Range for the first
@@ -4167,6 +4169,9 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'spreadsheet') {
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
@@ -4237,6 +4242,9 @@ Object.assign(CodemanApp.prototype, {
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/
_stopFilePreviewMedia() {
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
// leaves both running, so tear them down with the rest of the media.
this._disposeSpreadsheetPreview();
const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) {
@@ -4251,6 +4259,42 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = '';
},
/**
* Render an XLSX into the preview body via spreadsheet-preview.js, which
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
* so the server applies its preview size cap. Superseded by the next
* _stopFilePreviewMedia(), which runs on every open and on close.
*/
_openSpreadsheetPreview(bodyEl, url, size) {
this._disposeSpreadsheetPreview();
const renderer = window.CodemanSpreadsheetPreview;
if (!renderer?.open) {
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
return;
}
bodyEl.textContent = '';
const token = {};
this._spreadsheetPreviewToken = token;
this._spreadsheetPreview = renderer.open({
container: bodyEl,
url,
size,
isCurrent: () => this._spreadsheetPreviewToken === token,
});
},
_disposeSpreadsheetPreview() {
const handle = this._spreadsheetPreview;
this._spreadsheetPreview = null;
this._spreadsheetPreviewToken = null;
try {
handle?.dispose();
} catch (err) {
console.warn('Failed to dispose spreadsheet preview:', err);
}
},
// ═══════════════════════════════════════════════════════════════
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
// ═══════════════════════════════════════════════════════════════
@@ -0,0 +1,220 @@
/**
* @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). 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',
});
}
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the
// fflate re-read is a fallback (admission already bounded the file) and the
// default Office palette is the last resort.
function readThemeXml(loadedWorkbook, bytes) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
try {
const entries = self.fflate.unzipSync(new Uint8Array(bytes), {
filter: (file) => file.name === 'xl/theme/theme1.xml',
});
const theme = entries['xl/theme/theme1.xml'];
if (theme) return new TextDecoder().decode(theme);
} catch (error) {
void error;
}
return '';
}
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);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
await nextWorkbook.xlsx.load(bytes);
const nextSheets = new Map();
const nextRows = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, bytes));
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();
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) 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 (rowNumber < range.r1) continue;
if (rowNumber > range.r2) break;
const row = sheet.getRow(rowNumber);
row.eachCell({ includeEmpty: false }, (cell) => {
if (cell.col < range.c1 || cell.col > range.c2) return;
addCell(cell);
});
if (cells.length > 2500) throw new core.XlsxPreviewError('tile-limit', 'Spreadsheet tile exceeds the cell limit');
}
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));
}
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' });
+414
View File
@@ -0,0 +1,414 @@
/**
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
*
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
* the visible tile of cells. Parsing happens entirely in the browser worker; the
* server just streams the file through its existing confined raw routes.
*
* Every workbook string (cell text, sheet names) is written with `textContent`,
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
* or closed.
*
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
*/
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '4b7074e75ab1';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
const ROW_HEADING_WIDTH = 36;
const COLUMN_HEADING_HEIGHT = 20;
const assets = Object.freeze({
version: SPREADSHEET_ASSET_VERSION,
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
});
function message(container, text, kind) {
container.textContent = '';
const state = document.createElement('div');
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
state.textContent = text;
container.appendChild(state);
}
function safePreviewUrl(candidate) {
const url = new URL(candidate, global.location.href);
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
url.searchParams.set('preview', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
function open(options) {
const container = options.container;
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
let disposed = false;
let worker = null;
let controller = null;
let timer = null;
let metadata = null;
let activeSheetId = null;
let latestRequestId = 0;
let grid = null;
let spacer = null;
let cellsLayer = null;
let headingsLayer = null;
let emptySheetState = null;
let resizeObserver = null;
let scaleX = 1;
let scaleY = 1;
let latestRange = null;
let scrollFrame = null;
const current = () => !disposed && isCurrent();
const clearTimer = () => {
if (timer !== null) global.clearTimeout(timer);
timer = null;
};
const fail = (text) => {
if (!current()) return;
clearTimer();
message(container, text || 'Spreadsheet preview failed', 'error');
};
function sheetMetadata() {
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
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;
}
return value;
}
function axisIndex(count, defaultSize, overrides, offset) {
let low = 1;
let high = Math.max(1, count);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
else high = mid - 1;
}
return low;
}
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
);
scaleX = Math.max(
1,
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1) / MAX_SCROLL_PX
);
const r1 = Math.max(
1,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT) * scaleY
) - 2
);
const c1 = Math.max(
1,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH) * scaleX
) - 2
);
const r2 = Math.min(
sheet.rows,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT + (grid.clientHeight || 500)) * scaleY
) + 2
);
const c2 = Math.min(
sheet.cols,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH + (grid.clientWidth || 800)) * scaleX
) + 2
);
latestRequestId += 1;
latestRange = { r1, c1, r2, c2 };
worker.postMessage({
type: 'tile',
requestId: latestRequestId,
sheetId: String(activeSheetId),
range: { r1, c1, r2, c2 },
});
}
function renderWarnings(tileWarnings) {
const notice = container.querySelector('.spreadsheet-preview-notice');
if (!notice) return;
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
notice.hidden = warnings.length === 0;
const warningLabel =
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
}
function pinHeadings() {
if (!grid || !headingsLayer) return;
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
heading.style.left = `${grid.scrollLeft}px`;
});
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
heading.style.top = `${grid.scrollTop}px`;
});
}
function renderTile(tile) {
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
const sheet = sheetMetadata();
if (!sheet || !cellsLayer || !headingsLayer || !latestRange) return;
cellsLayer.textContent = '';
headingsLayer.textContent = '';
const mergeByAnchor = new Map();
for (const merge of tile.merges || []) {
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
if (!match) continue;
const column = (letters) =>
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
r2: Number(match[4]),
c2: column(match[3]),
});
}
for (const cell of tile.cells.slice(0, 2500)) {
const element = document.createElement('div');
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
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`;
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`;
cellsLayer.appendChild(element);
}
for (let row = latestRange.r1; row <= latestRange.r2 && row < latestRange.r1 + 200; row += 1) {
const heading = document.createElement('div');
heading.className = 'spreadsheet-row-heading';
heading.textContent = String(row);
heading.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) / scaleY}px`;
heading.style.left = `${grid.scrollLeft}px`;
headingsLayer.appendChild(heading);
}
for (let col = latestRange.c1; col <= latestRange.c2 && col < latestRange.c1 + 100; col += 1) {
const heading = document.createElement('div');
heading.className = 'spreadsheet-column-heading';
let label = '';
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 + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) / scaleX}px`;
heading.style.top = `${grid.scrollTop}px`;
headingsLayer.appendChild(heading);
}
renderWarnings(tile.warnings);
}
function selectSheet(sheetId) {
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
activeSheetId = String(sheetId);
latestRequestId += 1;
latestRange = null;
if (cellsLayer) cellsLayer.textContent = '';
if (headingsLayer) headingsLayer.textContent = '';
container.querySelectorAll('[role="tab"]').forEach((tab) => {
const selected = tab.dataset.sheetId === activeSheetId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
if (grid) {
grid.scrollTop = 0;
grid.scrollLeft = 0;
}
const sheet = sheetMetadata();
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`;
}
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
renderWarnings([]);
requestTile();
}
function renderMetadata(nextMetadata) {
if (!current()) return;
metadata = nextMetadata;
container.textContent = '';
if (!metadata.sheets?.length) {
message(container, 'This workbook has no visible worksheets.', 'empty');
return;
}
const shell = document.createElement('div');
shell.className = 'spreadsheet-preview-shell';
const styleSheet = document.createElement('style');
styleSheet.textContent = (metadata.styles || [])
.map((style, id) => {
const declarations = [];
if (style.font?.bold) declarations.push('font-weight:700');
if (style.font?.italic) declarations.push('font-style:italic');
// Colour and background are emitted together or not at all.
// The worker already contrast-checked them as a pair; contributing
// one half would drop the cell back onto the skin's own background.
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
}
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
if (style.wrapText) declarations.push('white-space:normal');
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
})
.join('');
const tabs = document.createElement('div');
tabs.className = 'spreadsheet-sheet-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('data-i18n-skip', '');
for (const sheet of metadata.sheets) {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'spreadsheet-sheet-tab';
tab.setAttribute('role', 'tab');
tab.dataset.sheetId = String(sheet.id);
tab.textContent = sheet.name;
tab.addEventListener('click', () => selectSheet(sheet.id));
tabs.appendChild(tab);
}
const notice = document.createElement('div');
notice.className = 'spreadsheet-preview-notice';
notice.hidden = true;
emptySheetState = document.createElement('div');
emptySheetState.className = 'spreadsheet-empty-sheet';
emptySheetState.textContent = 'This worksheet is empty.';
emptySheetState.hidden = true;
grid = document.createElement('div');
grid.className = 'spreadsheet-grid';
grid.setAttribute('data-i18n-skip', '');
spacer = document.createElement('div');
spacer.className = 'spreadsheet-grid-spacer';
cellsLayer = document.createElement('div');
cellsLayer.className = 'spreadsheet-cells';
headingsLayer = document.createElement('div');
headingsLayer.className = 'spreadsheet-headings';
grid.append(spacer, cellsLayer, headingsLayer);
grid.addEventListener(
'scroll',
() => {
pinHeadings();
if (scrollFrame !== null) return;
scrollFrame = global.requestAnimationFrame(() => {
scrollFrame = null;
requestTile();
});
},
{ passive: true }
);
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
container.appendChild(shell);
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
resizeObserver?.observe(grid);
selectSheet(metadata.sheets[0].id);
}
function dispose() {
if (disposed) return;
disposed = true;
clearTimer();
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
controller?.abort();
resizeObserver?.disconnect();
try {
worker?.postMessage({ type: 'dispose' });
worker?.terminate();
} catch {
// A worker that failed during startup may already be unavailable.
}
worker = null;
}
async function start() {
if (Number(options.size) > MAX_PREVIEW_BYTES) {
fail('This workbook is too large to preview (10 MB limit).');
return;
}
message(container, 'Loading spreadsheet…', 'loading');
let previewUrl;
try {
previewUrl = safePreviewUrl(options.url);
controller = new AbortController();
// Root-absolute paths ignore <base href>; route through the mount prefix.
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
const ready = new Promise((resolve, reject) => {
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
worker.onmessage = (event) => {
if (event.data?.type === 'ready') resolve();
};
});
const responsePromise = fetch(previewUrl, { signal: controller.signal });
const [response] = await Promise.all([responsePromise, ready]);
if (!current()) return;
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
const bytes = await response.arrayBuffer();
if (!current()) return;
worker.onmessage = (event) => {
if (!current()) return;
const payload = event.data || {};
if (payload.type === 'metadata') {
clearTimer();
renderMetadata(payload);
} else if (payload.type === 'tile') renderTile(payload);
else if (payload.type === 'error') fail(payload.message);
};
worker.onerror = () => fail('Spreadsheet parser failed.');
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
timer = global.setTimeout(() => {
worker?.terminate();
fail('Spreadsheet preview timed out.');
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
worker.postMessage({ type: 'load', bytes }, [bytes]);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
}
}
void start();
return Object.freeze({ dispose, selectSheet, resize: requestTile });
}
global.CodemanSpreadsheetPreviewAssets = assets;
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
})(window);
+588
View File
@@ -0,0 +1,588 @@
/**
* @fileoverview Pure XLSX admission, formatting, and sparse-grid helpers.
*
* Loaded only inside spreadsheet-preview-worker.js (never by the page) and by
* test/spreadsheet-xlsx-core.test.ts. `admitXlsx()` walks the ZIP central
* directory and streams every entry through fflate BEFORE ExcelJS sees the
* bytes, enforcing {@link LIMITS}; a workbook that trips any cap is refused
* rather than truncated.
*/
(function initSpreadsheetXlsxCore(global) {
'use strict';
const LIMITS = Object.freeze({
maxEntries: 5000,
maxInflatedBytes: 64 * 1024 * 1024,
maxEntryBytes: 32 * 1024 * 1024,
maxCompressionRatio: 100,
maxWorksheets: 50,
maxCells: 250000,
maxCellsPerSheet: 100000,
maxMergesPerSheet: 5000,
maxStyles: 5000,
});
const MAX_ROW = 1048576;
const MAX_COL = 16384;
class XlsxPreviewError extends Error {
constructor(code, message) {
super(message);
this.name = 'XlsxPreviewError';
this.code = code;
}
}
function fail(code, message) {
throw new XlsxPreviewError(code, message);
}
function mergedLimits(overrides) {
return Object.assign({}, LIMITS, overrides || {});
}
function u16(bytes, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true);
}
function u32(bytes, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
}
function inspectZipDirectory(bytes, overrides) {
const limits = mergedLimits(overrides);
if (bytes.length >= 4 && bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0) {
fail('encrypted', 'Encrypted or legacy OLE workbooks cannot be previewed');
}
let eocd = -1;
const floor = Math.max(0, bytes.length - 65557);
for (let i = bytes.length - 22; i >= floor; i -= 1) {
if (u32(bytes, i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) fail('malformed', 'Malformed XLSX ZIP directory');
const entryCount = u16(bytes, eocd + 10);
const directorySize = u32(bytes, eocd + 12);
const directoryOffset = u32(bytes, eocd + 16);
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
fail('zip64', 'ZIP64 workbooks are not supported');
}
if (entryCount > limits.maxEntries) fail('entry-limit', `Workbook exceeds ${limits.maxEntries} ZIP entries`);
if (directoryOffset + directorySize > eocd) fail('malformed', 'Malformed XLSX central directory bounds');
const entries = [];
let cursor = directoryOffset;
for (let i = 0; i < entryCount; i += 1) {
if (cursor + 46 > eocd || u32(bytes, cursor) !== 0x02014b50)
fail('malformed', 'Malformed XLSX central directory');
const compressedSize = u32(bytes, cursor + 20);
const declaredSize = u32(bytes, cursor + 24);
const nameLength = u16(bytes, cursor + 28);
const extraLength = u16(bytes, cursor + 30);
const commentLength = u16(bytes, cursor + 32);
const localHeaderOffset = u32(bytes, cursor + 42);
if (compressedSize === 0xffffffff || declaredSize === 0xffffffff)
fail('zip64', 'ZIP64 entries are not supported');
const end = cursor + 46 + nameLength + extraLength + commentLength;
if (end > eocd) fail('malformed', 'Malformed XLSX entry bounds');
const name = new TextDecoder().decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength));
if (localHeaderOffset + 30 > directoryOffset || u32(bytes, localHeaderOffset) !== 0x04034b50) {
fail('malformed', 'Malformed XLSX local file header');
}
const localNameLength = u16(bytes, localHeaderOffset + 26);
const localExtraLength = u16(bytes, localHeaderOffset + 28);
const localNameEnd = localHeaderOffset + 30 + localNameLength;
if (localNameEnd + localExtraLength > directoryOffset) fail('malformed', 'Malformed XLSX local entry bounds');
const localName = new TextDecoder().decode(bytes.subarray(localHeaderOffset + 30, localNameEnd));
if (localName !== name) fail('malformed', 'XLSX local and central directory names do not match');
entries.push({ name, compressedSize, declaredSize, localHeaderOffset });
cursor = end;
}
return { entries };
}
function featureForName(name) {
if (name.startsWith('xl/charts/')) return 'charts';
if (name.startsWith('xl/drawings/')) return 'drawings';
if (name.startsWith('xl/pivotCache/')) return 'pivotTables';
if (name.startsWith('xl/externalLinks/')) return 'externalLinks';
if (/vbaProject\.bin$/i.test(name)) return 'macros';
return null;
}
function createXmlCounter(name, counts, limits) {
let tail = '';
const decoder = new TextDecoder();
let sheetCells = 0;
let sheetMerges = 0;
let inCellXfs = false;
const worksheet = /^xl\/worksheets\/[^/]+\.xml$/i.test(name);
const styles = name === 'xl/styles.xml';
return {
push(chunk, final) {
if (!worksheet && !styles) return;
const text = tail + decoder.decode(chunk, { stream: !final });
const safeEnd = final ? text.length : Math.max(0, text.length - 128);
const scan = text.slice(0, safeEnd);
if (worksheet) {
const cells = (scan.match(/<c(?:\s|>)/g) || []).length;
const merges = (scan.match(/<mergeCell(?:\s|>)/g) || []).length;
sheetCells += cells;
sheetMerges += merges;
counts.cells += cells;
counts.merges += merges;
if (sheetCells > limits.maxCellsPerSheet || counts.cells > limits.maxCells)
fail('cell-limit', 'Workbook exceeds the cells limit');
if (sheetMerges > limits.maxMergesPerSheet) fail('merge-limit', 'Worksheet exceeds the merged ranges limit');
}
if (styles) {
const tokens = scan.match(/<cellXfs(?:\s|>)|<\/cellXfs\s*>|<xf(?:\s|\/?>)/g) || [];
for (const token of tokens) {
if (token.startsWith('<cellXfs')) inCellXfs = true;
else if (token.startsWith('</cellXfs')) inCellXfs = false;
else if (inCellXfs) counts.styles += 1;
}
if (counts.styles > limits.maxStyles) fail('style-limit', 'Workbook exceeds the cell styles limit');
}
tail = text.slice(safeEnd);
},
};
}
function admitXlsx(bytes, zipApi, overrides) {
const limits = mergedLimits(overrides);
const directory = inspectZipDirectory(bytes, limits);
const directoryByName = new Map(directory.entries.map((entry) => [entry.name, entry]));
const expectedEntries = new Map();
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
const streamedEntries = new Map();
const counts = { worksheets: 0, cells: 0, merges: 0, styles: 0 };
const features = new Set();
let totalInflated = 0;
let seenEntries = 0;
let thrown;
const unzip = new zipApi.Unzip((file) => {
if (!directoryByName.has(file.name)) fail('malformed', 'Local XLSX entry is absent from the central directory');
streamedEntries.set(file.name, (streamedEntries.get(file.name) || 0) + 1);
seenEntries += 1;
if (seenEntries > limits.maxEntries) fail('entry-limit', 'Workbook exceeds the ZIP entries limit');
if (/^xl\/worksheets\/[^/]+\.xml$/i.test(file.name)) {
counts.worksheets += 1;
if (counts.worksheets > limits.maxWorksheets) fail('worksheet-limit', 'Workbook exceeds the worksheet limit');
}
const feature = featureForName(file.name);
if (feature) features.add(feature);
const counter = createXmlCounter(file.name, counts, limits);
let entryInflated = 0;
file.ondata = (error, chunk, final) => {
if (error) throw error;
entryInflated += chunk.length;
totalInflated += chunk.length;
if (entryInflated > limits.maxEntryBytes) fail('entry-size', 'Inflated ZIP entry exceeds the entry limit');
if (totalInflated > limits.maxInflatedBytes) fail('inflated-size', 'Workbook exceeds the inflated bytes limit');
const compressed = directoryByName.get(file.name)?.compressedSize || 1;
if (entryInflated / Math.max(1, compressed) > limits.maxCompressionRatio) {
fail('compression-ratio', 'ZIP entry exceeds the compression ratio limit');
}
counter.push(chunk, final);
};
file.start();
});
unzip.register(zipApi.UnzipInflate);
try {
const inputChunkBytes = 64 * 1024;
for (let offset = 0; offset < bytes.length && !thrown; offset += inputChunkBytes) {
const end = Math.min(bytes.length, offset + inputChunkBytes);
unzip.push(bytes.subarray(offset, end), end === bytes.length);
}
} catch (error) {
thrown = error;
}
if (thrown) throw thrown;
for (const [name, count] of expectedEntries) {
if (streamedEntries.get(name) !== count) fail('malformed', 'Central XLSX entry was not streamed for admission');
}
return { counts, features: Array.from(features), inflatedBytes: totalInflated };
}
function parseCellRef(ref) {
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
if (!match) return null;
let col = 0;
for (const char of match[1].toUpperCase()) col = col * 26 + char.charCodeAt(0) - 64;
const row = Number(match[2]);
return row <= MAX_ROW && col <= MAX_COL ? { row, col } : null;
}
function parseRange(range) {
const parts = String(range).split(':');
const start = parseCellRef(parts[0]);
const end = parseCellRef(parts[1] || parts[0]);
return start && end ? { r1: start.row, c1: start.col, r2: end.row, c2: end.col } : null;
}
function deriveExtent(cells, merges) {
let rows = 0;
let cols = 0;
for (const ref of cells) {
const cell = parseCellRef(ref);
if (cell) {
rows = Math.max(rows, cell.row);
cols = Math.max(cols, cell.col);
}
}
for (const merge of merges) {
const range = parseRange(merge);
if (range) {
rows = Math.max(rows, range.r2);
cols = Math.max(cols, range.c2);
}
}
return { rows, cols };
}
function createSparseAxis(count, defaultSize, overrides) {
const sorted = Array.from(overrides || [])
.filter(([index, size]) => index >= 1 && index <= count && Number.isFinite(size))
.map(([index, size]) => [index, Math.max(0, size)])
.sort((a, b) => a[0] - b[0]);
return { count: Math.max(0, count), defaultSize: Math.max(0, defaultSize), overrides: sorted };
}
function axisOffset(axis, index) {
const bounded = Math.max(1, Math.min(axis.count + 1, index));
let offset = (bounded - 1) * axis.defaultSize;
for (const [overrideIndex, size] of axis.overrides) {
if (overrideIndex >= bounded) break;
offset += size - axis.defaultSize;
}
return offset;
}
function axisIndexAt(axis, offset) {
let low = 1;
let high = Math.max(1, axis.count);
const target = Math.max(0, offset);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(axis, mid) <= target) low = mid;
else high = mid - 1;
}
return low;
}
function computeViewport(axis, offset, viewportSize, overscan) {
const pad = Math.max(0, overscan || 0);
const start = Math.max(1, axisIndexAt(axis, offset) - pad);
const end = Math.min(axis.count, axisIndexAt(axis, offset + Math.max(0, viewportSize)) + pad);
return [start, end];
}
function intersectingMerges(merges, viewport) {
return merges.filter((merge) => {
const range = parseRange(merge);
return (
range &&
range.r1 <= viewport.r2 &&
range.r2 >= viewport.r1 &&
range.c1 <= viewport.c2 &&
range.c2 >= viewport.c1
);
});
}
function excelDate(serial, date1904) {
if (date1904) return new Date(Date.UTC(1904, 0, 1) + Number(serial) * 86400000);
const numeric = Number(serial);
const adjusted = numeric >= 60 ? numeric - 1 : numeric;
return new Date(Date.UTC(1899, 11, 31) + adjusted * 86400000);
}
function formatCellValue(value, format, date1904) {
if (value && typeof value === 'object' && 'formula' in value) {
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
return { text: `=${String(value.formula)}`, warning: 'Formula has no cached result' };
}
if (value === null || value === undefined) return { text: '' };
const code = String(format || 'General');
if (typeof value !== 'number') return { text: String(value) };
if (/^General$/i.test(code)) return { text: String(value) };
if (/^[ymd\-/ ]+$/i.test(code)) {
const date = excelDate(value, Boolean(date1904));
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return { text: `${yyyy}-${mm}-${dd}` };
}
if (/^[hms: ]+$/i.test(code)) {
const seconds = Math.round((value - Math.floor(value)) * 86400) % 86400;
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return { text: `${hh}:${mm}:${ss}` };
}
if (/^[ymdhis\-/: ]+$/i.test(code)) {
const date = excelDate(value, Boolean(date1904));
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
const hh = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return { text: `${yyyy}-${mm}-${dd} ${hh}:${minutes}` };
}
const percent = code.includes('%');
const decimals = code.match(/\.([0#]+)/)?.[1].length || 0;
const numericPattern = /^[€£¥$]?[#,0]+(?:\.[0#]+)?%?$/;
if (numericPattern.test(code)) {
const currency = /^[€£¥$]/.exec(code)?.[0] || '';
const numeric = percent ? value * 100 : value;
const useGrouping = code.includes(',');
return {
text:
currency +
numeric.toLocaleString('en-US', {
useGrouping,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}) +
(percent ? '%' : ''),
};
}
return { text: String(value), warning: `Unsupported number format: ${code}` };
}
// Colour resolution --------------------------------------------------------
//
// ExcelJS surfaces theme and indexed palette colours WITHOUT an `argb` key
// (`{theme,tint}` / `{indexed}`), and theme colours are what Excel emits by
// default. Dropping them left cells rendering the workbook's font colour on
// the skin's own background, which is how black-on-dark (invisible) text got
// shipped. Everything below is pure so `test/spreadsheet-xlsx-core.test.ts`
// can pin it without a DOM.
// styles.xml `theme="N"` order. NOTE: theme1.xml's <a:clrScheme> lists
// dk1, lt1, dk2, lt2 — indices 0/1 and 2/3 are SWAPPED between the two.
const THEME_SLOT_ORDER = Object.freeze([
'lt1',
'dk1',
'lt2',
'dk2',
'accent1',
'accent2',
'accent3',
'accent4',
'accent5',
'accent6',
'hlink',
'folHlink',
]);
// Default Office theme, used when theme1.xml is missing or unparseable.
const DEFAULT_THEME_PALETTE = Object.freeze([
'#ffffff',
'#000000',
'#eeece1',
'#1f497d',
'#4f81bd',
'#c0504d',
'#9bbb59',
'#8064a2',
'#4bacc6',
'#f79646',
'#0000ff',
'#800080',
]);
// Legacy 64-entry indexed palette. Indices 64/65 are the "auto" foreground and
// background sentinels and deliberately have no entry here.
// prettier-ignore
const INDEXED_PALETTE = Object.freeze([
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#c0c0c0', '#808080',
'#9999ff', '#993366', '#ffffcc', '#ccffff', '#660066', '#ff8080', '#0066cc', '#ccccff',
'#000080', '#ff00ff', '#ffff00', '#00ffff', '#800080', '#800000', '#008080', '#0000ff',
'#00ccff', '#ccffff', '#ccffcc', '#ffff99', '#99ccff', '#ff99cc', '#cc99ff', '#ffcc99',
'#3366ff', '#33cccc', '#99cc00', '#ffcc00', '#ff9900', '#ff6600', '#666699', '#969696',
'#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333',
]);
const MIN_CONTRAST_RATIO = 4.5;
// A workbook with no fill renders on Excel's implicit white sheet background,
// never on the viewer skin's `var(--bg-primary)`.
const IMPLICIT_SHEET_BACKGROUND = '#ffffff';
const IMPLICIT_SHEET_FOREGROUND = '#000000';
function hexToRgb(hex) {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''));
if (!match) return null;
return [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)];
}
function rgbToHex(rgb) {
let hex = '#';
for (const channel of rgb) {
const bounded = Math.max(0, Math.min(255, Math.round(channel)));
hex += (bounded < 16 ? '0' : '') + bounded.toString(16);
}
return hex;
}
function rgbToHsl(rgb) {
const r = rgb[0] / 255;
const g = rgb[1] / 255;
const b = rgb[2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const delta = max - min;
const s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let h;
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
return { h: h / 6, s, l };
}
function hueToChannel(p, q, hue) {
let t = hue;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function hslToRgb(hsl) {
if (hsl.s === 0) {
const gray = hsl.l * 255;
return [gray, gray, gray];
}
const q = hsl.l < 0.5 ? hsl.l * (1 + hsl.s) : hsl.l + hsl.s - hsl.l * hsl.s;
const p = 2 * hsl.l - q;
return [
hueToChannel(p, q, hsl.h + 1 / 3) * 255,
hueToChannel(p, q, hsl.h) * 255,
hueToChannel(p, q, hsl.h - 1 / 3) * 255,
];
}
// Excel tint acts on HSL luminance: negative darkens, positive lightens.
function applyTint(hex, tint) {
const amount = Number(tint);
if (!Number.isFinite(amount) || amount === 0) return hex;
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const bounded = Math.max(-1, Math.min(1, amount));
const hsl = rgbToHsl(rgb);
const luminance = bounded < 0 ? hsl.l * (1 + bounded) : hsl.l * (1 - bounded) + bounded;
return rgbToHex(hslToRgb({ h: hsl.h, s: hsl.s, l: Math.max(0, Math.min(1, luminance)) }));
}
function parseSchemeColor(fragment) {
const srgb = /<(?:[A-Za-z0-9_]+:)?srgbClr\b[^>]*\bval="([0-9A-Fa-f]{6})"/.exec(fragment);
if (srgb) return `#${srgb[1].toLowerCase()}`;
const sys = /<(?:[A-Za-z0-9_]+:)?sysClr\b[^>]*\blastClr="([0-9A-Fa-f]{6})"/.exec(fragment);
if (sys) return `#${sys[1].toLowerCase()}`;
return undefined;
}
function parseThemePalette(xml) {
const palette = DEFAULT_THEME_PALETTE.slice();
const text = typeof xml === 'string' ? xml : '';
const scheme = /<(?:[A-Za-z0-9_]+:)?clrScheme\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?clrScheme\s*>/.exec(text);
if (!scheme) return palette;
// Fresh pattern per call: a shared /g regex would carry lastIndex across calls.
const slots =
/<(?:[A-Za-z0-9_]+:)?(lt1|dk1|lt2|dk2|accent[1-6]|hlink|folHlink)\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?\1\s*>/g;
let match = slots.exec(scheme[1]);
while (match) {
const index = THEME_SLOT_ORDER.indexOf(match[1]);
const resolved = index >= 0 ? parseSchemeColor(match[2]) : undefined;
if (resolved) palette[index] = resolved;
match = slots.exec(scheme[1]);
}
return palette;
}
function themePaletteOrDefault(palette) {
return Array.isArray(palette) && palette.length === THEME_SLOT_ORDER.length ? palette : DEFAULT_THEME_PALETTE;
}
// Accepts every colour shape ExcelJS emits: {argb}, {theme,tint}, {indexed}.
function resolveColor(color, palette) {
if (!color || typeof color !== 'object') return undefined;
const argb = color.argb;
if (typeof argb === 'string' && /^[A-Fa-f0-9]{8}$/.test(argb)) return `#${argb.slice(2).toLowerCase()}`;
const theme = color.theme;
if (Number.isInteger(theme) && theme >= 0 && theme < THEME_SLOT_ORDER.length) {
const base = themePaletteOrDefault(palette)[theme];
return typeof base === 'string' ? applyTint(base, color.tint) : undefined;
}
const indexed = color.indexed;
if (Number.isInteger(indexed) && indexed >= 0 && indexed < INDEXED_PALETTE.length) return INDEXED_PALETTE[indexed];
return undefined;
}
function channelLuminance(channel) {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex) {
const rgb = hexToRgb(hex);
if (!rgb) return 0;
return 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]);
}
function contrastRatio(a, b) {
const first = relativeLuminance(a);
const second = relativeLuminance(b);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}
function ensureContrast(foreground, background, minRatio) {
const minimum = Number.isFinite(minRatio) ? minRatio : MIN_CONTRAST_RATIO;
if (contrastRatio(foreground, background) >= minimum) return foreground;
return contrastRatio('#000000', background) >= contrastRatio('#ffffff', background) ? '#000000' : '#ffffff';
}
// Returns a SELF-CONSISTENT pair, or nothing at all. Emitting only one half is
// what let workbook text land on the skin's background and vanish.
function resolveCellColors(fillColor, fontColor, palette) {
const fill = resolveColor(fillColor, palette);
const font = resolveColor(fontColor, palette);
if (!fill && !font) return {};
const background = fill || IMPLICIT_SHEET_BACKGROUND;
return { background, foreground: ensureContrast(font || IMPLICIT_SHEET_FOREGROUND, background) };
}
global.CodemanSpreadsheetXlsxCore = Object.freeze({
LIMITS,
MAX_ROW,
MAX_COL,
XlsxPreviewError,
inspectZipDirectory,
admitXlsx,
parseCellRef,
parseRange,
deriveExtent,
createSparseAxis,
axisOffset,
axisIndexAt,
computeViewport,
intersectingMerges,
formatCellValue,
DEFAULT_THEME_PALETTE,
INDEXED_PALETTE,
MIN_CONTRAST_RATIO,
parseThemePalette,
resolveColor,
contrastRatio,
ensureContrast,
resolveCellColors,
});
})(typeof self !== 'undefined' ? self : globalThis);
+130
View File
@@ -19088,3 +19088,133 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 13px;
color: var(--text-muted);
}
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
.spreadsheet-preview-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background: var(--bg-primary);
}
.spreadsheet-sheet-tabs {
display: flex;
flex: 0 0 auto;
gap: 2px;
overflow-x: auto;
padding: 6px 8px 0;
border-bottom: 1px solid var(--border);
}
.spreadsheet-sheet-tab {
flex: 0 0 auto;
min-width: 80px;
max-width: 220px;
padding: 6px 12px;
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 6px 6px 0 0;
background: transparent;
}
.spreadsheet-sheet-tab[aria-selected='true'] {
color: var(--text-primary);
border-color: var(--border);
background: var(--bg-secondary);
}
.spreadsheet-preview-notice {
flex: 0 0 auto;
padding: 5px 10px;
color: var(--warning, #f59e0b);
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.spreadsheet-grid {
position: relative;
flex: 1 1 auto;
min-height: 220px;
overflow: auto;
overscroll-behavior: contain;
}
.spreadsheet-grid-spacer,
.spreadsheet-cells,
.spreadsheet-headings {
position: absolute;
top: 0;
left: 0;
}
.spreadsheet-row-heading,
.spreadsheet-column-heading {
position: absolute;
z-index: 2;
color: var(--text-secondary);
font-size: 11px;
line-height: 18px;
text-align: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
pointer-events: none;
}
.spreadsheet-row-heading {
left: 0;
width: 36px;
height: 20px;
}
.spreadsheet-column-heading {
top: 0;
width: 64px;
height: 20px;
}
.spreadsheet-empty-sheet {
position: sticky;
top: 20px;
left: 36px;
z-index: 3;
width: max-content;
padding: 24px;
color: var(--text-secondary);
background: var(--bg-primary);
}
.spreadsheet-cell {
position: absolute;
min-width: 0;
padding: 2px 5px;
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
}
.spreadsheet-preview-message {
display: grid;
min-height: 220px;
place-items: center;
padding: 24px;
color: var(--text-secondary);
text-align: center;
}
.spreadsheet-preview-message.error {
color: var(--danger, #ef4444);
}
+44 -4
View File
@@ -73,6 +73,35 @@ import {
isEditableFileName,
} from '../../config/file-editing.js';
/**
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
* what a single preview can hand the worker; the renderer refuses the same size
* before fetching. An explicit download is unaffected (global download cap).
*/
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
/** Whether a raw request is an XLSX browser preview over the preview cap. */
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
return (
extension === 'xlsx' &&
query.preview === 'true' &&
query.download !== 'true' &&
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
);
}
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
)
);
}
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
json: 'application/json',
md: 'text/markdown',
txt: 'text/plain',
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body)
// Return metadata for media/binary files (no text body). XLSX is still a
// binary here (no text body); `spreadsheet` tells the overlay it can parse
// it client-side from `url`. xls/ods stay plain binary (download only).
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: mediaType ?? 'binary',
type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
extension: ext,
url: fileRawUrl,
},
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath, download } = req.query as { path?: string; download?: string };
const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
if (!filePath) {
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -2216,7 +2252,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string };
const { download, preview } = req.query as { download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return;
@@ -2236,6 +2272,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile(
reply,
target,