mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(preview): address review of the XLSX preview
- Normalize the value shapes ExcelJS loads before formatting: Date cells are formatted from their serial (UTC), so they no longer render as a local-time string a day early at negative UTC offsets; rich text joins its runs, hyperlinks show their text, error values show the error, and formula and shared-formula results (including error results) recurse. Excel serials are rounded to whole milliseconds so 00:05 no longer shows as 00:04. - sendTile() skips hidden rows and columns, and at the 2500-cell cap returns a truncated tile with a warning instead of failing the whole preview. - ExcelJS now parses a STORE-only archive rebuilt from exactly the entries admitXlsx() inflated and counted, never the fetched bytes. Admission walks local headers while JSZip reads the central directory, so overlapping entries could show the two readers different sheets. A duplicate local entry name is refused. The theme fallback reads the admitted entry too. - Row and column headings take their size from the same axis math as cells. - Document the admission, worker-only loading and SPREADSHEET_ASSET_VERSION rules in architecture-invariants, and list .xlsx in the attachments panel help and the `codeman attach` error text (built from the accepted list).
This commit is contained in:
@@ -284,7 +284,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
|
||||
@@ -358,6 +358,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
⚠️ **The preview overlay must outrank the panel that launched it.** `.file-preview-overlay` sits at `z-index: 5100`, above the response viewer (5000) and its backdrop (4999); at its historical 2000 a path clicked in the chat opened the overlay *behind* the chat, which reads as a dead link. It stays below the toast/picker band (10000+) so a "Saved" toast still lands on top.
|
||||
|
||||
**XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. The worker is a stable URL cache-busted by `SPREADSHEET_ASSET_VERSION` in `spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core.
|
||||
|
||||
### Filesystem path picker
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command.
|
||||
|
||||
@@ -53,17 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
*/
|
||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||
|
||||
/**
|
||||
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||
*/
|
||||
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||
|
||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'webp',
|
||||
'pdf',
|
||||
'docx',
|
||||
'pptx',
|
||||
// Previewed client-side (spreadsheet-preview-worker.js); served raw like the rest.
|
||||
'xlsx',
|
||||
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||
'md',
|
||||
'txt',
|
||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||
|
||||
+6
-2
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
|
||||
import { getRalphLoop } from './ralph-loop.js';
|
||||
import { getStore } from './state-store.js';
|
||||
import { getErrorMessage } from './types.js';
|
||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||
@@ -111,7 +111,11 @@ program
|
||||
.action(async (filePath, options) => {
|
||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
||||
console.error(
|
||||
palette.err(
|
||||
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -4790,7 +4790,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||
<div>Show a file here by running:</div>
|
||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
||||
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* 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
|
||||
* 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:
|
||||
@@ -35,22 +37,16 @@ function postError(error) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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;
|
||||
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 '';
|
||||
const theme = admittedEntries?.['xl/theme/theme1.xml'];
|
||||
return theme ? new TextDecoder().decode(theme) : '';
|
||||
}
|
||||
|
||||
function normalizeStyle(cell) {
|
||||
@@ -126,14 +122,15 @@ function cellDisplay(cell, date1904, warnings) {
|
||||
|
||||
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();
|
||||
await nextWorkbook.xlsx.load(bytes);
|
||||
await nextWorkbook.xlsx.load(admitted);
|
||||
const nextSheets = new Map();
|
||||
const nextRows = new Map();
|
||||
normalizedStyles = [];
|
||||
styleIds = new Map();
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, bytes));
|
||||
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||
const sheets = [];
|
||||
for (const sheet of nextWorkbook.worksheets) {
|
||||
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
|
||||
@@ -164,9 +161,22 @@ function sendTile(message) {
|
||||
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,
|
||||
@@ -177,20 +187,22 @@ function sendTile(message) {
|
||||
};
|
||||
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);
|
||||
});
|
||||
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));
|
||||
}
|
||||
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
|
||||
self.postMessage({
|
||||
type: 'tile',
|
||||
requestId: message.requestId,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
(function initSpreadsheetPreview(global) {
|
||||
'use strict';
|
||||
|
||||
const SPREADSHEET_ASSET_VERSION = '4b7074e75ab1';
|
||||
const SPREADSHEET_ASSET_VERSION = '91b615278d5b';
|
||||
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 20000;
|
||||
const MAX_SCROLL_PX = 8000000;
|
||||
@@ -213,22 +213,38 @@
|
||||
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) {
|
||||
// Headings take their size from the same axis math as the cells, so custom
|
||||
// widths/heights line up; hidden (0 px) rows and columns get no heading and
|
||||
// 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;
|
||||
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 + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) / scaleY}px`;
|
||||
heading.style.top = `${COLUMN_HEADING_HEIGHT + top / scaleY}px`;
|
||||
heading.style.height = `${height}px`;
|
||||
heading.style.left = `${grid.scrollLeft}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
for (let col = latestRange.c1; col <= latestRange.c2 && col < latestRange.c1 + 100; col += 1) {
|
||||
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;
|
||||
if (width <= 0) continue;
|
||||
columnHeadings += 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.left = `${ROW_HEADING_WIDTH + left / scaleX}px`;
|
||||
heading.style.width = `${width}px`;
|
||||
heading.style.top = `${grid.scrollTop}px`;
|
||||
headingsLayer.appendChild(heading);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
* 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.
|
||||
* rather than truncated. It returns the entries it inflated, and the worker
|
||||
* hands ExcelJS a STORE-only archive rebuilt from exactly those
|
||||
* (`buildAdmittedArchive()`), never the original bytes: admission follows local
|
||||
* headers while ExcelJS (JSZip) follows the central directory, so overlapping
|
||||
* entries could otherwise show each reader a different file.
|
||||
*/
|
||||
|
||||
(function initSpreadsheetXlsxCore(global) {
|
||||
@@ -157,6 +161,7 @@
|
||||
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 inflatedEntries = Object.create(null);
|
||||
const counts = { worksheets: 0, cells: 0, merges: 0, styles: 0 };
|
||||
const features = new Set();
|
||||
let totalInflated = 0;
|
||||
@@ -164,6 +169,9 @@
|
||||
let thrown;
|
||||
const unzip = new zipApi.Unzip((file) => {
|
||||
if (!directoryByName.has(file.name)) fail('malformed', 'Local XLSX entry is absent from the central directory');
|
||||
// The admitted archive is rebuilt from these entries, which cannot hold two
|
||||
// files under one name, so a duplicate name is refused rather than dropped.
|
||||
if (streamedEntries.has(file.name)) fail('malformed', 'Duplicate XLSX entry name');
|
||||
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');
|
||||
@@ -175,8 +183,10 @@
|
||||
if (feature) features.add(feature);
|
||||
const counter = createXmlCounter(file.name, counts, limits);
|
||||
let entryInflated = 0;
|
||||
const chunks = [];
|
||||
file.ondata = (error, chunk, final) => {
|
||||
if (error) throw error;
|
||||
chunks.push(chunk.slice());
|
||||
entryInflated += chunk.length;
|
||||
totalInflated += chunk.length;
|
||||
if (entryInflated > limits.maxEntryBytes) fail('entry-size', 'Inflated ZIP entry exceeds the entry limit');
|
||||
@@ -186,6 +196,16 @@
|
||||
fail('compression-ratio', 'ZIP entry exceeds the compression ratio limit');
|
||||
}
|
||||
counter.push(chunk, final);
|
||||
if (final) {
|
||||
const data = new Uint8Array(entryInflated);
|
||||
let offset = 0;
|
||||
for (const part of chunks) {
|
||||
data.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
chunks.length = 0;
|
||||
inflatedEntries[file.name] = data;
|
||||
}
|
||||
};
|
||||
file.start();
|
||||
});
|
||||
@@ -203,7 +223,17 @@
|
||||
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 };
|
||||
for (const name of streamedEntries.keys()) {
|
||||
if (!(name in inflatedEntries)) fail('malformed', 'XLSX entry did not finish streaming for admission');
|
||||
}
|
||||
return { counts, features: Array.from(features), inflatedBytes: totalInflated, entries: inflatedEntries };
|
||||
}
|
||||
|
||||
// The ONLY bytes ExcelJS may parse: the entries admission itself inflated and
|
||||
// counted, re-packed uncompressed. Its size is ~ the inflated total (already
|
||||
// capped at LIMITS.maxInflatedBytes) plus per-entry headers.
|
||||
function buildAdmittedArchive(admission, zipApi) {
|
||||
return zipApi.zipSync(admission.entries, { level: 0 });
|
||||
}
|
||||
|
||||
function parseCellRef(ref) {
|
||||
@@ -292,37 +322,82 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Rounded to whole milliseconds: `new Date(fraction)` truncates, which turned
|
||||
// midnight minus a float error into the previous day.
|
||||
function excelDate(serial, date1904) {
|
||||
if (date1904) return new Date(Date.UTC(1904, 0, 1) + Number(serial) * 86400000);
|
||||
if (date1904) return new Date(Math.round(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);
|
||||
return new Date(Math.round(Date.UTC(1899, 11, 31) + adjusted * 86400000));
|
||||
}
|
||||
|
||||
function isDateValue(value) {
|
||||
return Object.prototype.toString.call(value) === '[object Date]' && Number.isFinite(value.getTime());
|
||||
}
|
||||
|
||||
// Inverse of ExcelJS's own `excelToDate()` (utils.js), which builds the Date
|
||||
// from the serial in UTC. Recovering the serial keeps the result independent of
|
||||
// the viewer's timezone; `String(date)` rendered it in local time, a day early
|
||||
// at any negative UTC offset.
|
||||
function dateToSerial(date, date1904) {
|
||||
return 25569 + date.getTime() / 86400000 - (date1904 ? 1462 : 0);
|
||||
}
|
||||
|
||||
const DATE_FORMAT = /^[ymd\-/ ]+$/i;
|
||||
const TIME_FORMAT = /^[hms: ]+$/i;
|
||||
const DATE_TIME_FORMAT = /^[ymdhis\-/: ]+$/i;
|
||||
|
||||
function isFormulaValue(value) {
|
||||
return 'formula' in value || 'sharedFormula' in value;
|
||||
}
|
||||
|
||||
// Every non-scalar shape ExcelJS loads a cell value as. Anything not handled
|
||||
// here would otherwise reach String() and render as "[object Object]".
|
||||
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: '' };
|
||||
if (isDateValue(value)) {
|
||||
const code = String(format || 'General');
|
||||
const known = DATE_FORMAT.test(code) || TIME_FORMAT.test(code) || DATE_TIME_FORMAT.test(code);
|
||||
const serial = dateToSerial(value, date1904);
|
||||
if (known) return formatCellValue(serial, code, date1904);
|
||||
const fallback = serial % 1 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm';
|
||||
const formatted = formatCellValue(serial, fallback, date1904);
|
||||
return /^General$/i.test(code)
|
||||
? formatted
|
||||
: { text: formatted.text, warning: `Unsupported number format: ${code}` };
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (isFormulaValue(value)) {
|
||||
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
|
||||
const source = typeof value.formula === 'string' ? `=${value.formula}` : '';
|
||||
return { text: source, warning: 'Formula has no cached result' };
|
||||
}
|
||||
if (typeof value.error === 'string') return { text: value.error };
|
||||
if (Array.isArray(value.richText)) {
|
||||
return { text: value.richText.map((run) => (typeof run?.text === 'string' ? run.text : '')).join('') };
|
||||
}
|
||||
// Hyperlink: the display text, never the target. The text may be rich.
|
||||
if ('text' in value) return formatCellValue(value.text, 'General', date1904);
|
||||
return { text: '', warning: 'Unsupported cell value' };
|
||||
}
|
||||
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)) {
|
||||
if (DATE_FORMAT.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)) {
|
||||
if (TIME_FORMAT.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)) {
|
||||
if (DATE_TIME_FORMAT.test(code)) {
|
||||
const date = excelDate(value, Boolean(date1904));
|
||||
const yyyy = date.getUTCFullYear();
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
@@ -567,6 +642,7 @@
|
||||
XlsxPreviewError,
|
||||
inspectZipDirectory,
|
||||
admitXlsx,
|
||||
buildAdmittedArchive,
|
||||
parseCellRef,
|
||||
parseRange,
|
||||
deriveExtent,
|
||||
|
||||
@@ -19167,15 +19167,15 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only the fixed gutter dimension lives here; the per-row height and per-column
|
||||
width come inline from spreadsheet-preview.js's axis math. */
|
||||
.spreadsheet-row-heading {
|
||||
left: 0;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.spreadsheet-column-heading {
|
||||
top: 0;
|
||||
width: 64px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
|
||||
@@ -81,4 +82,15 @@ describe('spreadsheet preview assets', () => {
|
||||
expect(check).toContain("createHash('sha256')");
|
||||
expect(read('src/web/public/spreadsheet-preview.js')).toMatch(/const SPREADSHEET_ASSET_VERSION = '[a-f0-9]{12}'/);
|
||||
});
|
||||
|
||||
it('names every accepted document type in the attachments panel help', () => {
|
||||
expect(DOCUMENT_ATTACHMENT_EXTENSIONS).toContain('xlsx');
|
||||
const help = /<div>Supports ([^<]+)<\/div>/.exec(read('src/web/public/panels-ui.js'))?.[1] || '';
|
||||
for (const extension of DOCUMENT_ATTACHMENT_EXTENSIONS) {
|
||||
expect(isSupportedAttachmentExtension(extension)).toBe(true);
|
||||
expect(help).toContain(`.${extension}`);
|
||||
}
|
||||
// The CLI's refusal text is built from the same list rather than restating it.
|
||||
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { crc32 } from 'node:zlib';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
import ExcelJS from 'exceljs';
|
||||
import * as fflate from 'fflate';
|
||||
|
||||
// A negative UTC offset is what turned ExcelJS `Date` cells into the previous
|
||||
// day. Node re-reads TZ on assignment; the date test asserts it took effect.
|
||||
const originalTz = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
afterAll(() => {
|
||||
if (originalTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const workerSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-preview-worker.js'), 'utf8');
|
||||
const coreSource = readFileSync(resolve(root, 'src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
|
||||
@@ -121,6 +131,7 @@ function createHarness() {
|
||||
return {
|
||||
messages,
|
||||
imports,
|
||||
self,
|
||||
send: async (data: unknown) => {
|
||||
await (self.onmessage as (event: { data: unknown }) => Promise<void>)({ data });
|
||||
},
|
||||
@@ -235,3 +246,265 @@ describe('spreadsheet preview worker', () => {
|
||||
expect(harness.messages.at(-1)).toMatchObject({ type: 'metadata', warnings: ['charts'] });
|
||||
});
|
||||
});
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function writeWorkbook(workbook: ExcelJS.Workbook): Promise<ArrayBuffer> {
|
||||
return toArrayBuffer(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
||||
}
|
||||
|
||||
type Harness = ReturnType<typeof createHarness>;
|
||||
type Range = { r1: number; c1: number; r2: number; c2: number };
|
||||
|
||||
async function loadMetadata(harness: Harness, bytes: ArrayBuffer): Promise<Record<string, any>> {
|
||||
await harness.send({ type: 'load', bytes });
|
||||
const metadata = harness.messages.at(-1) as Record<string, any>;
|
||||
expect(metadata.type, JSON.stringify(metadata)).toBe('metadata');
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function requestTile(harness: Harness, sheetId: string, range: Range): Promise<Record<string, any>> {
|
||||
await harness.send({ type: 'tile', requestId: 1, sheetId, range });
|
||||
return harness.messages.at(-1) as Record<string, any>;
|
||||
}
|
||||
|
||||
/** The range spreadsheet-preview.js asks for at scroll 0 with its fallback 800x500 viewport. */
|
||||
function defaultViewportRange(harness: Harness, sheet: Record<string, any>): Range {
|
||||
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
||||
createSparseAxis(count: number, size: number, overrides: Array<[number, number]>): unknown;
|
||||
axisIndexAt(axis: unknown, offset: number): number;
|
||||
};
|
||||
const rows = core.createSparseAxis(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides);
|
||||
const cols = core.createSparseAxis(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides);
|
||||
return {
|
||||
r1: 1,
|
||||
c1: 1,
|
||||
r2: Math.min(sheet.rows, core.axisIndexAt(rows, 500) + 2),
|
||||
c2: Math.min(sheet.cols, core.axisIndexAt(cols, 800) + 2),
|
||||
};
|
||||
}
|
||||
|
||||
describe('spreadsheet preview worker: ExcelJS value shapes', () => {
|
||||
it('formats Date, rich text, hyperlink, error and formula-result cells as ExcelJS loads them', async () => {
|
||||
// Proves the negative-offset TZ is really in force for this file.
|
||||
expect(new Date(Date.UTC(2024, 0, 15)).getDate()).toBe(14);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Values');
|
||||
const day = new Date(Date.UTC(2024, 0, 15));
|
||||
sheet.getCell('A1').value = day;
|
||||
sheet.getCell('A1').numFmt = 'yyyy-mm-dd';
|
||||
sheet.getCell('A2').value = new Date(Date.UTC(2024, 0, 15, 13, 45));
|
||||
sheet.getCell('A2').numFmt = 'yyyy-mm-dd hh:mm';
|
||||
sheet.getCell('A3').value = day; // ExcelJS's default date format (mm-dd-yy)
|
||||
sheet.getCell('A4').value = { richText: [{ text: 'Hello ' }, { font: { bold: true }, text: 'World' }] };
|
||||
sheet.getCell('A5').value = { text: 'Codeman', hyperlink: 'https://example.invalid/' };
|
||||
sheet.getCell('A6').value = { error: '#DIV/0!' } as ExcelJS.CellErrorValue;
|
||||
sheet.getCell('A7').value = { formula: '1/0', result: { error: '#DIV/0!' } } as ExcelJS.CellFormulaValue;
|
||||
sheet.getCell('A8').value = { formula: 'ROW()', result: 8, shareType: 'shared', ref: 'A8:A9' } as never;
|
||||
sheet.getCell('A9').value = { sharedFormula: 'A8', result: 9 } as ExcelJS.CellSharedFormulaValue;
|
||||
sheet.getCell('A10').value = { formula: 'DATE(2024,1,15)', result: day } as ExcelJS.CellFormulaValue;
|
||||
sheet.getCell('A10').numFmt = 'yyyy-mm-dd';
|
||||
sheet.getCell('A11').value = new Date(Date.UTC(1899, 11, 30, 6, 30, 15));
|
||||
sheet.getCell('A11').numFmt = 'hh:mm:ss';
|
||||
// Float error lands this one just under 00:05; truncating it showed 00:04.
|
||||
sheet.getCell('A12').value = new Date(Date.UTC(2020, 0, 1, 0, 5));
|
||||
sheet.getCell('A12').numFmt = 'yyyy-mm-dd hh:mm';
|
||||
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 12, c2: 1 });
|
||||
expect(tile.type).toBe('tile');
|
||||
const text = new Map((tile.cells as Array<{ row: number; text: string }>).map((cell) => [cell.row, cell.text]));
|
||||
expect(Object.fromEntries(text)).toEqual({
|
||||
1: '2024-01-15',
|
||||
2: '2024-01-15 13:45',
|
||||
3: '2024-01-15',
|
||||
4: 'Hello World',
|
||||
5: 'Codeman',
|
||||
6: '#DIV/0!',
|
||||
7: '#DIV/0!',
|
||||
8: '8',
|
||||
9: '9',
|
||||
10: '2024-01-15',
|
||||
11: '06:30:15',
|
||||
12: '2020-01-01 00:05',
|
||||
});
|
||||
for (const value of text.values()) expect(value).not.toMatch(/object Object|GMT/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spreadsheet preview worker: hidden rows and dense tiles', () => {
|
||||
it('skips filtered-out rows and hidden columns at the renderer default viewport', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Filtered');
|
||||
for (let row = 1; row <= 1000; row += 1) {
|
||||
for (let col = 1; col <= 6; col += 1) sheet.getCell(row, col).value = row * 10 + col;
|
||||
}
|
||||
sheet.autoFilter = 'A1:F1000';
|
||||
for (let row = 2; row <= 981; row += 1) sheet.getRow(row).hidden = true; // 980 rows filtered out
|
||||
sheet.getColumn(3).hidden = true;
|
||||
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
||||
const range = defaultViewportRange(harness, metadata.sheets[0]);
|
||||
expect(range).toEqual({ r1: 1, c1: 1, r2: 1000, c2: 6 });
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, range);
|
||||
|
||||
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
||||
const cells = tile.cells as Array<{ row: number; col: number }>;
|
||||
expect(cells).toHaveLength(20 * 5);
|
||||
expect(cells.some((cell) => cell.row >= 2 && cell.row <= 981)).toBe(false);
|
||||
expect(cells.some((cell) => cell.col === 3)).toBe(false);
|
||||
expect(tile.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a truncated tile with a warning instead of failing on a dense 60x60 block', async () => {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Dense');
|
||||
for (let row = 1; row <= 60; row += 1) {
|
||||
for (let col = 1; col <= 60; col += 1) sheet.getCell(row, col).value = row * 100 + col;
|
||||
}
|
||||
const harness = createHarness();
|
||||
const metadata = await loadMetadata(harness, await writeWorkbook(workbook));
|
||||
const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 60, c2: 60 });
|
||||
|
||||
expect(tile.type, JSON.stringify(tile)).toBe('tile');
|
||||
expect(tile.cells).toHaveLength(2500);
|
||||
expect(tile.cells[0]).toMatchObject({ row: 1, col: 1, text: '101' });
|
||||
expect(tile.warnings).toEqual([expect.stringMatching(/truncated/i)]);
|
||||
});
|
||||
});
|
||||
|
||||
type ZipPart = { name: string; data: Uint8Array; method: 0 | 8; size: number; crc: number };
|
||||
|
||||
function zipPart(name: string, content: Uint8Array, method: 0 | 8): ZipPart {
|
||||
return {
|
||||
name,
|
||||
data: method === 8 ? fflate.deflateSync(content) : content,
|
||||
method,
|
||||
size: content.length,
|
||||
crc: crc32(content) >>> 0,
|
||||
};
|
||||
}
|
||||
|
||||
function localHeader(part: ZipPart): Uint8Array {
|
||||
const name = fflate.strToU8(part.name);
|
||||
const header = new Uint8Array(30 + name.length);
|
||||
const view = new DataView(header.buffer);
|
||||
view.setUint32(0, 0x04034b50, true);
|
||||
view.setUint16(4, 20, true);
|
||||
view.setUint16(8, part.method, true);
|
||||
view.setUint32(14, part.crc, true);
|
||||
view.setUint32(18, part.data.length, true);
|
||||
view.setUint32(22, part.size, true);
|
||||
view.setUint16(26, name.length, true);
|
||||
header.set(name, 30);
|
||||
return header;
|
||||
}
|
||||
|
||||
function centralHeader(part: ZipPart, offset: number): Uint8Array {
|
||||
const name = fflate.strToU8(part.name);
|
||||
const header = new Uint8Array(46 + name.length);
|
||||
const view = new DataView(header.buffer);
|
||||
view.setUint32(0, 0x02014b50, true);
|
||||
view.setUint16(4, 20, true);
|
||||
view.setUint16(6, 20, true);
|
||||
view.setUint16(10, part.method, true);
|
||||
view.setUint32(16, part.crc, true);
|
||||
view.setUint32(20, part.data.length, true);
|
||||
view.setUint32(24, part.size, true);
|
||||
view.setUint16(28, name.length, true);
|
||||
view.setUint32(42, offset, true);
|
||||
header.set(name, 46);
|
||||
return header;
|
||||
}
|
||||
|
||||
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviewer's bypass: a STORED carrier entry whose data is a complete local
|
||||
* entry for a huge `sheet1.xml`, followed later by a one-cell decoy `sheet1.xml`.
|
||||
* The central directory points `sheet1.xml` INSIDE the carrier, so a local-header
|
||||
* walk (admission) meets the decoy while JSZip (ExcelJS) reads the hidden sheet.
|
||||
*/
|
||||
async function overlappingEntryWorkbook(hiddenRows = 11_000, hiddenCols = 10): Promise<Uint8Array> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.addWorksheet('Data').getCell('A1').value = 'decoy';
|
||||
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
|
||||
const decoyXml = fflate.strFromU8(entries['xl/worksheets/sheet1.xml']);
|
||||
const letters = Array.from({ length: hiddenCols }, (_, index) => String.fromCharCode(65 + index));
|
||||
let rows = '';
|
||||
for (let row = 1; row <= hiddenRows; row += 1) {
|
||||
rows += `<row r="${row}">${letters.map((letter) => `<c r="${letter}${row}"><v>${row}</v></c>`).join('')}</row>`;
|
||||
}
|
||||
const hiddenXml = decoyXml.replace(/<sheetData>[\s\S]*<\/sheetData>/, `<sheetData>${rows}</sheetData>`);
|
||||
expect(hiddenXml).not.toBe(decoyXml);
|
||||
|
||||
const hidden = zipPart('xl/worksheets/sheet1.xml', fflate.strToU8(hiddenXml), 8);
|
||||
const carrier = zipPart('docProps/carrier.bin', concatBytes([localHeader(hidden), hidden.data]), 0);
|
||||
const decoy = zipPart('xl/worksheets/sheet1.xml', entries['xl/worksheets/sheet1.xml'], 8);
|
||||
const others = Object.keys(entries)
|
||||
.filter((name) => name !== 'xl/worksheets/sheet1.xml')
|
||||
.map((name) => zipPart(name, entries[name], 8));
|
||||
|
||||
const locals: Uint8Array[] = [];
|
||||
const central: Uint8Array[] = [];
|
||||
let offset = 0;
|
||||
const emit = (part: ZipPart) => {
|
||||
const at = offset;
|
||||
const chunk = concatBytes([localHeader(part), part.data]);
|
||||
locals.push(chunk);
|
||||
offset += chunk.length;
|
||||
return at;
|
||||
};
|
||||
for (const part of others) central.push(centralHeader(part, emit(part)));
|
||||
const carrierOffset = emit(carrier);
|
||||
central.push(centralHeader(carrier, carrierOffset));
|
||||
emit(decoy); // streamed by admission, absent from the central directory
|
||||
const hiddenOffset = carrierOffset + 30 + fflate.strToU8(carrier.name).length;
|
||||
central.push(centralHeader(hidden, hiddenOffset));
|
||||
|
||||
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: ExcelJS sees only what admission checked', () => {
|
||||
it('parses the admitted decoy, never a sheet hidden inside an overlapping stored entry', async () => {
|
||||
const crafted = await overlappingEntryWorkbook();
|
||||
// The unpatched pipeline: JSZip, reading the central directory, finds the hidden sheet.
|
||||
const direct = new ExcelJS.Workbook();
|
||||
await direct.xlsx.load(toArrayBuffer(crafted));
|
||||
expect(direct.worksheets[0].rowCount).toBe(11_000);
|
||||
|
||||
const harness = createHarness();
|
||||
const core = harness.self.CodemanSpreadsheetXlsxCore as {
|
||||
admitXlsx(bytes: Uint8Array, zip: typeof fflate): { counts: { cells: number } };
|
||||
};
|
||||
// Admission walks local headers, so it only ever counts the one-cell decoy.
|
||||
expect(core.admitXlsx(crafted, fflate).counts.cells).toBe(1);
|
||||
|
||||
await harness.send({ type: 'load', bytes: toArrayBuffer(crafted) });
|
||||
const result = harness.messages.at(-1) as Record<string, any>;
|
||||
// Either outcome is safe; parsing the 110k hidden cells is not.
|
||||
if (result.type === 'metadata') expect(result.sheets[0]).toMatchObject({ rows: 1, cols: 1 });
|
||||
else expect(result.type).toBe('error');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ async function workbookBytes(): Promise<Buffer> {
|
||||
const summary = workbook.addWorksheet('Summary');
|
||||
summary.getCell('A1').value = 'Local workbook';
|
||||
summary.getCell('B2').value = 42;
|
||||
summary.getColumn(2).width = 18;
|
||||
summary.getRow(2).height = 30;
|
||||
summary.mergeCells('A3:C3');
|
||||
summary.getCell('A3').value = 'Merged cells';
|
||||
summary.getCell('A100').value = 'Far row';
|
||||
@@ -92,6 +94,24 @@ describe('spreadsheet preview browser boundary', () => {
|
||||
});
|
||||
expect(initialGeometry.left).toBeGreaterThanOrEqual(36);
|
||||
expect(initialGeometry.top).toBeGreaterThanOrEqual(20);
|
||||
// Headings line up with a custom-width column and a custom-height row.
|
||||
const headingFit = await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||
const rect = (selector: string, text: string) => {
|
||||
const element = [...grid.querySelectorAll(selector)].find((node) => node.textContent === text);
|
||||
if (!element) throw new Error(`Missing ${selector} ${text}`);
|
||||
return element.getBoundingClientRect();
|
||||
};
|
||||
const cell = rect('.spreadsheet-cell', '42');
|
||||
const column = rect('.spreadsheet-column-heading', 'B');
|
||||
const row = rect('.spreadsheet-row-heading', '2');
|
||||
return {
|
||||
cell: [cell.left, cell.width, cell.top, cell.height],
|
||||
heading: [column.left, column.width, row.top, row.height],
|
||||
};
|
||||
});
|
||||
expect(headingFit.cell[1]).toBe(126);
|
||||
expect(headingFit.cell[3]).toBe(40);
|
||||
expect(headingFit.heading).toEqual(headingFit.cell);
|
||||
await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||
grid.scrollTop = 400;
|
||||
grid.scrollLeft = 400;
|
||||
|
||||
@@ -225,6 +225,63 @@ describe('spreadsheet preview renderer', () => {
|
||||
expect(document.body.textContent).not.toContain('old cell');
|
||||
});
|
||||
|
||||
it('sizes row and column headings from the same axis math as the cells', 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());
|
||||
// Column B 18 wide and row 2 30pt tall, as the worker reports the fixture in
|
||||
// spreadsheet-preview-worker.test.ts; row 3 and column C hidden.
|
||||
worker.emit({
|
||||
type: 'metadata',
|
||||
styles: [],
|
||||
sheets: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Summary',
|
||||
rows: 4,
|
||||
cols: 4,
|
||||
defaultRowHeight: 20,
|
||||
defaultColumnWidth: 64,
|
||||
rowOverrides: [
|
||||
[2, 40],
|
||||
[3, 0],
|
||||
],
|
||||
columnOverrides: [
|
||||
[2, 126],
|
||||
[3, 0],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||
worker.emit({
|
||||
type: 'tile',
|
||||
requestId: request.requestId,
|
||||
sheetId: '1',
|
||||
cells: [{ row: 2, col: 2, text: 'B2', styleId: 0 }],
|
||||
warnings: [],
|
||||
});
|
||||
const cell = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||
const heading = (selector: string, text: string) =>
|
||||
[...document.querySelectorAll(selector)].find((element) => element.textContent === text) as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
const columnB = heading('.spreadsheet-column-heading', 'B');
|
||||
const row2 = heading('.spreadsheet-row-heading', '2');
|
||||
expect(columnB?.style.width).toBe(cell.style.width);
|
||||
expect(columnB?.style.left).toBe(cell.style.left);
|
||||
expect(row2?.style.height).toBe(cell.style.height);
|
||||
expect(row2?.style.top).toBe(cell.style.top);
|
||||
expect(heading('.spreadsheet-column-heading', 'A')?.style.width).toBe('64px');
|
||||
expect(heading('.spreadsheet-row-heading', '1')?.style.height).toBe('20px');
|
||||
// A hidden row or column has no size, so it gets no heading at all.
|
||||
expect(heading('.spreadsheet-column-heading', 'C')).toBeUndefined();
|
||||
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits colour and background together or not at all', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||
const renderer = loadRenderer(fetchMock);
|
||||
|
||||
@@ -11,6 +11,7 @@ type Core = {
|
||||
XlsxPreviewError: new (code: string, message: string) => Error & { code: string };
|
||||
inspectZipDirectory(bytes: Uint8Array, limits?: Record<string, number>): { entries: Array<{ name: string }> };
|
||||
admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record<string, number>): unknown;
|
||||
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
|
||||
parseCellRef(ref: string): { row: number; col: number } | null;
|
||||
deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number };
|
||||
createSparseAxis(count: number, defaultSize: number, overrides: Array<[number, number]>): unknown;
|
||||
@@ -110,6 +111,41 @@ describe('spreadsheet XLSX core', () => {
|
||||
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
|
||||
});
|
||||
|
||||
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
|
||||
const zip = workbookZip();
|
||||
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
|
||||
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
|
||||
const rebuilt = core.buildAdmittedArchive(admission, fflate);
|
||||
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
|
||||
name: string;
|
||||
compressedSize: number;
|
||||
declaredSize: number;
|
||||
}>;
|
||||
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
|
||||
const roundTrip = fflate.unzipSync(rebuilt);
|
||||
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
|
||||
});
|
||||
|
||||
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
|
||||
const zip = workbookZip();
|
||||
class DuplicateUnzip {
|
||||
constructor(private readonly onFile: (file: any) => void) {}
|
||||
register() {}
|
||||
push(_bytes: Uint8Array, final: boolean) {
|
||||
if (!final) return;
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const file: Record<string, any> = {
|
||||
name: 'xl/workbook.xml',
|
||||
start: () => file.ondata(null, new Uint8Array(1), true),
|
||||
};
|
||||
this.onFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
|
||||
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
|
||||
});
|
||||
|
||||
it('derives bounded extents from real cells and merges', () => {
|
||||
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
|
||||
expect(core.parseCellRef('XFE1')).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user