mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Generated
+1004
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'presentation'
|
| 'presentation'
|
||||||
|
| 'spreadsheet'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'text';
|
| 'text';
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ==========
|
||||||
|
|||||||
@@ -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}'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user