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

xlsx files were download-only. Add a read-only, virtualized preview (sheet
tabs, number formats, merges, theme colours) parsed entirely in a browser
Web Worker with exceljs and fflate, loaded only when a spreadsheet is
opened. The workbook is checked against ZIP-bomb, entry and cell limits
before exceljs loads; cell text is written with textContent, formulas are
never evaluated and nothing referenced by the workbook is fetched. On the
server xlsx only joins the existing allowlist and classification, with a
10 MB cap on ?preview=true. xls and ods stay download-only.
This commit is contained in:
Aamer Akhter
2026-09-26 23:13:21 -04:00
parent 45ea2e1d32
commit 0b122e2c76
27 changed files with 3765 additions and 13 deletions
+2 -2
View File
@@ -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) **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`. → [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, 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) **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)
@@ -312,7 +312,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend ### Frontend
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
+1
View File
@@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [
'test/split-pane-terminal.browser.test.ts', 'test/split-pane-terminal.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts',
'test/spreadsheet-preview.browser.test.ts',
]; ];
/** /**
+1004
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -127,6 +127,8 @@
"agent-browser": "^0.6.0", "agent-browser": "^0.6.0",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"exceljs": "4.4.0",
"fflate": "0.8.2",
"pixelmatch": "^6.0.0", "pixelmatch": "^6.0.0",
"playwright": "^1.58.0", "playwright": "^1.58.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
+4
View File
@@ -49,6 +49,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li
run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'); run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'); run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'); run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js,
// never by the page (see scripts/prepare-spreadsheet-assets.mjs).
run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)` // Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
appendFileSync( appendFileSync(
@@ -125,6 +128,7 @@ console.log('\n[build] content-hash cache busting');
'api-client.js', 'api-client.js',
'subagent-windows.js', 'subagent-windows.js',
'image-input.js', 'image-input.js',
'spreadsheet-preview.js',
'vendor/xterm-zerolag-input.js', 'vendor/xterm-zerolag-input.js',
'vendor/xterm-predictive-echo.js', 'vendor/xterm-predictive-echo.js',
]; ];
+39 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs'; import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, extname, join, relative, resolve } from 'node:path'; import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const publicRoot = resolve(repoRoot, 'src/web/public'); const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier'); const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']); const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate).
// They load only inside the spreadsheet worker, but a dependency bump that
// balloons them should be a deliberate decision, not a silent one.
const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000;
function collectTextAssets(dir) { function collectTextAssets(dir) {
const files = []; const files = [];
@@ -35,6 +40,39 @@ function findNullByte(buffer) {
const files = collectTextAssets(publicRoot); const files = collectTextAssets(publicRoot);
const failures = []; const failures = [];
// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the
// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be
// the content hash of everything the worker loads, or a deploy can pair a new
// worker with a stale cached core/vendor file (static assets are cached 1y).
const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js');
const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js');
const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js');
const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')];
if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) {
const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0);
if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) {
failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`);
}
const expectedVersion = createHash('sha256')
.update(readFileSync(spreadsheetWorker))
.update(readFileSync(spreadsheetCore))
.update(readFileSync(spreadsheetVendors[0]))
.update(readFileSync(spreadsheetVendors[1]))
.digest('hex')
.slice(0, 12);
const entrySource = readFileSync(spreadsheetEntry, 'utf8');
const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1];
if (actualVersion !== expectedVersion) {
failures.push(
`SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}`
);
}
} else {
failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`');
}
for (const file of files) { for (const file of files) {
const rel = relative(repoRoot, file); const rel = relative(repoRoot, file);
const data = readFileSync(file); const data = readFileSync(file);
+16
View File
@@ -341,6 +341,22 @@ if (isGlobalInstall) {
} }
} }
// ----------------------------------------------------------------------------
// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into
// src/web/public/vendor/ for dev mode. The build does the same into dist/.
// ----------------------------------------------------------------------------
if (!isGlobalInstall) {
try {
execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' });
console.log(colors.green('✓ Spreadsheet preview vendor files prepared'));
} catch (err) {
hasWarnings = true;
console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files'));
console.log(colors.dim(` ${err.message}`));
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode // 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode
// (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal. // (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor
* dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by
* build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly
* in package.json, and check-public-assets.mjs hashes the output into
* SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump
* that changes the bytes fails that check until the token is refreshed.
* Source-map comments are stripped: the maps are not shipped.
*/
import { createRequire } from 'node:module';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
const require = createRequire(import.meta.url);
const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor'));
const excelSource = require.resolve('exceljs/dist/exceljs.min.js');
const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js');
function copyBrowserBundle(source, outputName) {
const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n');
if (/sourceMappingURL/.test(content)) {
throw new Error(`Failed to strip sourceMappingURL from ${outputName}`);
}
writeFileSync(join(outputDir, outputName), content, 'utf8');
}
mkdirSync(outputDir, { recursive: true });
copyBrowserBundle(excelSource, 'exceljs.min.js');
copyBrowserBundle(fflateSource, 'fflate.min.js');
+3
View File
@@ -62,6 +62,8 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
'pdf', 'pdf',
'docx', 'docx',
'pptx', 'pptx',
// Previewed client-side (spreadsheet-preview-worker.js); served raw like the rest.
'xlsx',
'md', 'md',
'txt', 'txt',
...VIDEO_ATTACHMENT_EXTENSIONS, ...VIDEO_ATTACHMENT_EXTENSIONS,
@@ -154,6 +156,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType {
if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio'; if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio';
if (normalized === 'pdf') return 'pdf'; if (normalized === 'pdf') return 'pdf';
if (normalized === 'pptx') return 'presentation'; if (normalized === 'pptx') return 'presentation';
if (normalized === 'xlsx') return 'spreadsheet';
if (normalized === 'md') return 'markdown'; if (normalized === 'md') return 'markdown';
// Everything else in the text family reads as text, including code and // Everything else in the text family reads as text, including code and
// config: the card and the preview both treat it as a plain-text file. // config: the card and the preview both treat it as a plain-text file.
+1
View File
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
| 'pdf' | 'pdf'
| 'document' | 'document'
| 'presentation' | 'presentation'
| 'spreadsheet'
| 'markdown' | 'markdown'
| 'text'; | 'text';
+2 -2
View File
@@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) {
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call. * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
*/ */
const FILE_PATH_LINK_PATTERN = const FILE_PATH_LINK_PATTERN =
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g; /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g;
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */ /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
function absoluteFilePathPattern() { function absoluteFilePathPattern() {
@@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() {
* file in /tmp played fine. test/media-extension-parity.test.ts pins the sync. * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync.
*/ */
const FILE_PREVIEW_EXTENSIONS = new Set( const FILE_PREVIEW_EXTENSIONS = new Set(
('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ') ('png jpg jpeg gif webp bmp svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
); );
/** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */ /** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */
+1
View File
@@ -3766,5 +3766,6 @@
<script defer src="ultracode-windows.js"></script> <script defer src="ultracode-windows.js"></script>
<script defer src="session-lineage.js"></script> <script defer src="session-lineage.js"></script>
<script defer src="image-input.js"></script> <script defer src="image-input.js"></script>
<script defer src="spreadsheet-preview.js"></script>
</body> </body>
</html> </html>
+21
View File
@@ -3937,3 +3937,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh)); max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
} }
} }
/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller,
touch-scrollable grid on phones. */
@media (max-width: 700px) {
.spreadsheet-sheet-tabs {
padding-inline: 4px;
scroll-snap-type: x proximity;
}
.spreadsheet-sheet-tab {
min-width: 96px;
min-height: 40px;
scroll-snap-align: start;
}
.spreadsheet-grid {
min-height: 55vh;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
}
+44
View File
@@ -4083,6 +4083,8 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`; bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'docx' || ext === 'pptx') { } else if (ext === 'docx' || ext === 'pptx') {
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`; bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
} else if (ext === 'xlsx') {
this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize);
} else { } else {
try { try {
// Bounded like the workspace text preview: a Range for the first // Bounded like the workspace text preview: a Range for the first
@@ -4167,6 +4169,9 @@ Object.assign(CodemanApp.prototype, {
} else if (data.type === 'audio') { } else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`; bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'spreadsheet') {
this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size);
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') { } else if (data.type === 'binary') {
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`); const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`; bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
@@ -4237,6 +4242,9 @@ Object.assign(CodemanApp.prototype, {
* the in-flight network fetch and puts the element back in NETWORK_EMPTY. * the in-flight network fetch and puts the element back in NETWORK_EMPTY.
*/ */
_stopFilePreviewMedia() { _stopFilePreviewMedia() {
// A spreadsheet preview owns a fetch and a Web Worker; emptying the body
// leaves both running, so tear them down with the rest of the media.
this._disposeSpreadsheetPreview();
const bodyEl = this.$('filePreviewBody'); const bodyEl = this.$('filePreviewBody');
if (!bodyEl) return; if (!bodyEl) return;
for (const media of bodyEl.querySelectorAll('video, audio')) { for (const media of bodyEl.querySelectorAll('video, audio')) {
@@ -4251,6 +4259,42 @@ Object.assign(CodemanApp.prototype, {
bodyEl.innerHTML = ''; bodyEl.innerHTML = '';
}, },
/**
* Render an XLSX into the preview body via spreadsheet-preview.js, which
* parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and
* never on page load). `url` is a raw route; the renderer adds `?preview=true`
* so the server applies its preview size cap. Superseded by the next
* _stopFilePreviewMedia(), which runs on every open and on close.
*/
_openSpreadsheetPreview(bodyEl, url, size) {
this._disposeSpreadsheetPreview();
const renderer = window.CodemanSpreadsheetPreview;
if (!renderer?.open) {
bodyEl.innerHTML = '<div class="binary-message">Spreadsheet preview is unavailable.</div>';
return;
}
bodyEl.textContent = '';
const token = {};
this._spreadsheetPreviewToken = token;
this._spreadsheetPreview = renderer.open({
container: bodyEl,
url,
size,
isCurrent: () => this._spreadsheetPreviewToken === token,
});
},
_disposeSpreadsheetPreview() {
const handle = this._spreadsheetPreview;
this._spreadsheetPreview = null;
this._spreadsheetPreviewToken = null;
try {
handle?.dispose();
} catch (err) {
console.warn('Failed to dispose spreadsheet preview:', err);
}
},
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md) // File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -0,0 +1,220 @@
/**
* @fileoverview Same-origin XLSX parsing worker for the file-preview overlay.
*
* Runs off the main thread and is the ONLY place the spreadsheet vendor bundles
* load: fflate + the pure core at worker start, ExcelJS only after the ZIP has
* passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). The page never
* loads either vendor file. Cell values are sent back as plain strings; the
* renderer writes them with `textContent`. Formulas are never evaluated (the
* cached result is shown, else the formula text), and nothing here fetches:
* external links, images and drawings are reported as unsupported features.
*
* Script URLs are RELATIVE so they resolve against this worker's own URL, which
* keeps a reverse-proxy `--base-url` mount working.
*/
'use strict';
const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev';
const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`;
importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`);
const core = self.CodemanSpreadsheetXlsxCore;
let workbook = null;
let sheetsById = new Map();
let populatedRowsById = new Map();
let normalizedStyles = [];
let styleIds = new Map();
let themePalette = core.DEFAULT_THEME_PALETTE;
function postError(error) {
self.postMessage({
type: 'error',
code: error?.code || 'parse-failed',
message: error?.message || 'Spreadsheet preview failed',
});
}
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the
// fflate re-read is a fallback (admission already bounded the file) and the
// default Office palette is the last resort.
function readThemeXml(loadedWorkbook, bytes) {
const stashed = loadedWorkbook?._themes?.theme1;
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
try {
const entries = self.fflate.unzipSync(new Uint8Array(bytes), {
filter: (file) => file.name === 'xl/theme/theme1.xml',
});
const theme = entries['xl/theme/theme1.xml'];
if (theme) return new TextDecoder().decode(theme);
} catch (error) {
void error;
}
return '';
}
function normalizeStyle(cell) {
// Colours are resolved and contrast-checked as a PAIR. Emitting a
// font colour without its background lets workbook text land on the skin's
// `var(--bg-primary)` and disappear.
const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette);
const style = {
font: {
bold: Boolean(cell.font?.bold),
italic: Boolean(cell.font?.italic),
color: colors.foreground,
},
fill: colors.background,
alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined,
wrapText: Boolean(cell.alignment?.wrapText),
};
const key = JSON.stringify(style);
if (styleIds.has(key)) return styleIds.get(key);
if (normalizedStyles.length >= core.LIMITS.maxStyles) {
throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit');
}
const id = normalizedStyles.length;
normalizedStyles.push(style);
styleIds.set(key, id);
return id;
}
function worksheetMetadata(sheet) {
const cellRefs = [];
const populatedRows = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
populatedRows.push(row.number);
row.eachCell({ includeEmpty: false }, (cell) => {
cellRefs.push(cell.address);
normalizeStyle(cell);
});
});
const merges = Array.from(sheet.model?.merges || []);
const extent = core.deriveExtent(cellRefs, merges);
const rowOverrides = [];
sheet.eachRow({ includeEmpty: false }, (row) => {
if (row.hidden) rowOverrides.push([row.number, 0]);
else if (row.height) rowOverrides.push([row.number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
});
const columnOverrides = [];
for (let col = 1; col <= extent.cols; col += 1) {
const column = sheet.getColumn(col);
if (column.hidden) columnOverrides.push([col, 0]);
else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]);
}
return {
populatedRows,
metadata: {
id: String(sheet.id),
name: sheet.name,
rows: extent.rows,
cols: extent.cols,
defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))),
defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)),
rowOverrides,
columnOverrides,
merges,
},
};
}
function cellDisplay(cell, date1904, warnings) {
const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904);
if (formatted.warning) warnings.add(formatted.warning);
return formatted.text;
}
async function loadWorkbook(bytes) {
const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate);
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
const nextWorkbook = new self.ExcelJS.Workbook();
await nextWorkbook.xlsx.load(bytes);
const nextSheets = new Map();
const nextRows = new Map();
normalizedStyles = [];
styleIds = new Map();
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, bytes));
const sheets = [];
for (const sheet of nextWorkbook.worksheets) {
if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue;
const sheetResult = worksheetMetadata(sheet);
const metadata = sheetResult.metadata;
nextSheets.set(metadata.id, sheet);
nextRows.set(metadata.id, sheetResult.populatedRows);
sheets.push(metadata);
}
workbook = nextWorkbook;
sheetsById = nextSheets;
populatedRowsById = nextRows;
self.postMessage({
type: 'metadata',
sheets,
styles: normalizedStyles,
date1904: Boolean(workbook.properties?.date1904),
empty: sheets.length === 0,
warnings: admission.features,
});
}
function sendTile(message) {
if (!workbook) throw new Error('Workbook is not loaded');
const sheet = sheetsById.get(String(message.sheetId));
if (!sheet) throw new Error('Worksheet is unavailable');
const range = message.range;
const warnings = new Set();
const cells = [];
const seenCells = new Set();
const addCell = (cell) => {
const key = `${cell.row}:${cell.col}`;
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
seenCells.add(key);
cells.push({
row: cell.row,
col: cell.col,
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
styleId: normalizeStyle(cell),
});
};
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
for (const rowNumber of populatedRows) {
if (rowNumber < range.r1) continue;
if (rowNumber > range.r2) break;
const row = sheet.getRow(rowNumber);
row.eachCell({ includeEmpty: false }, (cell) => {
if (cell.col < range.c1 || cell.col > range.c2) return;
addCell(cell);
});
if (cells.length > 2500) throw new core.XlsxPreviewError('tile-limit', 'Spreadsheet tile exceeds the cell limit');
}
const merges = core.intersectingMerges(Array.from(sheet.model?.merges || []), range);
for (const merge of merges) {
const anchor = core.parseRange(merge);
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
}
self.postMessage({
type: 'tile',
requestId: message.requestId,
sheetId: String(message.sheetId),
cells,
merges,
warnings: Array.from(warnings),
});
}
self.onmessage = async (event) => {
try {
const message = event.data || {};
if (message.type === 'load') await loadWorkbook(message.bytes);
else if (message.type === 'tile') sendTile(message);
else if (message.type === 'dispose') {
workbook = null;
sheetsById = new Map();
populatedRowsById = new Map();
themePalette = core.DEFAULT_THEME_PALETTE;
}
} catch (error) {
postError(error);
}
};
self.postMessage({ type: 'ready' });
+414
View File
@@ -0,0 +1,414 @@
/**
* @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay.
*
* `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook
* bytes (same-origin only, `?preview=true` so the server applies its 10 MB
* preview cap), hands them to spreadsheet-preview-worker.js, and renders only
* the visible tile of cells. Parsing happens entirely in the browser worker; the
* server just streams the file through its existing confined raw routes.
*
* Every workbook string (cell text, sheet names) is written with `textContent`,
* never markup. The per-style `<style>` block only emits validated `#rrggbb`
* colours and a fixed set of keywords. `dispose()` aborts the fetch and
* terminates the worker; panels-ui.js calls it whenever the overlay is reused
* or closed.
*
* @dependency constants.js (CodemanBase.url for the worker URL under --base-url)
* @loadorder 16.5 (after image-input.js; only defines a global, used on demand)
*/
(function initSpreadsheetPreview(global) {
'use strict';
const SPREADSHEET_ASSET_VERSION = '4b7074e75ab1';
const MAX_PREVIEW_BYTES = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 20000;
const MAX_SCROLL_PX = 8000000;
const ROW_HEADING_WIDTH = 36;
const COLUMN_HEADING_HEIGHT = 20;
const assets = Object.freeze({
version: SPREADSHEET_ASSET_VERSION,
workerUrl: `/spreadsheet-preview-worker.js?v=${SPREADSHEET_ASSET_VERSION}`,
});
function message(container, text, kind) {
container.textContent = '';
const state = document.createElement('div');
state.className = `spreadsheet-preview-message ${kind || ''}`.trim();
state.textContent = text;
container.appendChild(state);
}
function safePreviewUrl(candidate) {
const url = new URL(candidate, global.location.href);
if (url.origin !== global.location.origin) throw new Error('Spreadsheet preview must use a same-origin URL');
url.searchParams.set('preview', 'true');
return `${url.pathname}${url.search}${url.hash}`;
}
function open(options) {
const container = options.container;
const isCurrent = typeof options.isCurrent === 'function' ? options.isCurrent : () => true;
let disposed = false;
let worker = null;
let controller = null;
let timer = null;
let metadata = null;
let activeSheetId = null;
let latestRequestId = 0;
let grid = null;
let spacer = null;
let cellsLayer = null;
let headingsLayer = null;
let emptySheetState = null;
let resizeObserver = null;
let scaleX = 1;
let scaleY = 1;
let latestRange = null;
let scrollFrame = null;
const current = () => !disposed && isCurrent();
const clearTimer = () => {
if (timer !== null) global.clearTimeout(timer);
timer = null;
};
const fail = (text) => {
if (!current()) return;
clearTimer();
message(container, text || 'Spreadsheet preview failed', 'error');
};
function sheetMetadata() {
return metadata?.sheets.find((sheet) => String(sheet.id) === String(activeSheetId));
}
function axisOffset(count, defaultSize, overrides, index) {
const bounded = Math.max(1, Math.min(count + 1, index));
let value = (bounded - 1) * defaultSize;
for (const [overrideIndex, size] of overrides || []) {
if (overrideIndex >= bounded) break;
value += size - defaultSize;
}
return value;
}
function axisIndex(count, defaultSize, overrides, offset) {
let low = 1;
let high = Math.max(1, count);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(count, defaultSize, overrides, mid) <= offset) low = mid;
else high = mid - 1;
}
return low;
}
function requestTile() {
if (!current() || !worker || !grid) return;
const sheet = sheetMetadata();
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
scaleY = Math.max(
1,
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1) / MAX_SCROLL_PX
);
scaleX = Math.max(
1,
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1) / MAX_SCROLL_PX
);
const r1 = Math.max(
1,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT) * scaleY
) - 2
);
const c1 = Math.max(
1,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH) * scaleX
) - 2
);
const r2 = Math.min(
sheet.rows,
axisIndex(
sheet.rows,
sheet.defaultRowHeight,
sheet.rowOverrides,
Math.max(0, grid.scrollTop - COLUMN_HEADING_HEIGHT + (grid.clientHeight || 500)) * scaleY
) + 2
);
const c2 = Math.min(
sheet.cols,
axisIndex(
sheet.cols,
sheet.defaultColumnWidth,
sheet.columnOverrides,
Math.max(0, grid.scrollLeft - ROW_HEADING_WIDTH + (grid.clientWidth || 800)) * scaleX
) + 2
);
latestRequestId += 1;
latestRange = { r1, c1, r2, c2 };
worker.postMessage({
type: 'tile',
requestId: latestRequestId,
sheetId: String(activeSheetId),
range: { r1, c1, r2, c2 },
});
}
function renderWarnings(tileWarnings) {
const notice = container.querySelector('.spreadsheet-preview-notice');
if (!notice) return;
const warnings = [...(metadata?.warnings || []), ...(tileWarnings || [])];
notice.hidden = warnings.length === 0;
const warningLabel =
global.codemanT?.('Some workbook features are not shown') || 'Some workbook features are not shown';
notice.textContent = warnings.length ? `${warningLabel}: ${warnings.join(', ')}` : '';
}
function pinHeadings() {
if (!grid || !headingsLayer) return;
headingsLayer.querySelectorAll('.spreadsheet-row-heading').forEach((heading) => {
heading.style.left = `${grid.scrollLeft}px`;
});
headingsLayer.querySelectorAll('.spreadsheet-column-heading').forEach((heading) => {
heading.style.top = `${grid.scrollTop}px`;
});
}
function renderTile(tile) {
if (!current() || tile.requestId !== latestRequestId || String(tile.sheetId) !== String(activeSheetId)) return;
const sheet = sheetMetadata();
if (!sheet || !cellsLayer || !headingsLayer || !latestRange) return;
cellsLayer.textContent = '';
headingsLayer.textContent = '';
const mergeByAnchor = new Map();
for (const merge of tile.merges || []) {
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(merge);
if (!match) continue;
const column = (letters) =>
[...letters.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
mergeByAnchor.set(`${Number(match[2])}:${column(match[1])}`, {
r2: Number(match[4]),
c2: column(match[3]),
});
}
for (const cell of tile.cells.slice(0, 2500)) {
const element = document.createElement('div');
element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`;
element.dataset.row = String(cell.row);
element.dataset.col = String(cell.col);
element.textContent = String(cell.text ?? '');
element.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, cell.row) / scaleY}px`;
element.style.left = `${ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col) / scaleX}px`;
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
const finalRow = merge?.r2 || cell.row;
const finalCol = merge?.c2 || cell.col;
element.style.height = `${Math.max(0, (axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, finalRow + 1) - axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, cell.row)) / scaleY)}px`;
element.style.width = `${Math.max(0, (axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, finalCol + 1) - axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, cell.col)) / scaleX)}px`;
cellsLayer.appendChild(element);
}
for (let row = latestRange.r1; row <= latestRange.r2 && row < latestRange.r1 + 200; row += 1) {
const heading = document.createElement('div');
heading.className = 'spreadsheet-row-heading';
heading.textContent = String(row);
heading.style.top = `${COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) / scaleY}px`;
heading.style.left = `${grid.scrollLeft}px`;
headingsLayer.appendChild(heading);
}
for (let col = latestRange.c1; col <= latestRange.c2 && col < latestRange.c1 + 100; col += 1) {
const heading = document.createElement('div');
heading.className = 'spreadsheet-column-heading';
let label = '';
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
heading.textContent = label;
heading.style.left = `${ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) / scaleX}px`;
heading.style.top = `${grid.scrollTop}px`;
headingsLayer.appendChild(heading);
}
renderWarnings(tile.warnings);
}
function selectSheet(sheetId) {
if (!current() || !metadata?.sheets.some((sheet) => String(sheet.id) === String(sheetId))) return;
activeSheetId = String(sheetId);
latestRequestId += 1;
latestRange = null;
if (cellsLayer) cellsLayer.textContent = '';
if (headingsLayer) headingsLayer.textContent = '';
container.querySelectorAll('[role="tab"]').forEach((tab) => {
const selected = tab.dataset.sheetId === activeSheetId;
tab.setAttribute('aria-selected', String(selected));
tab.tabIndex = selected ? 0 : -1;
});
if (grid) {
grid.scrollTop = 0;
grid.scrollLeft = 0;
}
const sheet = sheetMetadata();
if (sheet && spacer) {
const logicalHeight = axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1);
const logicalWidth = axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1);
scaleY = Math.max(1, logicalHeight / MAX_SCROLL_PX);
scaleX = Math.max(1, logicalWidth / MAX_SCROLL_PX);
spacer.style.height = `${COLUMN_HEADING_HEIGHT + Math.min(MAX_SCROLL_PX, logicalHeight)}px`;
spacer.style.width = `${ROW_HEADING_WIDTH + Math.min(MAX_SCROLL_PX, logicalWidth)}px`;
}
if (emptySheetState) emptySheetState.hidden = Boolean(sheet?.rows && sheet?.cols);
renderWarnings([]);
requestTile();
}
function renderMetadata(nextMetadata) {
if (!current()) return;
metadata = nextMetadata;
container.textContent = '';
if (!metadata.sheets?.length) {
message(container, 'This workbook has no visible worksheets.', 'empty');
return;
}
const shell = document.createElement('div');
shell.className = 'spreadsheet-preview-shell';
const styleSheet = document.createElement('style');
styleSheet.textContent = (metadata.styles || [])
.map((style, id) => {
const declarations = [];
if (style.font?.bold) declarations.push('font-weight:700');
if (style.font?.italic) declarations.push('font-style:italic');
// Colour and background are emitted together or not at all.
// The worker already contrast-checked them as a pair; contributing
// one half would drop the cell back onto the skin's own background.
if (/^#[a-f0-9]{6}$/i.test(style.font?.color || '') && /^#[a-f0-9]{6}$/i.test(style.fill || '')) {
declarations.push(`color:${style.font.color}`, `background-color:${style.fill}`);
}
if (['left', 'center', 'right'].includes(style.alignment)) declarations.push(`text-align:${style.alignment}`);
if (style.wrapText) declarations.push('white-space:normal');
return `.spreadsheet-style-${id}{${declarations.join(';')}}`;
})
.join('');
const tabs = document.createElement('div');
tabs.className = 'spreadsheet-sheet-tabs';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('data-i18n-skip', '');
for (const sheet of metadata.sheets) {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'spreadsheet-sheet-tab';
tab.setAttribute('role', 'tab');
tab.dataset.sheetId = String(sheet.id);
tab.textContent = sheet.name;
tab.addEventListener('click', () => selectSheet(sheet.id));
tabs.appendChild(tab);
}
const notice = document.createElement('div');
notice.className = 'spreadsheet-preview-notice';
notice.hidden = true;
emptySheetState = document.createElement('div');
emptySheetState.className = 'spreadsheet-empty-sheet';
emptySheetState.textContent = 'This worksheet is empty.';
emptySheetState.hidden = true;
grid = document.createElement('div');
grid.className = 'spreadsheet-grid';
grid.setAttribute('data-i18n-skip', '');
spacer = document.createElement('div');
spacer.className = 'spreadsheet-grid-spacer';
cellsLayer = document.createElement('div');
cellsLayer.className = 'spreadsheet-cells';
headingsLayer = document.createElement('div');
headingsLayer.className = 'spreadsheet-headings';
grid.append(spacer, cellsLayer, headingsLayer);
grid.addEventListener(
'scroll',
() => {
pinHeadings();
if (scrollFrame !== null) return;
scrollFrame = global.requestAnimationFrame(() => {
scrollFrame = null;
requestTile();
});
},
{ passive: true }
);
shell.append(styleSheet, tabs, notice, emptySheetState, grid);
container.appendChild(shell);
resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(requestTile) : null;
resizeObserver?.observe(grid);
selectSheet(metadata.sheets[0].id);
}
function dispose() {
if (disposed) return;
disposed = true;
clearTimer();
if (scrollFrame !== null) global.cancelAnimationFrame(scrollFrame);
controller?.abort();
resizeObserver?.disconnect();
try {
worker?.postMessage({ type: 'dispose' });
worker?.terminate();
} catch {
// A worker that failed during startup may already be unavailable.
}
worker = null;
}
async function start() {
if (Number(options.size) > MAX_PREVIEW_BYTES) {
fail('This workbook is too large to preview (10 MB limit).');
return;
}
message(container, 'Loading spreadsheet…', 'loading');
let previewUrl;
try {
previewUrl = safePreviewUrl(options.url);
controller = new AbortController();
// Root-absolute paths ignore <base href>; route through the mount prefix.
worker = new Worker(global.CodemanBase?.url ? global.CodemanBase.url(assets.workerUrl) : assets.workerUrl);
const ready = new Promise((resolve, reject) => {
worker.onerror = () => reject(new Error('Spreadsheet parser failed to start'));
worker.onmessageerror = () => reject(new Error('Spreadsheet parser message failed'));
worker.onmessage = (event) => {
if (event.data?.type === 'ready') resolve();
};
});
const responsePromise = fetch(previewUrl, { signal: controller.signal });
const [response] = await Promise.all([responsePromise, ready]);
if (!current()) return;
if (response.status === 413) throw new Error('This workbook is too large to preview (10 MB limit).');
if (!response.ok) throw new Error(`Spreadsheet preview failed (${response.status})`);
const bytes = await response.arrayBuffer();
if (!current()) return;
worker.onmessage = (event) => {
if (!current()) return;
const payload = event.data || {};
if (payload.type === 'metadata') {
clearTimer();
renderMetadata(payload);
} else if (payload.type === 'tile') renderTile(payload);
else if (payload.type === 'error') fail(payload.message);
};
worker.onerror = () => fail('Spreadsheet parser failed.');
worker.onmessageerror = () => fail('Spreadsheet parser message failed.');
timer = global.setTimeout(() => {
worker?.terminate();
fail('Spreadsheet preview timed out.');
}, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
worker.postMessage({ type: 'load', bytes }, [bytes]);
} catch (error) {
if (!disposed && error?.name !== 'AbortError') fail(error?.message);
}
}
void start();
return Object.freeze({ dispose, selectSheet, resize: requestTile });
}
global.CodemanSpreadsheetPreviewAssets = assets;
global.CodemanSpreadsheetPreview = Object.freeze({ open, MAX_PREVIEW_BYTES });
})(window);
+588
View File
@@ -0,0 +1,588 @@
/**
* @fileoverview Pure XLSX admission, formatting, and sparse-grid helpers.
*
* Loaded only inside spreadsheet-preview-worker.js (never by the page) and by
* test/spreadsheet-xlsx-core.test.ts. `admitXlsx()` walks the ZIP central
* directory and streams every entry through fflate BEFORE ExcelJS sees the
* bytes, enforcing {@link LIMITS}; a workbook that trips any cap is refused
* rather than truncated.
*/
(function initSpreadsheetXlsxCore(global) {
'use strict';
const LIMITS = Object.freeze({
maxEntries: 5000,
maxInflatedBytes: 64 * 1024 * 1024,
maxEntryBytes: 32 * 1024 * 1024,
maxCompressionRatio: 100,
maxWorksheets: 50,
maxCells: 250000,
maxCellsPerSheet: 100000,
maxMergesPerSheet: 5000,
maxStyles: 5000,
});
const MAX_ROW = 1048576;
const MAX_COL = 16384;
class XlsxPreviewError extends Error {
constructor(code, message) {
super(message);
this.name = 'XlsxPreviewError';
this.code = code;
}
}
function fail(code, message) {
throw new XlsxPreviewError(code, message);
}
function mergedLimits(overrides) {
return Object.assign({}, LIMITS, overrides || {});
}
function u16(bytes, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(offset, true);
}
function u32(bytes, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true);
}
function inspectZipDirectory(bytes, overrides) {
const limits = mergedLimits(overrides);
if (bytes.length >= 4 && bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0) {
fail('encrypted', 'Encrypted or legacy OLE workbooks cannot be previewed');
}
let eocd = -1;
const floor = Math.max(0, bytes.length - 65557);
for (let i = bytes.length - 22; i >= floor; i -= 1) {
if (u32(bytes, i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) fail('malformed', 'Malformed XLSX ZIP directory');
const entryCount = u16(bytes, eocd + 10);
const directorySize = u32(bytes, eocd + 12);
const directoryOffset = u32(bytes, eocd + 16);
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
fail('zip64', 'ZIP64 workbooks are not supported');
}
if (entryCount > limits.maxEntries) fail('entry-limit', `Workbook exceeds ${limits.maxEntries} ZIP entries`);
if (directoryOffset + directorySize > eocd) fail('malformed', 'Malformed XLSX central directory bounds');
const entries = [];
let cursor = directoryOffset;
for (let i = 0; i < entryCount; i += 1) {
if (cursor + 46 > eocd || u32(bytes, cursor) !== 0x02014b50)
fail('malformed', 'Malformed XLSX central directory');
const compressedSize = u32(bytes, cursor + 20);
const declaredSize = u32(bytes, cursor + 24);
const nameLength = u16(bytes, cursor + 28);
const extraLength = u16(bytes, cursor + 30);
const commentLength = u16(bytes, cursor + 32);
const localHeaderOffset = u32(bytes, cursor + 42);
if (compressedSize === 0xffffffff || declaredSize === 0xffffffff)
fail('zip64', 'ZIP64 entries are not supported');
const end = cursor + 46 + nameLength + extraLength + commentLength;
if (end > eocd) fail('malformed', 'Malformed XLSX entry bounds');
const name = new TextDecoder().decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength));
if (localHeaderOffset + 30 > directoryOffset || u32(bytes, localHeaderOffset) !== 0x04034b50) {
fail('malformed', 'Malformed XLSX local file header');
}
const localNameLength = u16(bytes, localHeaderOffset + 26);
const localExtraLength = u16(bytes, localHeaderOffset + 28);
const localNameEnd = localHeaderOffset + 30 + localNameLength;
if (localNameEnd + localExtraLength > directoryOffset) fail('malformed', 'Malformed XLSX local entry bounds');
const localName = new TextDecoder().decode(bytes.subarray(localHeaderOffset + 30, localNameEnd));
if (localName !== name) fail('malformed', 'XLSX local and central directory names do not match');
entries.push({ name, compressedSize, declaredSize, localHeaderOffset });
cursor = end;
}
return { entries };
}
function featureForName(name) {
if (name.startsWith('xl/charts/')) return 'charts';
if (name.startsWith('xl/drawings/')) return 'drawings';
if (name.startsWith('xl/pivotCache/')) return 'pivotTables';
if (name.startsWith('xl/externalLinks/')) return 'externalLinks';
if (/vbaProject\.bin$/i.test(name)) return 'macros';
return null;
}
function createXmlCounter(name, counts, limits) {
let tail = '';
const decoder = new TextDecoder();
let sheetCells = 0;
let sheetMerges = 0;
let inCellXfs = false;
const worksheet = /^xl\/worksheets\/[^/]+\.xml$/i.test(name);
const styles = name === 'xl/styles.xml';
return {
push(chunk, final) {
if (!worksheet && !styles) return;
const text = tail + decoder.decode(chunk, { stream: !final });
const safeEnd = final ? text.length : Math.max(0, text.length - 128);
const scan = text.slice(0, safeEnd);
if (worksheet) {
const cells = (scan.match(/<c(?:\s|>)/g) || []).length;
const merges = (scan.match(/<mergeCell(?:\s|>)/g) || []).length;
sheetCells += cells;
sheetMerges += merges;
counts.cells += cells;
counts.merges += merges;
if (sheetCells > limits.maxCellsPerSheet || counts.cells > limits.maxCells)
fail('cell-limit', 'Workbook exceeds the cells limit');
if (sheetMerges > limits.maxMergesPerSheet) fail('merge-limit', 'Worksheet exceeds the merged ranges limit');
}
if (styles) {
const tokens = scan.match(/<cellXfs(?:\s|>)|<\/cellXfs\s*>|<xf(?:\s|\/?>)/g) || [];
for (const token of tokens) {
if (token.startsWith('<cellXfs')) inCellXfs = true;
else if (token.startsWith('</cellXfs')) inCellXfs = false;
else if (inCellXfs) counts.styles += 1;
}
if (counts.styles > limits.maxStyles) fail('style-limit', 'Workbook exceeds the cell styles limit');
}
tail = text.slice(safeEnd);
},
};
}
function admitXlsx(bytes, zipApi, overrides) {
const limits = mergedLimits(overrides);
const directory = inspectZipDirectory(bytes, limits);
const directoryByName = new Map(directory.entries.map((entry) => [entry.name, entry]));
const expectedEntries = new Map();
for (const entry of directory.entries) expectedEntries.set(entry.name, (expectedEntries.get(entry.name) || 0) + 1);
const streamedEntries = new Map();
const counts = { worksheets: 0, cells: 0, merges: 0, styles: 0 };
const features = new Set();
let totalInflated = 0;
let seenEntries = 0;
let thrown;
const unzip = new zipApi.Unzip((file) => {
if (!directoryByName.has(file.name)) fail('malformed', 'Local XLSX entry is absent from the central directory');
streamedEntries.set(file.name, (streamedEntries.get(file.name) || 0) + 1);
seenEntries += 1;
if (seenEntries > limits.maxEntries) fail('entry-limit', 'Workbook exceeds the ZIP entries limit');
if (/^xl\/worksheets\/[^/]+\.xml$/i.test(file.name)) {
counts.worksheets += 1;
if (counts.worksheets > limits.maxWorksheets) fail('worksheet-limit', 'Workbook exceeds the worksheet limit');
}
const feature = featureForName(file.name);
if (feature) features.add(feature);
const counter = createXmlCounter(file.name, counts, limits);
let entryInflated = 0;
file.ondata = (error, chunk, final) => {
if (error) throw error;
entryInflated += chunk.length;
totalInflated += chunk.length;
if (entryInflated > limits.maxEntryBytes) fail('entry-size', 'Inflated ZIP entry exceeds the entry limit');
if (totalInflated > limits.maxInflatedBytes) fail('inflated-size', 'Workbook exceeds the inflated bytes limit');
const compressed = directoryByName.get(file.name)?.compressedSize || 1;
if (entryInflated / Math.max(1, compressed) > limits.maxCompressionRatio) {
fail('compression-ratio', 'ZIP entry exceeds the compression ratio limit');
}
counter.push(chunk, final);
};
file.start();
});
unzip.register(zipApi.UnzipInflate);
try {
const inputChunkBytes = 64 * 1024;
for (let offset = 0; offset < bytes.length && !thrown; offset += inputChunkBytes) {
const end = Math.min(bytes.length, offset + inputChunkBytes);
unzip.push(bytes.subarray(offset, end), end === bytes.length);
}
} catch (error) {
thrown = error;
}
if (thrown) throw thrown;
for (const [name, count] of expectedEntries) {
if (streamedEntries.get(name) !== count) fail('malformed', 'Central XLSX entry was not streamed for admission');
}
return { counts, features: Array.from(features), inflatedBytes: totalInflated };
}
function parseCellRef(ref) {
const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || ''));
if (!match) return null;
let col = 0;
for (const char of match[1].toUpperCase()) col = col * 26 + char.charCodeAt(0) - 64;
const row = Number(match[2]);
return row <= MAX_ROW && col <= MAX_COL ? { row, col } : null;
}
function parseRange(range) {
const parts = String(range).split(':');
const start = parseCellRef(parts[0]);
const end = parseCellRef(parts[1] || parts[0]);
return start && end ? { r1: start.row, c1: start.col, r2: end.row, c2: end.col } : null;
}
function deriveExtent(cells, merges) {
let rows = 0;
let cols = 0;
for (const ref of cells) {
const cell = parseCellRef(ref);
if (cell) {
rows = Math.max(rows, cell.row);
cols = Math.max(cols, cell.col);
}
}
for (const merge of merges) {
const range = parseRange(merge);
if (range) {
rows = Math.max(rows, range.r2);
cols = Math.max(cols, range.c2);
}
}
return { rows, cols };
}
function createSparseAxis(count, defaultSize, overrides) {
const sorted = Array.from(overrides || [])
.filter(([index, size]) => index >= 1 && index <= count && Number.isFinite(size))
.map(([index, size]) => [index, Math.max(0, size)])
.sort((a, b) => a[0] - b[0]);
return { count: Math.max(0, count), defaultSize: Math.max(0, defaultSize), overrides: sorted };
}
function axisOffset(axis, index) {
const bounded = Math.max(1, Math.min(axis.count + 1, index));
let offset = (bounded - 1) * axis.defaultSize;
for (const [overrideIndex, size] of axis.overrides) {
if (overrideIndex >= bounded) break;
offset += size - axis.defaultSize;
}
return offset;
}
function axisIndexAt(axis, offset) {
let low = 1;
let high = Math.max(1, axis.count);
const target = Math.max(0, offset);
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (axisOffset(axis, mid) <= target) low = mid;
else high = mid - 1;
}
return low;
}
function computeViewport(axis, offset, viewportSize, overscan) {
const pad = Math.max(0, overscan || 0);
const start = Math.max(1, axisIndexAt(axis, offset) - pad);
const end = Math.min(axis.count, axisIndexAt(axis, offset + Math.max(0, viewportSize)) + pad);
return [start, end];
}
function intersectingMerges(merges, viewport) {
return merges.filter((merge) => {
const range = parseRange(merge);
return (
range &&
range.r1 <= viewport.r2 &&
range.r2 >= viewport.r1 &&
range.c1 <= viewport.c2 &&
range.c2 >= viewport.c1
);
});
}
function excelDate(serial, date1904) {
if (date1904) return new Date(Date.UTC(1904, 0, 1) + Number(serial) * 86400000);
const numeric = Number(serial);
const adjusted = numeric >= 60 ? numeric - 1 : numeric;
return new Date(Date.UTC(1899, 11, 31) + adjusted * 86400000);
}
function formatCellValue(value, format, date1904) {
if (value && typeof value === 'object' && 'formula' in value) {
if (value.result !== undefined && value.result !== null) return formatCellValue(value.result, format, date1904);
return { text: `=${String(value.formula)}`, warning: 'Formula has no cached result' };
}
if (value === null || value === undefined) return { text: '' };
const code = String(format || 'General');
if (typeof value !== 'number') return { text: String(value) };
if (/^General$/i.test(code)) return { text: String(value) };
if (/^[ymd\-/ ]+$/i.test(code)) {
const date = excelDate(value, Boolean(date1904));
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return { text: `${yyyy}-${mm}-${dd}` };
}
if (/^[hms: ]+$/i.test(code)) {
const seconds = Math.round((value - Math.floor(value)) * 86400) % 86400;
const hh = String(Math.floor(seconds / 3600)).padStart(2, '0');
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return { text: `${hh}:${mm}:${ss}` };
}
if (/^[ymdhis\-/: ]+$/i.test(code)) {
const date = excelDate(value, Boolean(date1904));
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
const hh = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return { text: `${yyyy}-${mm}-${dd} ${hh}:${minutes}` };
}
const percent = code.includes('%');
const decimals = code.match(/\.([0#]+)/)?.[1].length || 0;
const numericPattern = /^[€£¥$]?[#,0]+(?:\.[0#]+)?%?$/;
if (numericPattern.test(code)) {
const currency = /^[€£¥$]/.exec(code)?.[0] || '';
const numeric = percent ? value * 100 : value;
const useGrouping = code.includes(',');
return {
text:
currency +
numeric.toLocaleString('en-US', {
useGrouping,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}) +
(percent ? '%' : ''),
};
}
return { text: String(value), warning: `Unsupported number format: ${code}` };
}
// Colour resolution --------------------------------------------------------
//
// ExcelJS surfaces theme and indexed palette colours WITHOUT an `argb` key
// (`{theme,tint}` / `{indexed}`), and theme colours are what Excel emits by
// default. Dropping them left cells rendering the workbook's font colour on
// the skin's own background, which is how black-on-dark (invisible) text got
// shipped. Everything below is pure so `test/spreadsheet-xlsx-core.test.ts`
// can pin it without a DOM.
// styles.xml `theme="N"` order. NOTE: theme1.xml's <a:clrScheme> lists
// dk1, lt1, dk2, lt2 — indices 0/1 and 2/3 are SWAPPED between the two.
const THEME_SLOT_ORDER = Object.freeze([
'lt1',
'dk1',
'lt2',
'dk2',
'accent1',
'accent2',
'accent3',
'accent4',
'accent5',
'accent6',
'hlink',
'folHlink',
]);
// Default Office theme, used when theme1.xml is missing or unparseable.
const DEFAULT_THEME_PALETTE = Object.freeze([
'#ffffff',
'#000000',
'#eeece1',
'#1f497d',
'#4f81bd',
'#c0504d',
'#9bbb59',
'#8064a2',
'#4bacc6',
'#f79646',
'#0000ff',
'#800080',
]);
// Legacy 64-entry indexed palette. Indices 64/65 are the "auto" foreground and
// background sentinels and deliberately have no entry here.
// prettier-ignore
const INDEXED_PALETTE = Object.freeze([
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#c0c0c0', '#808080',
'#9999ff', '#993366', '#ffffcc', '#ccffff', '#660066', '#ff8080', '#0066cc', '#ccccff',
'#000080', '#ff00ff', '#ffff00', '#00ffff', '#800080', '#800000', '#008080', '#0000ff',
'#00ccff', '#ccffff', '#ccffcc', '#ffff99', '#99ccff', '#ff99cc', '#cc99ff', '#ffcc99',
'#3366ff', '#33cccc', '#99cc00', '#ffcc00', '#ff9900', '#ff6600', '#666699', '#969696',
'#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333',
]);
const MIN_CONTRAST_RATIO = 4.5;
// A workbook with no fill renders on Excel's implicit white sheet background,
// never on the viewer skin's `var(--bg-primary)`.
const IMPLICIT_SHEET_BACKGROUND = '#ffffff';
const IMPLICIT_SHEET_FOREGROUND = '#000000';
function hexToRgb(hex) {
const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''));
if (!match) return null;
return [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)];
}
function rgbToHex(rgb) {
let hex = '#';
for (const channel of rgb) {
const bounded = Math.max(0, Math.min(255, Math.round(channel)));
hex += (bounded < 16 ? '0' : '') + bounded.toString(16);
}
return hex;
}
function rgbToHsl(rgb) {
const r = rgb[0] / 255;
const g = rgb[1] / 255;
const b = rgb[2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l };
const delta = max - min;
const s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
let h;
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
return { h: h / 6, s, l };
}
function hueToChannel(p, q, hue) {
let t = hue;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
function hslToRgb(hsl) {
if (hsl.s === 0) {
const gray = hsl.l * 255;
return [gray, gray, gray];
}
const q = hsl.l < 0.5 ? hsl.l * (1 + hsl.s) : hsl.l + hsl.s - hsl.l * hsl.s;
const p = 2 * hsl.l - q;
return [
hueToChannel(p, q, hsl.h + 1 / 3) * 255,
hueToChannel(p, q, hsl.h) * 255,
hueToChannel(p, q, hsl.h - 1 / 3) * 255,
];
}
// Excel tint acts on HSL luminance: negative darkens, positive lightens.
function applyTint(hex, tint) {
const amount = Number(tint);
if (!Number.isFinite(amount) || amount === 0) return hex;
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const bounded = Math.max(-1, Math.min(1, amount));
const hsl = rgbToHsl(rgb);
const luminance = bounded < 0 ? hsl.l * (1 + bounded) : hsl.l * (1 - bounded) + bounded;
return rgbToHex(hslToRgb({ h: hsl.h, s: hsl.s, l: Math.max(0, Math.min(1, luminance)) }));
}
function parseSchemeColor(fragment) {
const srgb = /<(?:[A-Za-z0-9_]+:)?srgbClr\b[^>]*\bval="([0-9A-Fa-f]{6})"/.exec(fragment);
if (srgb) return `#${srgb[1].toLowerCase()}`;
const sys = /<(?:[A-Za-z0-9_]+:)?sysClr\b[^>]*\blastClr="([0-9A-Fa-f]{6})"/.exec(fragment);
if (sys) return `#${sys[1].toLowerCase()}`;
return undefined;
}
function parseThemePalette(xml) {
const palette = DEFAULT_THEME_PALETTE.slice();
const text = typeof xml === 'string' ? xml : '';
const scheme = /<(?:[A-Za-z0-9_]+:)?clrScheme\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?clrScheme\s*>/.exec(text);
if (!scheme) return palette;
// Fresh pattern per call: a shared /g regex would carry lastIndex across calls.
const slots =
/<(?:[A-Za-z0-9_]+:)?(lt1|dk1|lt2|dk2|accent[1-6]|hlink|folHlink)\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z0-9_]+:)?\1\s*>/g;
let match = slots.exec(scheme[1]);
while (match) {
const index = THEME_SLOT_ORDER.indexOf(match[1]);
const resolved = index >= 0 ? parseSchemeColor(match[2]) : undefined;
if (resolved) palette[index] = resolved;
match = slots.exec(scheme[1]);
}
return palette;
}
function themePaletteOrDefault(palette) {
return Array.isArray(palette) && palette.length === THEME_SLOT_ORDER.length ? palette : DEFAULT_THEME_PALETTE;
}
// Accepts every colour shape ExcelJS emits: {argb}, {theme,tint}, {indexed}.
function resolveColor(color, palette) {
if (!color || typeof color !== 'object') return undefined;
const argb = color.argb;
if (typeof argb === 'string' && /^[A-Fa-f0-9]{8}$/.test(argb)) return `#${argb.slice(2).toLowerCase()}`;
const theme = color.theme;
if (Number.isInteger(theme) && theme >= 0 && theme < THEME_SLOT_ORDER.length) {
const base = themePaletteOrDefault(palette)[theme];
return typeof base === 'string' ? applyTint(base, color.tint) : undefined;
}
const indexed = color.indexed;
if (Number.isInteger(indexed) && indexed >= 0 && indexed < INDEXED_PALETTE.length) return INDEXED_PALETTE[indexed];
return undefined;
}
function channelLuminance(channel) {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex) {
const rgb = hexToRgb(hex);
if (!rgb) return 0;
return 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]);
}
function contrastRatio(a, b) {
const first = relativeLuminance(a);
const second = relativeLuminance(b);
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
}
function ensureContrast(foreground, background, minRatio) {
const minimum = Number.isFinite(minRatio) ? minRatio : MIN_CONTRAST_RATIO;
if (contrastRatio(foreground, background) >= minimum) return foreground;
return contrastRatio('#000000', background) >= contrastRatio('#ffffff', background) ? '#000000' : '#ffffff';
}
// Returns a SELF-CONSISTENT pair, or nothing at all. Emitting only one half is
// what let workbook text land on the skin's background and vanish.
function resolveCellColors(fillColor, fontColor, palette) {
const fill = resolveColor(fillColor, palette);
const font = resolveColor(fontColor, palette);
if (!fill && !font) return {};
const background = fill || IMPLICIT_SHEET_BACKGROUND;
return { background, foreground: ensureContrast(font || IMPLICIT_SHEET_FOREGROUND, background) };
}
global.CodemanSpreadsheetXlsxCore = Object.freeze({
LIMITS,
MAX_ROW,
MAX_COL,
XlsxPreviewError,
inspectZipDirectory,
admitXlsx,
parseCellRef,
parseRange,
deriveExtent,
createSparseAxis,
axisOffset,
axisIndexAt,
computeViewport,
intersectingMerges,
formatCellValue,
DEFAULT_THEME_PALETTE,
INDEXED_PALETTE,
MIN_CONTRAST_RATIO,
parseThemePalette,
resolveColor,
contrastRatio,
ensureContrast,
resolveCellColors,
});
})(typeof self !== 'undefined' ? self : globalThis);
+130
View File
@@ -19088,3 +19088,133 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
font-size: 13px; font-size: 13px;
color: var(--text-muted); color: var(--text-muted);
} }
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
.spreadsheet-preview-shell {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
background: var(--bg-primary);
}
.spreadsheet-sheet-tabs {
display: flex;
flex: 0 0 auto;
gap: 2px;
overflow-x: auto;
padding: 6px 8px 0;
border-bottom: 1px solid var(--border);
}
.spreadsheet-sheet-tab {
flex: 0 0 auto;
min-width: 80px;
max-width: 220px;
padding: 6px 12px;
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid transparent;
border-bottom: 0;
border-radius: 6px 6px 0 0;
background: transparent;
}
.spreadsheet-sheet-tab[aria-selected='true'] {
color: var(--text-primary);
border-color: var(--border);
background: var(--bg-secondary);
}
.spreadsheet-preview-notice {
flex: 0 0 auto;
padding: 5px 10px;
color: var(--warning, #f59e0b);
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.spreadsheet-grid {
position: relative;
flex: 1 1 auto;
min-height: 220px;
overflow: auto;
overscroll-behavior: contain;
}
.spreadsheet-grid-spacer,
.spreadsheet-cells,
.spreadsheet-headings {
position: absolute;
top: 0;
left: 0;
}
.spreadsheet-row-heading,
.spreadsheet-column-heading {
position: absolute;
z-index: 2;
color: var(--text-secondary);
font-size: 11px;
line-height: 18px;
text-align: center;
background: var(--bg-secondary);
border: 1px solid var(--border);
pointer-events: none;
}
.spreadsheet-row-heading {
left: 0;
width: 36px;
height: 20px;
}
.spreadsheet-column-heading {
top: 0;
width: 64px;
height: 20px;
}
.spreadsheet-empty-sheet {
position: sticky;
top: 20px;
left: 36px;
z-index: 3;
width: max-content;
padding: 24px;
color: var(--text-secondary);
background: var(--bg-primary);
}
.spreadsheet-cell {
position: absolute;
min-width: 0;
padding: 2px 5px;
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
}
.spreadsheet-preview-message {
display: grid;
min-height: 220px;
place-items: center;
padding: 24px;
color: var(--text-secondary);
text-align: center;
}
.spreadsheet-preview-message.error {
color: var(--danger, #ef4444);
}
+44 -4
View File
@@ -73,6 +73,35 @@ import {
isEditableFileName, isEditableFileName,
} from '../../config/file-editing.js'; } from '../../config/file-editing.js';
/**
* Upper bound on an XLSX the browser preview will fetch (`?preview=true`).
* Parsing happens client-side in spreadsheet-preview-worker.js, so this caps
* what a single preview can hand the worker; the renderer refuses the same size
* before fetching. An explicit download is unaffected (global download cap).
*/
export const MAX_XLSX_BROWSER_PREVIEW_BYTES = 10 * 1024 * 1024;
/** Whether a raw request is an XLSX browser preview over the preview cap. */
function exceedsXlsxPreviewLimit(extension: string, query: { preview?: string; download?: string }, size: number) {
return (
extension === 'xlsx' &&
query.preview === 'true' &&
query.download !== 'true' &&
size > MAX_XLSX_BROWSER_PREVIEW_BYTES
);
}
function sendXlsxPreviewTooLarge(reply: FastifyReply, size: number): void {
reply
.code(413)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(size / 1024 / 1024)}MB > ${MAX_XLSX_BROWSER_PREVIEW_BYTES / 1024 / 1024}MB limit)`
)
);
}
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
png: 'image/png', png: 'image/png',
jpg: 'image/jpeg', jpg: 'image/jpeg',
@@ -100,6 +129,7 @@ const MIME_TYPES: Record<string, string> = {
pdf: 'application/pdf', pdf: 'application/pdf',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
json: 'application/json', json: 'application/json',
md: 'text/markdown', md: 'text/markdown',
txt: 'text/plain', txt: 'text/plain',
@@ -1778,13 +1808,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`; const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`;
if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) { if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) {
// Return metadata for media/binary files (no text body) // Return metadata for media/binary files (no text body). XLSX is still a
// binary here (no text body); `spreadsheet` tells the overlay it can parse
// it client-side from `url`. xls/ods stay plain binary (download only).
return { return {
success: true, success: true,
data: { data: {
path: filePath, path: filePath,
size: stat.size, size: stat.size,
type: mediaType ?? 'binary', type: mediaType ?? (ext === 'xlsx' ? 'spreadsheet' : 'binary'),
extension: ext, extension: ext,
url: fileRawUrl, url: fileRawUrl,
}, },
@@ -2009,7 +2041,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Serve raw file content (for images/binary files) // Serve raw file content (for images/binary files)
app.get('/api/sessions/:id/file-raw', async (req, reply) => { app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { path: filePath, download } = req.query as { path?: string; download?: string }; const { path: filePath, download, preview } = req.query as { path?: string; download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req); const session = findSessionOrFail(ctx, id, req);
if (!filePath) { if (!filePath) {
@@ -2039,6 +2071,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
} }
const ext = filePath.split('.').pop()?.toLowerCase() || ''; const ext = filePath.split('.').pop()?.toLowerCase() || '';
if (exceedsXlsxPreviewLimit(ext, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
png: 'image/png', png: 'image/png',
jpg: 'image/jpeg', jpg: 'image/jpeg',
@@ -2216,7 +2252,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// attachment-guard policy on every request (defense-in-depth) before streaming. // attachment-guard policy on every request (defense-in-depth) before streaming.
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => { app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const { id, attachmentId } = req.params as { id: string; attachmentId: string };
const { download } = req.query as { download?: string }; const { download, preview } = req.query as { download?: string; preview?: string };
const session = findSessionOrFail(ctx, id, req); const session = findSessionOrFail(ctx, id, req);
const record = getAttachmentOr404(reply, id, attachmentId); const record = getAttachmentOr404(reply, id, attachmentId);
if (!record) return; if (!record) return;
@@ -2236,6 +2272,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe } ? { kind: 'remote', resolvedPath: servable.path, relativePath: '', remote, probe: servable.probe }
: { kind: 'local', resolvedPath: servable.path, relativePath: '' }; : { kind: 'local', resolvedPath: servable.path, relativePath: '' };
const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size; const size = servable.probe ? servable.probe.size : (await fs.stat(servable.path)).size;
if (exceedsXlsxPreviewLimit(record.extension, { preview, download }, size)) {
sendXlsxPreviewTooLarge(reply, size);
return;
}
await serveRawFile( await serveRawFile(
reply, reply,
target, target,
+13 -2
View File
@@ -46,10 +46,17 @@ function lockedVersions(lock: PackageLock, packageName: string): string[] {
return [...versions].sort(); return [...versions].sort();
} }
function expectEveryLockedVersionAtLeast(lock: PackageLock, packageName: string, minimum: string): void { function expectEveryLockedVersionAtLeast(
lock: PackageLock,
packageName: string,
minimum: string,
/** Exact versions deliberately outside this policy; each call site says why. */
exempt: string[] = []
): void {
const versions = lockedVersions(lock, packageName); const versions = lockedVersions(lock, packageName);
expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0); expect(versions, `${packageName} should be present in package-lock.json`).not.toHaveLength(0);
for (const version of versions) { for (const version of versions) {
if (exempt.includes(version)) continue;
expect( expect(
compareVersions(version, minimum), compareVersions(version, minimum),
`${packageName}@${version} should be >= ${minimum}` `${packageName}@${version} should be >= ${minimum}`
@@ -136,7 +143,11 @@ describe('dependency security policy', () => {
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line. // <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2'); expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0'); expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0'); // Our own uuid stays >= 14. exceljs@4.4.0 (devDependency, vendored into the
// XLSX preview worker at build time) pins uuid@8.3.2 and only calls v4();
// GHSA-w5hq-g745-h8pq is MODERATE and covers v3/v5/v6 with a caller buffer,
// so it is outside this CRITICAL/HIGH policy and unreachable from exceljs.
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0', ['8.3.2']);
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of // ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion // GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version // is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
@@ -556,6 +556,52 @@ describe('file-routes attachment path guard (COD-53)', () => {
}); });
}); });
// ===== XLSX: client-side preview, same guard and routes =====
// xlsx joins the extension allowlist so the overlay can preview a workbook
// outside the workspace by id. Nothing else about the pipeline changes; only
// `?preview=true` adds a tighter size cap because the browser parses it.
describe('xlsx attachments', () => {
async function registerXlsx(size: number) {
mockedStat.mockResolvedValue({ size, isFile: () => true, mtimeMs: 5 } as never);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path: '/tmp/report.xlsx', notify: false },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body).data as { attachmentId: string; attachmentType: string };
}
it('registers an xlsx as a spreadsheet attachment', async () => {
const data = await registerXlsx(2048);
expect(data.attachmentType).toBe('spreadsheet');
});
it('still refuses xls and ods (download-only, no preview)', async () => {
for (const path of ['/tmp/legacy.xls', '/tmp/open.ods']) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/attachments`,
payload: { path, notify: false },
});
expect(res.statusCode, path).toBe(400);
}
});
it('caps a ?preview=true fetch at 10 MB but still serves a plain download', async () => {
const { attachmentId } = await registerXlsx(11 * 1024 * 1024);
const base = `/api/sessions/${harness.ctx._sessionId}/attachments/${attachmentId}/raw`;
const preview = await harness.app.inject({ method: 'GET', url: `${base}?preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
mockedCreateReadStream.mockReturnValue(Readable.from([Buffer.from('PK')]) as never);
const download = await harness.app.inject({ method: 'GET', url: `${base}?preview=true&download=true` });
expect(download.statusCode).toBe(200);
});
});
// ===== Quiet registration (click-to-preview) ===== // ===== Quiet registration (click-to-preview) =====
// The file-preview overlay registers a clicked out-of-workspace path to mint // The file-preview overlay registers a clicked out-of-workspace path to mint
// an id it can render by. It is already putting the file on screen, so the // an id it can render by. It is already putting the file on screen, so the
+42 -2
View File
@@ -695,12 +695,12 @@ describe('file-routes', () => {
expect(body.data.url).toContain('file-raw'); expect(body.data.url).toContain('file-raw');
}); });
it('flags known-binary extensions (e.g. xlsx) instead of dumping mojibake', async () => { it('flags known-binary extensions (e.g. xls) instead of dumping mojibake', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never); mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'GET', method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`, url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xls`,
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
@@ -709,6 +709,30 @@ describe('file-routes', () => {
expect(body.data.content).toBeUndefined(); expect(body.data.content).toBeUndefined();
}); });
it('classifies xlsx as a client-side spreadsheet preview, never a text body', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.xlsx`,
});
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.type).toBe('spreadsheet');
expect(body.data.url).toBe(`/api/sessions/${harness.ctx._sessionId}/file-raw?path=sheet.xlsx`);
expect(body.data.content).toBeUndefined();
});
it('keeps ods (and xls) download-only binaries', async () => {
mockedStat.mockResolvedValue({ size: 4096 } as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=sheet.ods`,
});
expect(JSON.parse(res.body).data.type).toBe('binary');
});
it('sniffs NUL bytes and flags binary content for unknown extensions', async () => { it('sniffs NUL bytes and flags binary content for unknown extensions', async () => {
const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]); const binary = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01, 0x02]);
mockedReadFile.mockResolvedValue(binary as never); mockedReadFile.mockResolvedValue(binary as never);
@@ -868,6 +892,22 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(413); expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES'); expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
}); });
it('caps an xlsx ?preview=true fetch at 10 MB, leaving downloads and small previews alone', async () => {
mockedStat.mockResolvedValue({ size: 11 * 1024 * 1024 } as never);
const url = `/api/sessions/${harness.ctx._sessionId}/file-raw?path=book.xlsx`;
const preview = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(preview.statusCode).toBe(413);
expect(JSON.parse(preview.body).error).toMatch(/too large to preview/i);
const download = await harness.app.inject({ method: 'GET', url: `${url}&preview=true&download=true` });
expect(download.statusCode).toBe(200);
mockedStat.mockResolvedValue({ size: 2048 } as never);
const small = await harness.app.inject({ method: 'GET', url: `${url}&preview=true` });
expect(small.statusCode).toBe(200);
});
}); });
// ========== DELETE /api/sessions/:id/tail-file/:streamId ========== // ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
+84
View File
@@ -0,0 +1,84 @@
/**
* @fileoverview XLSX preview asset delivery contracts.
*
* The parser bundles (exceljs ~950 KB, fflate ~33 KB) must never cost a page
* load: the page loads only spreadsheet-preview.js, and the vendor files are
* pulled by the worker when a spreadsheet is actually opened. These checks pin
* that shape plus the pinned versions and the dev/prod vendoring steps.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const root = resolve(import.meta.dirname, '..');
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
describe('spreadsheet preview assets', () => {
it('pins the browser parser packages exactly, as build-time (dev) dependencies', () => {
const pkg = JSON.parse(read('package.json')) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(pkg.devDependencies?.exceljs).toBe('4.4.0');
expect(pkg.devDependencies?.fflate).toBe('0.8.2');
// They are vendored into dist/ at build time; a runtime install never needs them.
expect(pkg.dependencies?.exceljs).toBeUndefined();
expect(pkg.dependencies?.fflate).toBeUndefined();
});
it('prepares the vendor bundles for development and production', () => {
const postinstall = read('scripts/postinstall.js');
const build = read('scripts/build.mjs');
const prepare = read('scripts/prepare-spreadsheet-assets.mjs');
expect(postinstall).toContain('prepare-spreadsheet-assets.mjs');
expect(build).toContain('node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor');
expect(prepare).toContain('exceljs.min.js');
expect(prepare).toContain('fflate.min.js');
expect(prepare).toContain('sourceMappingURL');
// Output lands in a gitignored dir, so the ~1 MB never gets committed.
expect(read('.gitignore')).toMatch(/^src\/web\/public\/vendor\/$/m);
});
it('loads only the small renderer on the page; the worker pulls the parser on demand', () => {
const html = read('src/web/public/index.html');
expect(html).toContain('<script defer src="spreadsheet-preview.js"></script>');
for (const lazy of ['spreadsheet-preview-worker.js', 'spreadsheet-xlsx-core.js', 'exceljs', 'fflate']) {
expect(html, lazy).not.toContain(lazy);
}
const sw = read('src/web/public/sw.js');
expect(sw).not.toContain('exceljs');
const worker = read('src/web/public/spreadsheet-preview-worker.js');
// ExcelJS loads only after admission, inside loadWorkbook().
const admit = worker.indexOf('core.admitXlsx(');
const excel = worker.indexOf('vendor/exceljs.min.js');
expect(admit).toBeGreaterThan(-1);
expect(excel).toBeGreaterThan(admit);
const build = read('scripts/build.mjs');
expect(build).toContain("'spreadsheet-preview.js',");
// The worker is a stable URL busted by SPREADSHEET_ASSET_VERSION, not a hashed name.
expect(build).not.toContain("'spreadsheet-preview-worker.js'");
});
it('routes an agent-printed .xlsx path to the preview overlay, but not .xls/.ods', () => {
const constants = read('src/web/public/constants.js');
const previewList = /FILE_PREVIEW_EXTENSIONS = new Set\(\s*\('([^']+)'\)/.exec(constants)?.[1].split(' ') ?? [];
expect(previewList).toContain('xlsx');
expect(previewList).not.toContain('xls');
expect(previewList).not.toContain('ods');
expect(constants).toMatch(/\|pptx\|xlsx\|/);
});
it('checks the combined size budget and the content-derived asset version', () => {
const check = read('scripts/check-public-assets.mjs');
expect(check).toContain('SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000');
expect(check).toContain('SPREADSHEET_ASSET_VERSION');
expect(check).toContain('spreadsheet-preview-worker.js');
expect(check).toContain('spreadsheet-xlsx-core.js');
expect(check).toContain("createHash('sha256')");
expect(read('src/web/public/spreadsheet-preview.js')).toMatch(/const SPREADSHEET_ASSET_VERSION = '[a-f0-9]{12}'/);
});
});
+237
View File
@@ -0,0 +1,237 @@
/** @fileoverview Worker-owned XLSX parse and viewport protocol. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import ExcelJS from 'exceljs';
import * as fflate from 'fflate';
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');
async function fixture(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const first = workbook.addWorksheet('Summary');
first.getCell('A1').value = 'Revenue';
first.getCell('B2').value = 1234.5;
first.getCell('B2').numFmt = '$#,##0.00';
first.getCell('C3').value = { formula: 'SUM(B2)', result: 1234.5 };
first.getCell('C3').numFmt = '$#,##0.00';
first.mergeCells('A4:C4');
first.getCell('A4').value = 'Merged';
first.getRow(2).height = 30;
first.getColumn(2).width = 18;
const second = workbook.addWorksheet('Details');
second.getCell('A1').value = 'Detail';
workbook.addWorksheet('Hidden').state = 'hidden';
const bytes = await workbook.xlsx.writeBuffer();
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
async function fixtureWithChartPart(): Promise<ArrayBuffer> {
const entries = fflate.unzipSync(new Uint8Array(await fixture()));
entries['xl/charts/chart1.xml'] = fflate.strToU8('<chart/>');
const bytes = fflate.zipSync(entries);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
// Theme fills, indexed colours, and unfilled cells in one workbook.
// accent1 is forced to pure red so a resolved theme colour cannot be confused
// with the built-in default Office palette.
async function themedFixture(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Themed');
for (const ref of ['A1', 'A2', 'A3', 'A4']) sheet.getCell(ref).value = ref;
const entries = fflate.unzipSync(new Uint8Array(await workbook.xlsx.writeBuffer()));
entries['xl/theme/theme1.xml'] = fflate.strToU8(
'<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">' +
'<a:themeElements><a:clrScheme name="Custom">' +
'<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>' +
'<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>' +
'<a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="EEECE1"/></a:lt2>' +
'<a:accent1><a:srgbClr val="FF0000"/></a:accent1><a:accent2><a:srgbClr val="C0504D"/></a:accent2>' +
'<a:accent3><a:srgbClr val="9BBB59"/></a:accent3><a:accent4><a:srgbClr val="8064A2"/></a:accent4>' +
'<a:accent5><a:srgbClr val="4BACC6"/></a:accent5><a:accent6><a:srgbClr val="F79646"/></a:accent6>' +
'<a:hlink><a:srgbClr val="0000FF"/></a:hlink><a:folHlink><a:srgbClr val="800080"/></a:folHlink>' +
'</a:clrScheme></a:themeElements></a:theme>'
);
entries['xl/styles.xml'] = fflate.strToU8(
'<?xml version="1.0"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
'<fonts count="5"><font><sz val="11"/></font><font><color theme="1"/></font><font><color indexed="9"/></font>' +
'<font><color rgb="FF000000"/></font><font><color rgb="FF11111B"/></font></fonts>' +
'<fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>' +
'<fill><patternFill patternType="solid"><fgColor theme="4"/></patternFill></fill>' +
'<fill><patternFill patternType="solid"><fgColor indexed="13"/></patternFill></fill>' +
'<fill><patternFill patternType="solid"><fgColor rgb="FF1A1A2E"/></patternFill></fill></fills>' +
'<borders count="1"><border/></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
'<cellXfs count="5"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>' +
'<xf numFmtId="0" fontId="3" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
'<xf numFmtId="0" fontId="4" fillId="4" borderId="0" xfId="0" applyFont="1" applyFill="1"/></cellXfs>' +
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>'
);
entries['xl/worksheets/sheet1.xml'] = fflate.strToU8(
fflate
.strFromU8(entries['xl/worksheets/sheet1.xml'])
.replace('<c r="A1"', '<c s="1" r="A1"')
.replace('<c r="A2"', '<c s="2" r="A2"')
.replace('<c r="A3"', '<c s="3" r="A3"')
.replace('<c r="A4"', '<c s="4" r="A4"')
);
const bytes = fflate.zipSync(entries);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
function createHarness() {
const messages: unknown[] = [];
const imports: string[] = [];
const self: Record<string, unknown> = {
location: { href: 'http://localhost/spreadsheet-preview-worker.js?v=test' },
postMessage: (message: unknown) => messages.push(message),
};
const context = vm.createContext({
self,
globalThis: self,
URL,
Uint8Array,
ArrayBuffer,
DataView,
TextDecoder,
Date,
Math,
Number,
String,
Object,
Map,
Set,
console,
importScripts: (...urls: string[]) => {
imports.push(...urls);
for (const url of urls) {
if (url.includes('fflate')) Object.assign(self, { fflate });
if (url.includes('spreadsheet-xlsx-core')) vm.runInContext(coreSource, context);
if (url.includes('exceljs')) Object.assign(self, { ExcelJS });
}
},
});
vm.runInContext(workerSource, context);
return {
messages,
imports,
send: async (data: unknown) => {
await (self.onmessage as (event: { data: unknown }) => Promise<void>)({ data });
},
};
}
describe('spreadsheet preview worker', () => {
it('admits before lazy ExcelJS loading and returns visible sheets in workbook order', async () => {
const harness = createHarness();
expect(harness.messages).toEqual([{ type: 'ready' }]);
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
await harness.send({ type: 'load', bytes: await fixture() });
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(true);
const metadata = harness.messages.at(-1) as Record<string, any>;
expect(metadata.type).toBe('metadata');
expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Summary', 'Details']);
expect(metadata.sheets[0]).toMatchObject({ rows: 4, cols: 3 });
expect(metadata.sheets[0].rowOverrides).toContainEqual([2, 40]);
expect(metadata.sheets[0].columnOverrides[0][0]).toBe(2);
expect(metadata.styles.length).toBeLessThanOrEqual(5000);
});
it('returns bounded intersecting tiles and echoes request and sheet identity', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixture() });
const metadata = harness.messages.at(-1) as Record<string, any>;
const sheetId = metadata.sheets[0].id;
await harness.send({ type: 'tile', requestId: 7, sheetId, range: { r1: 2, c1: 2, r2: 4, c2: 3 } });
const tile = harness.messages.at(-1) as Record<string, any>;
expect(tile).toMatchObject({ type: 'tile', requestId: 7, sheetId });
expect(tile.cells).toEqual(
expect.arrayContaining([
expect.objectContaining({ row: 2, col: 2, text: '$1,234.50' }),
expect.objectContaining({ row: 3, col: 3, text: '$1,234.50' }),
])
);
expect(tile.merges).toContain('A4:C4');
expect(tile.cells).toContainEqual(expect.objectContaining({ row: 4, col: 1, text: 'Merged' }));
expect(tile.cells.length).toBeLessThanOrEqual(9);
});
it('rejects malformed input without loading ExcelJS', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: Uint8Array.from([1, 2, 3]).buffer });
expect(harness.messages.at(-1)).toMatchObject({ type: 'error', code: 'malformed' });
expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false);
});
it('resolves theme and indexed colours and emits contrast-safe colour pairs', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await themedFixture() });
const metadata = harness.messages.at(-1) as Record<string, any>;
const sheetId = metadata.sheets[0].id;
await harness.send({ type: 'tile', requestId: 1, sheetId, range: { r1: 1, c1: 1, r2: 4, c2: 1 } });
const tile = harness.messages.at(-1) as Record<string, any>;
const pairs = new Map<number, { fill?: string; color?: string }>();
for (const cell of tile.cells as Array<{ row: number; styleId: number }>) {
const style = metadata.styles[cell.styleId];
pairs.set(cell.row, { fill: style.fill, color: style.font?.color });
}
// Theme fill (workbook's own accent1 = red) survives instead of being dropped.
expect(pairs.get(1)).toEqual({ fill: '#ff0000', color: '#000000' });
// Indexed white-on-yellow is illegible, so the foreground flips to black.
expect(pairs.get(2)).toEqual({ fill: '#ffff00', color: '#000000' });
// A black font with no fill renders on the implicit white sheet background.
expect(pairs.get(3)).toEqual({ fill: '#ffffff', color: '#000000' });
// Near-black on near-black flips the other way.
expect(pairs.get(4)).toEqual({ fill: '#1a1a2e', color: '#ffffff' });
});
it('loads its scripts by RELATIVE url so a --base-url mount resolves them under the prefix', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixture() });
expect(harness.imports.length).toBeGreaterThan(0);
for (const url of harness.imports) {
expect(url.startsWith('/'), url).toBe(false);
expect(url).toContain('?v=test');
}
});
it('never evaluates formulas: an uncached formula is shown as its source text', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Formulas');
sheet.getCell('A1').value = { formula: 'WEBSERVICE("http://example.invalid/")' } as ExcelJS.CellFormulaValue;
const written = await workbook.xlsx.writeBuffer();
const harness = createHarness();
await harness.send({
type: 'load',
bytes: written.buffer.slice(written.byteOffset, written.byteOffset + written.byteLength),
});
const metadata = harness.messages.at(-1) as Record<string, any>;
await harness.send({
type: 'tile',
requestId: 1,
sheetId: metadata.sheets[0].id,
range: { r1: 1, c1: 1, r2: 1, c2: 1 },
});
const tile = harness.messages.at(-1) as Record<string, any>;
expect(tile.cells).toEqual([
expect.objectContaining({ row: 1, col: 1, text: '=WEBSERVICE("http://example.invalid/")' }),
]);
expect(tile.warnings).toContain('Formula has no cached result');
});
it('carries detected unsupported features into workbook metadata', async () => {
const harness = createHarness();
await harness.send({ type: 'load', bytes: await fixtureWithChartPart() });
expect(harness.messages.at(-1)).toMatchObject({ type: 'metadata', warnings: ['charts'] });
});
});
+131
View File
@@ -0,0 +1,131 @@
/** @fileoverview XLSX preview in real Chromium: Worker + vendor bundles load same-origin under a strict CSP. */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import ExcelJS from 'exceljs';
const root = resolve(import.meta.dirname, '..');
const publicRoot = resolve(root, 'src/web/public');
let browser: Browser;
async function workbookBytes(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const summary = workbook.addWorksheet('Summary');
summary.getCell('A1').value = 'Local workbook';
summary.getCell('B2').value = 42;
summary.mergeCells('A3:C3');
summary.getCell('A3').value = 'Merged cells';
summary.getCell('A100').value = 'Far row';
summary.getCell('Z1').value = 'Far column';
const details = workbook.addWorksheet('Details');
details.getCell('A1').value = 'Second sheet';
return Buffer.from(await workbook.xlsx.writeBuffer());
}
describe('spreadsheet preview browser boundary', () => {
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
});
it('renders through same-origin versioned Worker assets under CSP without external requests', async () => {
const page = await browser.newPage({ viewport: { width: 390, height: 760 }, hasTouch: true });
const workbook = await workbookBytes();
const requests: string[] = [];
page.on('request', (request) => requests.push(request.url()));
await page.route('https://codeman.test/**', async (route) => {
const url = new URL(route.request().url());
if (url.pathname === '/') {
await route.fulfill({
contentType: 'text/html',
headers: {
'Content-Security-Policy':
"default-src 'self'; script-src 'self'; worker-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'",
},
body:
'<!doctype html><html><head><link rel="stylesheet" href="/styles.css"><link rel="stylesheet" href="/mobile.css"></head>' +
'<body><div id="preview" style="width:370px;height:650px"></div>' +
'<script src="/spreadsheet-preview.js"></script><script src="/start.js"></script></body></html>',
});
return;
}
if (url.pathname === '/start.js') {
await route.fulfill({
contentType: 'application/javascript',
body: `window.previewHandle = window.CodemanSpreadsheetPreview.open({container:document.querySelector('#preview'),url:'/api/book.xlsx?preview=true',size:${workbook.length}});`,
});
return;
}
if (url.pathname === '/api/book.xlsx') {
await route.fulfill({
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
body: workbook,
});
return;
}
const assetPath = resolve(publicRoot, `.${url.pathname}`);
if (!assetPath.startsWith(publicRoot)) throw new Error('Unexpected asset path');
await route.fulfill({
contentType: url.pathname.endsWith('.js')
? 'application/javascript'
: url.pathname.endsWith('.css')
? 'text/css'
: 'application/octet-stream',
body: readFileSync(assetPath),
});
});
await page.goto('https://codeman.test/');
await expect.poll(() => page.locator('.spreadsheet-sheet-tab').count()).toBe(2);
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Local workbook' }).count()).toBe(1);
const initialGeometry = await page.locator('.spreadsheet-grid').evaluate((grid) => {
const cell = grid.querySelector('.spreadsheet-cell');
if (!cell) throw new Error('Missing rendered spreadsheet cell');
const gridRect = grid.getBoundingClientRect();
const cellRect = cell.getBoundingClientRect();
return { left: cellRect.left - gridRect.left, top: cellRect.top - gridRect.top };
});
expect(initialGeometry.left).toBeGreaterThanOrEqual(36);
expect(initialGeometry.top).toBeGreaterThanOrEqual(20);
await page.locator('.spreadsheet-grid').evaluate((grid) => {
grid.scrollTop = 400;
grid.scrollLeft = 400;
grid.dispatchEvent(new Event('scroll'));
});
await expect
.poll(() =>
page.locator('.spreadsheet-grid').evaluate((grid) => {
const bounds = grid.getBoundingClientRect();
return [...grid.querySelectorAll('.spreadsheet-row-heading, .spreadsheet-column-heading')].filter(
(heading) => {
const rect = heading.getBoundingClientRect();
return (
rect.right > bounds.left &&
rect.left < bounds.right &&
rect.bottom > bounds.top &&
rect.top < bounds.bottom
);
}
).length;
})
)
.toBeGreaterThan(0);
await page.getByRole('tab', { name: 'Details' }).click();
await expect.poll(() => page.locator('.spreadsheet-cell', { hasText: 'Second sheet' }).count()).toBe(1);
expect(await page.locator('.spreadsheet-grid').evaluate((element) => getComputedStyle(element).overflow)).toBe(
'auto'
);
const origins = new Set(requests.map((request) => new URL(request).origin));
expect(origins).toEqual(new Set(['https://codeman.test']));
expect(requests.some((request) => /spreadsheet-preview-worker\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
expect(requests.some((request) => /vendor\/exceljs\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
expect(requests.some((request) => /vendor\/fflate\.min\.js\?v=[a-f0-9]{12}/.test(request))).toBe(true);
await page.close();
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* @fileoverview Spreadsheet renderer lifecycle and virtualization, plus its
* wiring into the file-preview overlay (panels-ui.js).
*
* The renderer runs against a standalone JSDOM window (like i18n-branding.test.ts)
* rather than the `jsdom` vitest environment. Workbook strings are asserted to
* land as text, never markup.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-preview.js'), 'utf8');
const panelsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
// A real origin: the renderer resolves its fetch URL against `location.href`.
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const window = dom.window as any;
const document = window.document as Document;
class WorkerMock {
static instances: WorkerMock[] = [];
onmessage: ((event: { data: any }) => void) | null = null;
onerror: (() => void) | null = null;
onmessageerror: (() => void) | null = null;
postMessage = vi.fn();
terminate = vi.fn();
constructor(readonly url: string) {
WorkerMock.instances.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data });
}
}
function loadRenderer(fetchMock: ReturnType<typeof vi.fn>) {
const context = vm.createContext({
window,
document,
URL,
AbortController,
Worker: WorkerMock,
ResizeObserver: undefined,
fetch: fetchMock,
setTimeout,
clearTimeout,
console,
});
vm.runInContext(source, context);
return (window as any).CodemanSpreadsheetPreview as { open(options: Record<string, unknown>): { dispose(): void } };
}
function metadata() {
return {
type: 'metadata',
sheets: [
{
id: '1',
name: '<Summary>',
rows: 1_000_000,
cols: 100,
defaultRowHeight: 20,
defaultColumnWidth: 64,
rowOverrides: [],
columnOverrides: [],
},
{ id: '2', name: 'Details', rows: 1, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 },
],
};
}
describe('spreadsheet preview renderer', () => {
beforeEach(() => {
WorkerMock.instances = [];
document.body.innerHTML = '<div id="preview"></div>';
delete (window as any).CodemanSpreadsheetPreview;
window.requestAnimationFrame = (callback: FrameRequestCallback) =>
setTimeout(() => callback(0), 0) as unknown as number;
window.cancelAnimationFrame = (id: number) => clearTimeout(id);
});
it('refuses oversized metadata before fetch or worker creation', () => {
const fetchMock = vi.fn();
const renderer = loadRenderer(fetchMock);
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 10 * 1024 * 1024 + 1 });
expect(fetchMock).not.toHaveBeenCalled();
expect(WorkerMock.instances).toHaveLength(0);
expect(document.body.textContent).toContain('10 MB limit');
});
it('fetches same-origin preview bytes, transfers them, and bounds rendered cells', async () => {
const bytes = new ArrayBuffer(8);
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => bytes }));
const renderer = loadRenderer(fetchMock);
renderer.open({ container: document.querySelector('#preview'), url: '/api/book.xlsx', size: 8 });
const worker = WorkerMock.instances[0];
worker.emit({ type: 'ready' });
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledWith({ type: 'load', bytes }, [bytes]));
expect(fetchMock).toHaveBeenCalledWith(
'/api/book.xlsx?preview=true',
expect.objectContaining({ signal: expect.anything() })
);
worker.emit(metadata());
const tileRequest = worker.postMessage.mock.calls.at(-1)?.[0];
expect(tileRequest.type).toBe('tile');
expect(tileRequest.range.r2 - tileRequest.range.r1).toBeLessThan(100);
worker.emit({
type: 'tile',
requestId: tileRequest.requestId,
sheetId: '1',
cells: Array.from({ length: 3000 }, (_, index) => ({
row: index + 1,
col: 1,
text: `<b>${index}</b>`,
styleId: 0,
})),
});
expect(document.querySelectorAll('.spreadsheet-cell')).toHaveLength(2500);
expect(document.querySelector('.spreadsheet-cell')?.textContent).toBe('<b>0</b>');
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.top).toBe('20px');
expect((document.querySelector('.spreadsheet-cell') as HTMLElement).style.left).toBe('36px');
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
grid.scrollLeft = 400;
grid.scrollTop = 300;
grid.dispatchEvent(new window.Event('scroll'));
expect((document.querySelector('.spreadsheet-row-heading') as HTMLElement).style.left).toBe('400px');
expect((document.querySelector('.spreadsheet-column-heading') as HTMLElement).style.top).toBe('300px');
expect(document.querySelector('.spreadsheet-sheet-tabs')?.hasAttribute('data-i18n-skip')).toBe(true);
expect(document.querySelector('.spreadsheet-grid')?.hasAttribute('data-i18n-skip')).toBe(true);
expect(Number.parseFloat((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height)).toBe(
8_000_020
);
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
expect((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height).toBe('40px');
});
it('drops stale tiles and aborts fetch plus terminates worker on dispose', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
const renderer = loadRenderer(fetchMock);
const handle = 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());
worker.emit(metadata());
const request = worker.postMessage.mock.calls.at(-1)?.[0];
worker.emit({
type: 'tile',
requestId: request.requestId - 1,
sheetId: '1',
cells: [{ row: 1, col: 1, text: 'stale' }],
});
expect(document.body.textContent).not.toContain('stale');
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
handle.dispose();
expect(signal.aborted).toBe(true);
expect(worker.terminate).toHaveBeenCalledTimes(1);
});
it('shows feature warnings and reports worker failures after readiness', 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());
worker.emit({ ...metadata(), warnings: ['charts'] });
const request = worker.postMessage.mock.calls.at(-1)?.[0];
worker.emit({ type: 'tile', requestId: request.requestId, sheetId: '1', cells: [], warnings: [] });
expect(document.body.textContent).toContain('charts');
worker.onerror?.();
expect(document.body.textContent).toContain('Spreadsheet parser failed');
});
it('shows an explicit empty-sheet state without dropping workbook warnings', 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());
worker.emit({
type: 'metadata',
warnings: ['charts'],
styles: [],
sheets: [
{
id: '0',
name: 'Data',
rows: 1,
cols: 1,
defaultRowHeight: 20,
defaultColumnWidth: 64,
rowOverrides: [],
columnOverrides: [],
},
{
id: '1',
name: 'Empty',
rows: 0,
cols: 0,
defaultRowHeight: 20,
defaultColumnWidth: 64,
rowOverrides: [],
columnOverrides: [],
},
],
});
const request = worker.postMessage.mock.calls.at(-1)?.[0];
worker.emit({
type: 'tile',
requestId: request.requestId,
sheetId: '0',
cells: [{ row: 1, col: 1, text: 'old cell', styleId: 0 }],
warnings: [],
});
expect(document.body.textContent).toContain('old cell');
(document.querySelectorAll('[role="tab"]')[1] as HTMLButtonElement).click();
expect(document.body.textContent).toContain('This worksheet is empty.');
expect(document.body.textContent).toContain('charts');
expect(document.body.textContent).not.toContain('old cell');
});
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);
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());
worker.emit({
...metadata(),
styles: [
{ font: { color: '#000000' }, fill: '#ffffff' },
{ font: { bold: true, color: '#000000' }, fill: undefined },
{ font: {}, fill: '#ffff00' },
{ font: { italic: true }, alignment: 'center' },
],
});
const css = (document.querySelector('.spreadsheet-preview-shell style') as HTMLElement).textContent || '';
expect(css).toContain('.spreadsheet-style-0{color:#000000;background-color:#ffffff}');
// A half pair would strand the text on the skin's own background, so drop both.
expect(css).toContain('.spreadsheet-style-1{font-weight:700}');
expect(css).toContain('.spreadsheet-style-2{}');
expect(css).toContain('.spreadsheet-style-3{font-style:italic;text-align:center}');
expect(css).not.toContain('background-color:#ffff00');
});
});
/**
* The overlay wiring in panels-ui.js: which route the renderer is pointed at,
* and that the worker/fetch lifecycle is torn down on close and on re-open.
*/
describe('file-preview overlay spreadsheet wiring', () => {
function loadOverlay(fileContent: Record<string, unknown>) {
const handles: Array<{ dispose: ReturnType<typeof vi.fn> }> = [];
const open = vi.fn(() => {
const handle = { dispose: vi.fn() };
handles.push(handle);
return handle;
});
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: fileContent }) }));
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
escapeHtml: (value: unknown) => String(value ?? ''),
CodemanBase: { base: '', url: (p: string) => p },
fetch: fetchMock,
setTimeout,
clearTimeout,
confirm: () => true,
document,
window: { CodemanSpreadsheetPreview: { open }, addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(panelsSource, context, { filename: 'panels-ui.js' });
document.body.innerHTML =
'<div id="filePreviewOverlay"></div><div id="filePreviewTitle"></div><div id="filePreviewBody"></div>' +
'<div id="filePreviewFooter"></div>';
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.sessions = new Map([['s1', { workingDir: '/work' }]]);
app.formatFileSize = () => '8 KB';
return { app, open, handles, fetchMock };
}
it('renders a workspace xlsx through the renderer, pointed at the confined file-raw route', async () => {
const { app, open, handles } = loadOverlay({
type: 'spreadsheet',
size: 8192,
extension: 'xlsx',
url: '/api/sessions/s1/file-raw?path=book.xlsx',
});
await app.openFilePreview('book.xlsx', 's1');
const body = document.getElementById('filePreviewBody');
expect(open).toHaveBeenCalledTimes(1);
expect(open).toHaveBeenCalledWith(
expect.objectContaining({ container: body, url: '/api/sessions/s1/file-raw?path=book.xlsx', size: 8192 })
);
expect(document.getElementById('filePreviewFooter')?.textContent).toContain('xlsx');
app.closeFilePreview();
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
});
it('disposes the previous spreadsheet when another preview opens, and stale renders stop', async () => {
const { app, open, handles } = loadOverlay({
type: 'spreadsheet',
size: 1,
extension: 'xlsx',
url: '/api/sessions/s1/file-raw?path=a.xlsx',
});
await app.openFilePreview('a.xlsx', 's1');
const firstIsCurrent = (open.mock.calls[0] as unknown as [{ isCurrent(): boolean }])[0].isCurrent;
expect(firstIsCurrent()).toBe(true);
await app.openFilePreview('b.xlsx', 's1');
expect(handles[0].dispose).toHaveBeenCalledTimes(1);
expect(firstIsCurrent()).toBe(false);
expect(handles[1].dispose).not.toHaveBeenCalled();
});
it('renders a registered xlsx attachment from its by-id raw route', async () => {
const { app, open, fetchMock } = loadOverlay({});
await app.openFilePreview('book.xlsx', 's1', 'att-1');
expect(fetchMock).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith(expect.objectContaining({ url: '/api/sessions/s1/attachments/att-1/raw' }));
});
it('leaves xls/ods on the download-only binary path', async () => {
const { app, open } = loadOverlay({ type: 'binary', size: 10, extension: 'xls' });
await app.openFilePreview('old.xls', 's1');
expect(open).not.toHaveBeenCalled();
expect(document.getElementById('filePreviewBody')?.textContent).toContain('Cannot preview');
});
});
+306
View File
@@ -0,0 +1,306 @@
/** @fileoverview XLSX admission, formatting, and sparse geometry contracts. */
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as fflate from 'fflate';
type Core = {
LIMITS: Record<string, number>;
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;
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;
axisOffset(axis: unknown, index: number): number;
axisIndexAt(axis: unknown, offset: number): number;
computeViewport(axis: unknown, offset: number, viewportSize: number, overscan?: number): [number, number];
intersectingMerges(merges: string[], range: { r1: number; c1: number; r2: number; c2: number }): string[];
formatCellValue(value: unknown, format: string, date1904?: boolean): { text: string; warning?: string };
DEFAULT_THEME_PALETTE: string[];
INDEXED_PALETTE: string[];
parseThemePalette(xml?: string): string[];
resolveColor(color: unknown, palette?: string[]): string | undefined;
contrastRatio(a: string, b: string): number;
ensureContrast(foreground: string, background: string, minRatio?: number): string;
resolveCellColors(
fillColor: unknown,
fontColor: unknown,
palette?: string[]
): { background?: string; foreground?: string };
};
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/spreadsheet-xlsx-core.js'), 'utf8');
const context = vm.createContext({ Uint8Array, DataView, TextDecoder, Date, Math, Number, String, Object, Map, Set });
vm.runInContext(source, context);
const core = (context as unknown as { CodemanSpreadsheetXlsxCore: Core }).CodemanSpreadsheetXlsxCore;
const workbookZip = (sheet = '<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells/></worksheet>') =>
fflate.zipSync({
'[Content_Types].xml': fflate.strToU8('<Types/>'),
'xl/workbook.xml': fflate.strToU8('<workbook><sheets><sheet name="Sheet1"/></sheets></workbook>'),
'xl/styles.xml': fflate.strToU8('<styleSheet><cellXfs count="1"><xf/></cellXfs></styleSheet>'),
'xl/worksheets/sheet1.xml': fflate.strToU8(sheet),
});
describe('spreadsheet XLSX core', () => {
it('rejects encrypted OLE files, malformed ZIPs, and ZIP64 sentinels', () => {
expect(() => core.inspectZipDirectory(Uint8Array.from([0xd0, 0xcf, 0x11, 0xe0]))).toThrowError(/encrypted/i);
expect(() => core.inspectZipDirectory(Uint8Array.from([1, 2, 3]))).toThrowError(/malformed/i);
const zip = workbookZip();
const eocd = zip.length - 22;
zip[eocd + 10] = 0xff;
zip[eocd + 11] = 0xff;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/ZIP64/i);
});
it('rejects divergent local-header and central-directory entry names', () => {
const zip = workbookZip();
const localNameLength = new DataView(zip.buffer, zip.byteOffset, zip.byteLength).getUint16(26, true);
expect(localNameLength).toBeGreaterThan(0);
zip[30] = zip[30] === 120 ? 121 : 120;
expect(() => core.inspectZipDirectory(zip)).toThrowError(/names do not match/i);
});
it('rejects central-directory entries that were not streamed through admission', () => {
class IncompleteUnzip {
constructor(private readonly onFile: (file: any) => void) {}
register() {}
push(_bytes: Uint8Array, final: boolean) {
if (!final) return;
const file: Record<string, any> = {
name: '[Content_Types].xml',
start: () => file.ondata(null, new Uint8Array(), true),
};
this.onFile(file);
}
}
const incomplete = { Unzip: IncompleteUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
expect(() => core.admitXlsx(workbookZip(), incomplete)).toThrowError(/not streamed/i);
});
it('uses actual streamed output for entry, total, ratio, and XML-count limits', () => {
const inflated = 'x'.repeat(20_000);
const bomb = fflate.zipSync({ 'xl/worksheets/sheet1.xml': fflate.strToU8(inflated) }, { level: 9 });
expect(() => core.admitXlsx(bomb, fflate, { maxEntryBytes: 10_000 })).toThrowError(/entry/i);
expect(() => core.admitXlsx(bomb, fflate, { maxInflatedBytes: 10_000 })).toThrowError(/inflated/i);
expect(() => core.admitXlsx(bomb, fflate, { maxCompressionRatio: 2 })).toThrowError(/compression ratio/i);
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
expect(() => core.admitXlsx(workbookZip(cells), fflate, { maxCellsPerSheet: 3 })).toThrowError(/cells/i);
expect(() => core.admitXlsx(workbookZip(), fflate, { maxEntries: 2 })).toThrowError(/entries/i);
});
it('detects OOXML feature parts and counts worksheets, merges, styles, and cells', () => {
const zip = fflate.zipSync({
...fflate.unzipSync(
workbookZip('<worksheet><sheetData><c r="A1"/></sheetData><mergeCell ref="A1:B2"/></worksheet>')
),
'xl/charts/chart1.xml': fflate.strToU8('<chart/>'),
'xl/externalLinks/externalLink1.xml': fflate.strToU8('<externalLink/>'),
});
const result = core.admitXlsx(zip, fflate) as {
counts: { worksheets: number; cells: number; merges: number; styles: number };
features: string[];
};
expect(result.counts).toEqual({ worksheets: 1, cells: 1, merges: 1, styles: 1 });
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
});
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();
expect(core.deriveExtent(['B3'], ['D5:F9'])).toEqual({ rows: 9, cols: 6 });
});
it('maps sparse axes and virtual viewports without dense allocation', () => {
const axis = core.createSparseAxis(1_000_000, 20, [
[2, 0],
[10, 40],
]);
expect(core.axisOffset(axis, 3)).toBe(20);
expect(core.axisIndexAt(axis, 20)).toBe(3);
const [start, end] = core.computeViewport(axis, 199, 60, 1);
expect(start).toBeLessThanOrEqual(9);
expect(end - start).toBeLessThan(10);
});
it('returns intersecting merges even when their anchor is offscreen', () => {
expect(core.intersectingMerges(['A1:D4', 'Z1:Z2'], { r1: 3, c1: 3, r2: 6, c2: 6 })).toEqual(['A1:D4']);
});
it('formats common values and safely flags unknown formats', () => {
expect(core.formatCellValue(0.125, '0.0%').text).toBe('12.5%');
expect(core.formatCellValue(1234.5, '#,##0.00').text).toBe('1,234.50');
expect(core.formatCellValue(10, '$#,##0.00').text).toBe('$10.00');
expect(core.formatCellValue(1, 'yyyy-mm-dd').text).toBe('1900-01-01');
expect(core.formatCellValue(0, 'yyyy-mm-dd', true).text).toBe('1904-01-01');
expect(core.formatCellValue(0.5, 'hh:mm:ss').text).toBe('12:00:00');
expect(core.formatCellValue(1.5, 'yyyy-mm-dd hh:mm').text).toBe('1900-01-01 12:00');
expect(core.formatCellValue(7, '[Red][<0]0.0')).toMatchObject({ text: '7', warning: expect.any(String) });
expect(core.formatCellValue({ formula: 'SUM(A1:A2)' }, 'General')).toMatchObject({ text: '=SUM(A1:A2)' });
});
});
const themeXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Custom"><a:themeElements>
<a:clrScheme name="Custom">
<a:dk1><a:sysClr val="windowText" lastClr="102030"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="F0F1F2"/></a:lt1>
<a:dk2><a:srgbClr val="203040"/></a:dk2>
<a:lt2><a:srgbClr val="E0E1E2"/></a:lt2>
<a:accent1><a:srgbClr val="FF0000"/></a:accent1>
<a:accent2><a:srgbClr val="00FF00"/></a:accent2>
<a:accent3><a:srgbClr val="0000FF"/></a:accent3>
<a:accent4><a:srgbClr val="010203"/></a:accent4>
<a:accent5><a:srgbClr val="040506"/></a:accent5>
<a:accent6><a:srgbClr val="070809"/></a:accent6>
<a:hlink><a:srgbClr val="123456"/></a:hlink>
<a:folHlink><a:srgbClr val="654321"/></a:folHlink>
</a:clrScheme></a:themeElements></a:theme>`;
describe('spreadsheet XLSX colour resolution', () => {
it('parses a theme palette into styles.xml index order, swapping lt/dk against clrScheme order', () => {
const palette = core.parseThemePalette(themeXml);
expect(palette).toHaveLength(12);
// styles.xml order is lt1, dk1, lt2, dk2 while clrScheme lists dk1, lt1, dk2, lt2.
expect(palette[0]).toBe('#f0f1f2');
expect(palette[1]).toBe('#102030');
expect(palette[2]).toBe('#e0e1e2');
expect(palette[3]).toBe('#203040');
expect(palette.slice(4)).toEqual([
'#ff0000',
'#00ff00',
'#0000ff',
'#010203',
'#040506',
'#070809',
'#123456',
'#654321',
]);
expect(core.resolveColor({ theme: 0 }, palette)).toBe('#f0f1f2');
expect(core.resolveColor({ theme: 1 }, palette)).toBe('#102030');
expect(core.resolveColor({ theme: 12 }, palette)).toBeUndefined();
});
it('reads sysClr entries from lastClr and srgbClr entries from val', () => {
const sysOnly = core.parseThemePalette(
themeXml.replace('<a:srgbClr val="203040"/>', '<a:sysClr val="windowText" lastClr="ABCDEF"/>')
);
expect(sysOnly[3]).toBe('#abcdef');
// A sysClr with no lastClr is unusable and must not poison the whole palette.
const missing = core.parseThemePalette(
themeXml.replace('<a:sysClr val="window" lastClr="F0F1F2"/>', '<a:sysClr val="window"/>')
);
expect(missing[0]).toBe(core.DEFAULT_THEME_PALETTE[0]);
expect(missing[1]).toBe('#102030');
});
it('applies Excel tint to HSL luminance in both directions', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveColor({ theme: 4, tint: 0.5 }, palette)).toBe('#ff8080');
expect(core.resolveColor({ theme: 4, tint: -0.5 }, palette)).toBe('#800000');
expect(core.resolveColor({ theme: 4, tint: 0 }, palette)).toBe('#ff0000');
expect(core.resolveColor({ theme: 4, tint: 1 }, palette)).toBe('#ffffff');
expect(core.resolveColor({ theme: 4, tint: -1 }, palette)).toBe('#000000');
});
it('falls back to the default Office palette when theme XML is missing or unparseable', () => {
expect(core.DEFAULT_THEME_PALETTE).toHaveLength(12);
expect(core.DEFAULT_THEME_PALETTE[0]).toBe('#ffffff');
expect(core.DEFAULT_THEME_PALETTE[1]).toBe('#000000');
expect(core.DEFAULT_THEME_PALETTE[2]).toBe('#eeece1');
expect(core.DEFAULT_THEME_PALETTE[3]).toBe('#1f497d');
expect(core.DEFAULT_THEME_PALETTE[4]).toBe('#4f81bd');
expect(core.DEFAULT_THEME_PALETTE[11]).toBe('#800080');
expect(core.parseThemePalette('')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette(undefined)).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.parseThemePalette('<html>not a theme</html>')).toEqual(core.DEFAULT_THEME_PALETTE);
expect(core.resolveColor({ theme: 1 })).toBe('#000000');
expect(core.resolveColor({ theme: 0 })).toBe('#ffffff');
});
it('resolves legacy indexed palette entries and ignores the auto sentinels', () => {
expect(core.INDEXED_PALETTE).toHaveLength(64);
expect(core.resolveColor({ indexed: 0 })).toBe('#000000');
expect(core.resolveColor({ indexed: 9 })).toBe('#ffffff');
expect(core.resolveColor({ indexed: 13 })).toBe('#ffff00');
expect(core.resolveColor({ indexed: 22 })).toBe('#c0c0c0');
expect(core.resolveColor({ indexed: 63 })).toBe('#333333');
expect(core.resolveColor({ indexed: 64 })).toBeUndefined();
expect(core.resolveColor({ indexed: 65 })).toBeUndefined();
expect(core.resolveColor({ argb: 'FF1F497D' })).toBe('#1f497d');
expect(core.resolveColor({ argb: 'nope' })).toBeUndefined();
expect(core.resolveColor(undefined)).toBeUndefined();
});
it('computes WCAG contrast ratios and overrides with the correct polarity', () => {
expect(core.contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 5);
expect(core.contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
expect(core.contrastRatio('#ffff00', '#ffffff')).toBeLessThan(4.5);
// White on yellow is unreadable -> black wins.
expect(core.ensureContrast('#ffffff', '#ffff00')).toBe('#000000');
// Near-black on black -> white wins.
expect(core.ensureContrast('#11111b', '#1a1a2e')).toBe('#ffffff');
// Already-legible pairs are preserved verbatim.
expect(core.ensureContrast('#1f497d', '#ffffff')).toBe('#1f497d');
});
it('pins an implicit white sheet background for a dark font with no resolvable fill', () => {
expect(core.resolveCellColors(undefined, { argb: 'FF000000' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 1 })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
});
it('pins black text when a fill resolves but the font colour does not', () => {
expect(core.resolveCellColors({ theme: 0 }, undefined)).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFF8E7' }, undefined)).toEqual({
background: '#fff8e7',
foreground: '#000000',
});
});
it('overrides light-on-light and dark-on-dark pairs while keeping legible authored pairs', () => {
expect(core.resolveCellColors({ indexed: 13 }, { indexed: 9 })).toEqual({
background: '#ffff00',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FFF5F5F5' })).toEqual({
background: '#ffffff',
foreground: '#000000',
});
expect(core.resolveCellColors({ argb: 'FF1A1A2E' }, { argb: 'FF11111B' })).toEqual({
background: '#1a1a2e',
foreground: '#ffffff',
});
expect(core.resolveCellColors({ argb: 'FFFFFFFF' }, { argb: 'FF1F497D' })).toEqual({
background: '#ffffff',
foreground: '#1f497d',
});
});
it('keeps theme fills and theme fonts instead of dropping them', () => {
const palette = core.parseThemePalette(themeXml);
expect(core.resolveCellColors({ theme: 4, tint: 0.6 }, { theme: 1 }, palette)).toEqual({
background: '#ff9999',
foreground: '#102030',
});
});
it('emits neither colour when nothing resolves, so the skin tokens stay paired', () => {
expect(core.resolveCellColors(undefined, undefined)).toEqual({});
expect(core.resolveCellColors({ indexed: 64 }, { theme: 99 })).toEqual({});
expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({});
});
});