diff --git a/CLAUDE.md b/CLAUDE.md index 712f6e17..ec025c29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6a25c5b3..bad2aa88 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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. diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts index 2d47f59d..010a1af6 100644 --- a/src/attachment-registry.ts +++ b/src/attachment-registry.ts @@ -53,17 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet = new Set([ */ export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet = 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, diff --git a/src/cli.ts b/src/cli.ts index 1b60246c..1083f777 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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); } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 16dc7eed..76c72299 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4790,7 +4790,7 @@ Object.assign(CodemanApp.prototype, {
No attachments yet
Show a file here by running:
codeman attach /absolute/path/to/file.pptx -
Supports .pptx, .docx, .pdf, .png, .md, and .txt.
+
Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.
`; return; diff --git a/src/web/public/spreadsheet-preview-worker.js b/src/web/public/spreadsheet-preview-worker.js index 839e07f0..4b784d8c 100644 --- a/src/web/public/spreadsheet-preview-worker.js +++ b/src/web/public/spreadsheet-preview-worker.js @@ -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, diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js index 01b690e5..75162d4d 100644 --- a/src/web/public/spreadsheet-preview.js +++ b/src/web/public/spreadsheet-preview.js @@ -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); } diff --git a/src/web/public/spreadsheet-xlsx-core.js b/src/web/public/spreadsheet-xlsx-core.js index 66d5728d..ffeaea8e 100644 --- a/src/web/public/spreadsheet-xlsx-core.js +++ b/src/web/public/spreadsheet-xlsx-core.js @@ -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, diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 66b48f34..de919e72 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; } diff --git a/test/spreadsheet-assets.test.ts b/test/spreadsheet-assets.test.ts index d07653a0..a87033ea 100644 --- a/test/spreadsheet-assets.test.ts +++ b/test/spreadsheet-assets.test.ts @@ -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 = /
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(', ')"); + }); }); diff --git a/test/spreadsheet-preview-worker.test.ts b/test/spreadsheet-preview-worker.test.ts index 7f7e3b80..ef4ef817 100644 --- a/test/spreadsheet-preview-worker.test.ts +++ b/test/spreadsheet-preview-worker.test.ts @@ -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)({ 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 { + return toArrayBuffer(new Uint8Array(await workbook.xlsx.writeBuffer())); +} + +type Harness = ReturnType; +type Range = { r1: number; c1: number; r2: number; c2: number }; + +async function loadMetadata(harness: Harness, bytes: ArrayBuffer): Promise> { + await harness.send({ type: 'load', bytes }); + const metadata = harness.messages.at(-1) as Record; + expect(metadata.type, JSON.stringify(metadata)).toBe('metadata'); + return metadata; +} + +async function requestTile(harness: Harness, sheetId: string, range: Range): Promise> { + await harness.send({ type: 'tile', requestId: 1, sheetId, range }); + return harness.messages.at(-1) as Record; +} + +/** The range spreadsheet-preview.js asks for at scroll 0 with its fallback 800x500 viewport. */ +function defaultViewportRange(harness: Harness, sheet: Record): 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 { + 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 += `${letters.map((letter) => `${row}`).join('')}`; + } + const hiddenXml = decoyXml.replace(/[\s\S]*<\/sheetData>/, `${rows}`); + 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; + // 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); +}); diff --git a/test/spreadsheet-preview.browser.test.ts b/test/spreadsheet-preview.browser.test.ts index 5229f674..f9cfe27d 100644 --- a/test/spreadsheet-preview.browser.test.ts +++ b/test/spreadsheet-preview.browser.test.ts @@ -15,6 +15,8 @@ async function workbookBytes(): Promise { 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; diff --git a/test/spreadsheet-preview.test.ts b/test/spreadsheet-preview.test.ts index 56480b85..1836e2f4 100644 --- a/test/spreadsheet-preview.test.ts +++ b/test/spreadsheet-preview.test.ts @@ -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); diff --git a/test/spreadsheet-xlsx-core.test.ts b/test/spreadsheet-xlsx-core.test.ts index 6f2621e3..61083cd1 100644 --- a/test/spreadsheet-xlsx-core.test.ts +++ b/test/spreadsheet-xlsx-core.test.ts @@ -11,6 +11,7 @@ type Core = { XlsxPreviewError: new (code: string, message: string) => Error & { code: string }; inspectZipDirectory(bytes: Uint8Array, limits?: Record): { entries: Array<{ name: string }> }; admitXlsx(bytes: Uint8Array, zip: typeof fflate, limits?: Record): 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; 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 = { + 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();