mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge #502: read-only XLSX spreadsheet preview in the file-preview overlay
# Conflicts: # CLAUDE.md
This commit is contained in:
@@ -41,6 +41,7 @@ export const BROWSER_TEST_GLOBS = [
|
|||||||
'test/git-status.browser.test.ts',
|
'test/git-status.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',
|
||||||
'test/mobile-ime-preview.browser.test.ts',
|
'test/mobile-ime-preview.browser.test.ts',
|
||||||
'test/run-mode-menu-scroll.browser.test.ts',
|
'test/run-mode-menu-scroll.browser.test.ts',
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ It renders what it can:
|
|||||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||||
| Images | Inline. |
|
| Images | Inline. |
|
||||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||||
|
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
|
||||||
| PDF and Office documents | Converted for preview when a converter is available. |
|
| PDF and Office documents | Converted for preview when a converter is available. |
|
||||||
| Anything else | Download. |
|
| Anything else | Download. |
|
||||||
|
|
||||||
|
|||||||
Generated
+1004
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,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.3",
|
||||||
"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(
|
||||||
@@ -129,6 +132,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');
|
||||||
@@ -53,15 +53,20 @@ export const AUDIO_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|||||||
*/
|
*/
|
||||||
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
export const TEXT_ATTACHMENT_EXTENSIONS: ReadonlySet<string> = EDITABLE_EXTENSIONS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document types an attachment card previews. Also the list `codeman attach`'s
|
||||||
|
* error text names, so the help cannot drift from what is accepted. `xlsx` is
|
||||||
|
* previewed client-side (spreadsheet-preview-worker.js) and served raw like the rest.
|
||||||
|
*/
|
||||||
|
export const DOCUMENT_ATTACHMENT_EXTENSIONS: readonly string[] = Object.freeze(['pdf', 'docx', 'pptx', 'xlsx']);
|
||||||
|
|
||||||
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
'jpeg',
|
'jpeg',
|
||||||
'gif',
|
'gif',
|
||||||
'webp',
|
'webp',
|
||||||
'pdf',
|
...DOCUMENT_ATTACHMENT_EXTENSIONS,
|
||||||
'docx',
|
|
||||||
'pptx',
|
|
||||||
'md',
|
'md',
|
||||||
'txt',
|
'txt',
|
||||||
...VIDEO_ATTACHMENT_EXTENSIONS,
|
...VIDEO_ATTACHMENT_EXTENSIONS,
|
||||||
@@ -154,6 +159,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.
|
||||||
|
|||||||
+6
-2
@@ -23,7 +23,7 @@ import { getTaskQueue } from './task-queue.js';
|
|||||||
import { getRalphLoop } from './ralph-loop.js';
|
import { getRalphLoop } from './ralph-loop.js';
|
||||||
import { getStore } from './state-store.js';
|
import { getStore } from './state-store.js';
|
||||||
import { getErrorMessage } from './types.js';
|
import { getErrorMessage } from './types.js';
|
||||||
import { isSupportedAttachmentExtension } from './attachment-registry.js';
|
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from './attachment-registry.js';
|
||||||
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
import { daemonStatus, startDaemon, stopDaemon, type WebLaunchOptions } from './daemon-control.js';
|
||||||
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
import { installService, serviceStatus, uninstallService } from './service-installer.js';
|
||||||
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
import { isLoopbackBindHost, isUnauthenticatedNetworkAcknowledged } from './web/network-auth-policy.js';
|
||||||
@@ -111,7 +111,11 @@ program
|
|||||||
.action(async (filePath, options) => {
|
.action(async (filePath, options) => {
|
||||||
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
const extension = String(filePath).split('.').pop()?.toLowerCase() || '';
|
||||||
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
if (!isAbsolute(filePath) || !isSupportedAttachmentExtension(extension)) {
|
||||||
console.error(palette.err('✗ attach requires an absolute path to a png, pdf, docx, pptx, md, or txt file'));
|
console.error(
|
||||||
|
palette.err(
|
||||||
|
`✗ attach requires an absolute path to an image (png, jpg, gif, webp), document (${DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')}), audio, video, md, txt or other text file`
|
||||||
|
)
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export type AttachmentDetectedType =
|
|||||||
| 'pdf'
|
| 'pdf'
|
||||||
| 'document'
|
| 'document'
|
||||||
| 'presentation'
|
| 'presentation'
|
||||||
|
| 'spreadsheet'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'text';
|
| 'text';
|
||||||
|
|
||||||
|
|||||||
@@ -1830,7 +1830,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|avif|bmp|ico|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|avif|bmp|ico|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() {
|
||||||
@@ -1848,7 +1848,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 avif bmp ico svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ')
|
('png jpg jpeg gif webp avif bmp ico 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. */
|
||||||
|
|||||||
@@ -4039,5 +4039,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>
|
||||||
|
|||||||
@@ -4037,3 +4037,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4178,6 +4178,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
|
||||||
@@ -4274,6 +4276,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>`;
|
||||||
@@ -4347,6 +4352,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')) {
|
||||||
@@ -4361,6 +4369,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 text view: rendered markdown, line numbers, wrap
|
// File Viewer text view: rendered markdown, line numbers, wrap
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -5056,7 +5100,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||||
<div>Show a file here by running:</div>
|
<div>Show a file here by running:</div>
|
||||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
<div>Supports .pptx, .docx, .xlsx, .pdf, .png, .md, and .txt.</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* @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). ExcelJS is then
|
||||||
|
* given a STORE-only archive rebuilt from the entries admission inflated, never
|
||||||
|
* the fetched bytes, so it can only parse what admission counted. The page never
|
||||||
|
* loads either vendor file. Cell values are sent back as plain strings; the
|
||||||
|
* renderer writes them with `textContent`. Formulas are never evaluated (the
|
||||||
|
* cached result is shown, else the formula text), and nothing here fetches:
|
||||||
|
* 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();
|
||||||
|
// Per sheet: its populated rows in order, each with its populated cells in
|
||||||
|
// column order, built once at load from the keys that exist (`populatedRowIndex`).
|
||||||
|
let populatedRowsById = new Map();
|
||||||
|
// Merges read once at load: `sheet.model` rebuilds every row and cell model,
|
||||||
|
// which is far too much to pay on every tile.
|
||||||
|
let mergesById = 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum cells in one tile reply; the renderer draws at most this many too.
|
||||||
|
const MAX_TILE_CELLS = 2500;
|
||||||
|
|
||||||
|
// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the admitted
|
||||||
|
// entry is the fallback and the default Office palette is the last resort.
|
||||||
|
function readThemeXml(loadedWorkbook, admittedEntries) {
|
||||||
|
const stashed = loadedWorkbook?._themes?.theme1;
|
||||||
|
if (typeof stashed === 'string' && stashed.length > 0) return stashed;
|
||||||
|
const theme = admittedEntries?.['xl/theme/theme1.xml'];
|
||||||
|
return theme ? new TextDecoder().decode(theme) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ascending numeric own keys of a sparse array. ExcelJS keeps rows at
|
||||||
|
// `_rows[r - 1]` and a row's cells at `_cells[col - 1]`, and one far index puts
|
||||||
|
// the array in dictionary mode, where its own `eachRow`, `eachCell` and
|
||||||
|
// `hasValues` (forEach/some) visit every index up to the largest: a single XFD
|
||||||
|
// cell per row costs 16,384 steps a row. Walking the keys that exist does not.
|
||||||
|
function presentIndices(sparse) {
|
||||||
|
const indices = [];
|
||||||
|
for (const key of Object.keys(sparse || [])) {
|
||||||
|
const index = Number(key);
|
||||||
|
if (Number.isInteger(index) && index >= 0) indices.push(index);
|
||||||
|
}
|
||||||
|
return indices.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows and cells `sheet.eachRow({ includeEmpty: false })` and
|
||||||
|
// `row.eachCell({ includeEmpty: false })` would visit, in the same order: a
|
||||||
|
// cell counts when it exists and its type is not `ValueType.Null`, and a row
|
||||||
|
// counts when it holds at least one such cell (ExcelJS's `row.hasValues`).
|
||||||
|
function populatedRowIndex(sheet) {
|
||||||
|
const nullType = self.ExcelJS.ValueType.Null;
|
||||||
|
const rows = [];
|
||||||
|
for (const rowIndex of presentIndices(sheet._rows)) {
|
||||||
|
const row = sheet._rows[rowIndex];
|
||||||
|
if (!row) continue;
|
||||||
|
const cells = [];
|
||||||
|
for (const cellIndex of presentIndices(row._cells)) {
|
||||||
|
const cell = row._cells[cellIndex];
|
||||||
|
if (cell && cell.type !== nullType) cells.push(cell);
|
||||||
|
}
|
||||||
|
if (cells.length > 0) rows.push({ number: row.number, row, cells });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function worksheetMetadata(sheet) {
|
||||||
|
const cellRefs = [];
|
||||||
|
const rowOverrides = [];
|
||||||
|
const populatedRows = populatedRowIndex(sheet);
|
||||||
|
for (const { number, row, cells } of populatedRows) {
|
||||||
|
for (const cell of cells) {
|
||||||
|
cellRefs.push(cell.address);
|
||||||
|
normalizeStyle(cell);
|
||||||
|
}
|
||||||
|
if (row.hidden) rowOverrides.push([number, 0]);
|
||||||
|
else if (row.height) rowOverrides.push([number, Math.min(546, Math.max(0, row.height * (4 / 3)))]);
|
||||||
|
}
|
||||||
|
// `sheet.model` rebuilds every row and cell model, so merges come straight
|
||||||
|
// from ExcelJS's own merge map, in the order the model getter would list them.
|
||||||
|
const merges = Object.values(sheet._merges || {}).map((merge) => merge.range);
|
||||||
|
const extent = core.deriveExtent(cellRefs, merges);
|
||||||
|
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,
|
||||||
|
merges,
|
||||||
|
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);
|
||||||
|
const admitted = core.buildAdmittedArchive(admission, self.fflate);
|
||||||
|
if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`);
|
||||||
|
const nextWorkbook = new self.ExcelJS.Workbook();
|
||||||
|
// ExcelJS's DefinedNames model setter expands every range into one object per
|
||||||
|
// cell (a whole-sheet name exhausts the heap), and admission reads only the
|
||||||
|
// `<sheet>` ids in xl/workbook.xml, never defined names. The preview never shows defined names, so they are not
|
||||||
|
// stored at all; print areas and titles are split off before this setter runs.
|
||||||
|
// defineProperty throws if a future ExcelJS renames `_definedNames`, rather
|
||||||
|
// than silently expanding again.
|
||||||
|
Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} });
|
||||||
|
// ExcelJS expands every address of a `<dataValidation sqref>` into its own
|
||||||
|
// object (a whole-column dropdown is a million), and the preview never shows
|
||||||
|
// validations, so they are not parsed at all. `maxRows` is a per-sheet
|
||||||
|
// backstop behind admission's row count, which also caps the workbook total.
|
||||||
|
await nextWorkbook.xlsx.load(admitted, {
|
||||||
|
ignoreNodes: ['dataValidations'],
|
||||||
|
maxRows: core.LIMITS.maxRowsPerSheet,
|
||||||
|
});
|
||||||
|
const nextSheets = new Map();
|
||||||
|
const nextRows = new Map();
|
||||||
|
const nextMerges = new Map();
|
||||||
|
normalizedStyles = [];
|
||||||
|
styleIds = new Map();
|
||||||
|
themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, admission.entries));
|
||||||
|
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);
|
||||||
|
nextMerges.set(metadata.id, sheetResult.merges);
|
||||||
|
sheets.push(metadata);
|
||||||
|
}
|
||||||
|
workbook = nextWorkbook;
|
||||||
|
sheetsById = nextSheets;
|
||||||
|
populatedRowsById = nextRows;
|
||||||
|
mergesById = nextMerges;
|
||||||
|
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();
|
||||||
|
let truncated = false;
|
||||||
|
// Hidden rows and columns are 0 px, so a viewport can span thousands of them
|
||||||
|
// (a filtered sheet); they are never drawn, so never sent.
|
||||||
|
const hiddenColumns = new Map();
|
||||||
|
const columnHidden = (col) => {
|
||||||
|
if (!hiddenColumns.has(col)) hiddenColumns.set(col, Boolean(sheet.getColumn(col).hidden));
|
||||||
|
return hiddenColumns.get(col);
|
||||||
|
};
|
||||||
|
const addCell = (cell) => {
|
||||||
|
const key = `${cell.row}:${cell.col}`;
|
||||||
|
if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return;
|
||||||
|
if (sheet.getRow(cell.row).hidden || columnHidden(cell.col)) return;
|
||||||
|
if (cells.length >= MAX_TILE_CELLS) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenCells.add(key);
|
||||||
|
cells.push({
|
||||||
|
row: cell.row,
|
||||||
|
col: cell.col,
|
||||||
|
text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings),
|
||||||
|
styleId: normalizeStyle(cell),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const populatedRows = populatedRowsById.get(String(message.sheetId)) || [];
|
||||||
|
for (const populated of populatedRows) {
|
||||||
|
if (truncated) break;
|
||||||
|
if (populated.number < range.r1) continue;
|
||||||
|
if (populated.number > range.r2) break;
|
||||||
|
if (populated.row.hidden) continue;
|
||||||
|
for (const cell of populated.cells) {
|
||||||
|
if (cell.col < range.c1) continue;
|
||||||
|
if (cell.col > range.c2) break;
|
||||||
|
addCell(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merges = core.intersectingMerges(mergesById.get(String(message.sheetId)) || [], range);
|
||||||
|
for (const merge of merges) {
|
||||||
|
const anchor = core.parseRange(merge);
|
||||||
|
if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1));
|
||||||
|
}
|
||||||
|
if (truncated) warnings.add(`View truncated to the first ${MAX_TILE_CELLS} cells`);
|
||||||
|
self.postMessage({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: message.requestId,
|
||||||
|
sheetId: String(message.sheetId),
|
||||||
|
cells,
|
||||||
|
merges,
|
||||||
|
// One counted entry for many unsupported number formats keeps the notice bar short.
|
||||||
|
warnings: core.foldWarnings(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();
|
||||||
|
mergesById = new Map();
|
||||||
|
themePalette = core.DEFAULT_THEME_PALETTE;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
postError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.postMessage({ type: 'ready' });
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* @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 = '911680fac09d';
|
||||||
|
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 latestRange = null;
|
||||||
|
let latestAxes = 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix sums per override list, built once per sheet's axis: renderTile
|
||||||
|
// asks for several offsets per cell, so a linear walk over every override
|
||||||
|
// (one per row on a sheet with explicit heights) made each tile O(n) per cell.
|
||||||
|
const axisDeltas = new WeakMap();
|
||||||
|
|
||||||
|
function overrideDeltas(overrides, defaultSize) {
|
||||||
|
let entry = axisDeltas.get(overrides);
|
||||||
|
if (!entry || entry.defaultSize !== defaultSize) {
|
||||||
|
const deltas = [];
|
||||||
|
let delta = 0;
|
||||||
|
for (const [, size] of overrides) {
|
||||||
|
delta += size - defaultSize;
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
entry = { defaultSize, deltas };
|
||||||
|
axisDeltas.set(overrides, entry);
|
||||||
|
}
|
||||||
|
return entry.deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function axisOffset(count, defaultSize, overrides, index) {
|
||||||
|
const bounded = Math.max(1, Math.min(count + 1, index));
|
||||||
|
const list = overrides || [];
|
||||||
|
let low = 0;
|
||||||
|
let high = list.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (list[mid][0] < bounded) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
return (bounded - 1) * defaultSize + (low ? overrideDeltas(list, defaultSize)[low - 1] : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX the spacer is shorter than the sheet, so only the
|
||||||
|
// scroll POSITION is scaled (the scroll range maps onto the sheet's whole
|
||||||
|
// range, so the last row stays reachable) and the tile is laid out at real
|
||||||
|
// sizes from there. `shift` is the logical offset minus the scroll offset,
|
||||||
|
// 0 when the sheet fits; `end` is the bottom (or right) of the spacer.
|
||||||
|
function scrollAxis(logical, scroll, viewport, heading) {
|
||||||
|
const shown = Math.min(MAX_SCROLL_PX, logical);
|
||||||
|
const scrollRange = Math.max(0, heading + shown - viewport);
|
||||||
|
const logicalRange = Math.max(0, heading + logical - viewport);
|
||||||
|
const virtual =
|
||||||
|
logical > shown && scrollRange > 0 ? Math.min(logicalRange, (scroll / scrollRange) * logicalRange) : scroll;
|
||||||
|
return { virtual, shift: virtual - scroll, end: heading + shown };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTile() {
|
||||||
|
if (!current() || !worker || !grid) return;
|
||||||
|
const sheet = sheetMetadata();
|
||||||
|
if (!sheet || sheet.rows === 0 || sheet.cols === 0) return;
|
||||||
|
const viewHeight = grid.clientHeight || 500;
|
||||||
|
const viewWidth = grid.clientWidth || 800;
|
||||||
|
const y = scrollAxis(
|
||||||
|
axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, sheet.rows + 1),
|
||||||
|
grid.scrollTop,
|
||||||
|
viewHeight,
|
||||||
|
COLUMN_HEADING_HEIGHT
|
||||||
|
);
|
||||||
|
const x = scrollAxis(
|
||||||
|
axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, sheet.cols + 1),
|
||||||
|
grid.scrollLeft,
|
||||||
|
viewWidth,
|
||||||
|
ROW_HEADING_WIDTH
|
||||||
|
);
|
||||||
|
const r1 = Math.max(
|
||||||
|
1,
|
||||||
|
axisIndex(
|
||||||
|
sheet.rows,
|
||||||
|
sheet.defaultRowHeight,
|
||||||
|
sheet.rowOverrides,
|
||||||
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT)
|
||||||
|
) - 2
|
||||||
|
);
|
||||||
|
const c1 = Math.max(
|
||||||
|
1,
|
||||||
|
axisIndex(
|
||||||
|
sheet.cols,
|
||||||
|
sheet.defaultColumnWidth,
|
||||||
|
sheet.columnOverrides,
|
||||||
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH)
|
||||||
|
) - 2
|
||||||
|
);
|
||||||
|
const r2 = Math.min(
|
||||||
|
sheet.rows,
|
||||||
|
axisIndex(
|
||||||
|
sheet.rows,
|
||||||
|
sheet.defaultRowHeight,
|
||||||
|
sheet.rowOverrides,
|
||||||
|
Math.max(0, y.virtual - COLUMN_HEADING_HEIGHT + viewHeight)
|
||||||
|
) + 2
|
||||||
|
);
|
||||||
|
const c2 = Math.min(
|
||||||
|
sheet.cols,
|
||||||
|
axisIndex(
|
||||||
|
sheet.cols,
|
||||||
|
sheet.defaultColumnWidth,
|
||||||
|
sheet.columnOverrides,
|
||||||
|
Math.max(0, x.virtual - ROW_HEADING_WIDTH + viewWidth)
|
||||||
|
) + 2
|
||||||
|
);
|
||||||
|
latestRequestId += 1;
|
||||||
|
latestRange = { r1, c1, r2, c2 };
|
||||||
|
latestAxes = { y, x };
|
||||||
|
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 || !latestAxes) return;
|
||||||
|
const { y, x } = latestAxes;
|
||||||
|
// Sizes are real; a span (a tall merge) is clipped at the spacer's edge so
|
||||||
|
// it never grows the scroll area.
|
||||||
|
const rowTop = (row) =>
|
||||||
|
COLUMN_HEADING_HEIGHT + axisOffset(sheet.rows, sheet.defaultRowHeight, sheet.rowOverrides, row) - y.shift;
|
||||||
|
const colLeft = (col) =>
|
||||||
|
ROW_HEADING_WIDTH + axisOffset(sheet.cols, sheet.defaultColumnWidth, sheet.columnOverrides, col) - x.shift;
|
||||||
|
const rowSpan = (from, to) => Math.max(0, Math.min(rowTop(to + 1), y.end) - rowTop(from));
|
||||||
|
const colSpan = (from, to) => Math.max(0, Math.min(colLeft(to + 1), x.end) - colLeft(from));
|
||||||
|
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 = `${rowTop(cell.row)}px`;
|
||||||
|
element.style.left = `${colLeft(cell.col)}px`;
|
||||||
|
const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`);
|
||||||
|
element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`;
|
||||||
|
element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`;
|
||||||
|
cellsLayer.appendChild(element);
|
||||||
|
}
|
||||||
|
// Headings take their size from the same axis math as the cells, so custom
|
||||||
|
// widths/heights line up; hidden (0 px) rows and columns get no heading and
|
||||||
|
// do not count against the heading caps.
|
||||||
|
let rowHeadings = 0;
|
||||||
|
for (let row = latestRange.r1; row <= latestRange.r2 && rowHeadings < 200; row += 1) {
|
||||||
|
const height = rowSpan(row, row);
|
||||||
|
if (height <= 0) continue;
|
||||||
|
rowHeadings += 1;
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'spreadsheet-row-heading';
|
||||||
|
heading.textContent = String(row);
|
||||||
|
heading.style.top = `${rowTop(row)}px`;
|
||||||
|
heading.style.height = `${height}px`;
|
||||||
|
heading.style.left = `${grid.scrollLeft}px`;
|
||||||
|
headingsLayer.appendChild(heading);
|
||||||
|
}
|
||||||
|
let columnHeadings = 0;
|
||||||
|
for (let col = latestRange.c1; col <= latestRange.c2 && columnHeadings < 100; col += 1) {
|
||||||
|
const width = colSpan(col, col);
|
||||||
|
if (width <= 0) continue;
|
||||||
|
columnHeadings += 1;
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'spreadsheet-column-heading';
|
||||||
|
let label = '';
|
||||||
|
for (let value = col; value > 0; value = Math.floor((value - 1) / 26))
|
||||||
|
label = String.fromCharCode(65 + ((value - 1) % 26)) + label;
|
||||||
|
heading.textContent = label;
|
||||||
|
heading.style.left = `${colLeft(col)}px`;
|
||||||
|
heading.style.width = `${width}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;
|
||||||
|
latestAxes = 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);
|
||||||
|
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);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20188,6 +20188,138 @@ body.tile-grid-resizing--row * {
|
|||||||
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;
|
||||||
|
/* A long warning list scrolls inside the bar instead of pushing the grid out of view. */
|
||||||
|
max-height: 4.5em;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only the fixed gutter dimension lives here; the per-row height and per-column
|
||||||
|
width come inline from spreadsheet-preview.js's axis math. */
|
||||||
|
.spreadsheet-row-heading {
|
||||||
|
left: 0;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spreadsheet-column-heading {
|
||||||
|
top: 0;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
/* ── Git status: bottom-bar indicator + panel (git-status-ui.js) ─────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -2217,7 +2253,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;
|
||||||
@@ -2237,6 +2273,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
|
||||||
@@ -149,6 +160,10 @@ describe('dependency security policy', () => {
|
|||||||
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
||||||
|
// GHSA-px8p-9vwx-vf98 (unbounded loop on a ZIP64 marker in a local header)
|
||||||
|
// covers <=0.8.2. The XLSX preview worker streams untrusted files through
|
||||||
|
// fflate's Unzip before any admission callback runs.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, 'fflate', '0.8.3');
|
||||||
expectNoVulnerableBraceExpansion(lock);
|
expectNoVulnerableBraceExpansion(lock);
|
||||||
expectNoVulnerableVite(lock);
|
expectNoVulnerableVite(lock);
|
||||||
expectNoVulnerablePicomatch(lock);
|
expectNoVulnerablePicomatch(lock);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -707,12 +707,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);
|
||||||
@@ -721,6 +721,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);
|
||||||
@@ -893,6 +917,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,96 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
import { DOCUMENT_ATTACHMENT_EXTENSIONS, isSupportedAttachmentExtension } from '../src/attachment-registry.js';
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, '..');
|
||||||
|
const read = (path: string) => readFileSync(resolve(root, path), 'utf8');
|
||||||
|
|
||||||
|
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.3');
|
||||||
|
// 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}'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every accepted document type in the attachments panel help', () => {
|
||||||
|
expect(DOCUMENT_ATTACHMENT_EXTENSIONS).toContain('xlsx');
|
||||||
|
const help = /<div>Supports ([^<]+)<\/div>/.exec(read('src/web/public/panels-ui.js'))?.[1] || '';
|
||||||
|
for (const extension of DOCUMENT_ATTACHMENT_EXTENSIONS) {
|
||||||
|
expect(isSupportedAttachmentExtension(extension)).toBe(true);
|
||||||
|
expect(help).toContain(`.${extension}`);
|
||||||
|
}
|
||||||
|
// The CLI's refusal text is built from the same list rather than restating it.
|
||||||
|
expect(read('src/cli.ts')).toContain("DOCUMENT_ATTACHMENT_EXTENSIONS.join(', ')");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
|||||||
|
/** @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.getColumn(2).width = 18;
|
||||||
|
summary.getRow(2).height = 30;
|
||||||
|
summary.mergeCells('A3:C3');
|
||||||
|
summary.getCell('A3').value = 'Merged cells';
|
||||||
|
summary.getCell('A100').value = 'Far row';
|
||||||
|
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);
|
||||||
|
// Headings line up with a custom-width column and a custom-height row.
|
||||||
|
const headingFit = await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
const rect = (selector: string, text: string) => {
|
||||||
|
const element = [...grid.querySelectorAll(selector)].find((node) => node.textContent === text);
|
||||||
|
if (!element) throw new Error(`Missing ${selector} ${text}`);
|
||||||
|
return element.getBoundingClientRect();
|
||||||
|
};
|
||||||
|
const cell = rect('.spreadsheet-cell', '42');
|
||||||
|
const column = rect('.spreadsheet-column-heading', 'B');
|
||||||
|
const row = rect('.spreadsheet-row-heading', '2');
|
||||||
|
return {
|
||||||
|
cell: [cell.left, cell.width, cell.top, cell.height],
|
||||||
|
heading: [column.left, column.width, row.top, row.height],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(headingFit.cell[1]).toBe(126);
|
||||||
|
expect(headingFit.cell[3]).toBe(40);
|
||||||
|
expect(headingFit.heading).toEqual(headingFit.cell);
|
||||||
|
await page.locator('.spreadsheet-grid').evaluate((grid) => {
|
||||||
|
grid.scrollTop = 400;
|
||||||
|
grid.scrollLeft = 400;
|
||||||
|
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,480 @@
|
|||||||
|
/**
|
||||||
|
* @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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The bar sits above the grid in a flex column; unbounded, enough warnings
|
||||||
|
// pushed the grid out of view.
|
||||||
|
it('clamps the notice bar height and scrolls its overflow', () => {
|
||||||
|
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
|
||||||
|
const rule = /\.spreadsheet-preview-notice\s*\{([^}]*)\}/.exec(css)?.[1] || '';
|
||||||
|
expect(rule).toMatch(/max-height:\s*\d/);
|
||||||
|
expect(rule).toMatch(/overflow-y:\s*auto/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('sizes row and column headings from the same axis math as the cells', async () => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }));
|
||||||
|
const renderer = loadRenderer(fetchMock);
|
||||||
|
renderer.open({ container: document.querySelector('#preview'), url: '/book.xlsx', size: 8 });
|
||||||
|
const worker = WorkerMock.instances[0];
|
||||||
|
worker.emit({ type: 'ready' });
|
||||||
|
await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalled());
|
||||||
|
// Column B 18 wide and row 2 30pt tall, as the worker reports the fixture in
|
||||||
|
// spreadsheet-preview-worker.test.ts; row 3 and column C hidden.
|
||||||
|
worker.emit({
|
||||||
|
type: 'metadata',
|
||||||
|
styles: [],
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Summary',
|
||||||
|
rows: 4,
|
||||||
|
cols: 4,
|
||||||
|
defaultRowHeight: 20,
|
||||||
|
defaultColumnWidth: 64,
|
||||||
|
rowOverrides: [
|
||||||
|
[2, 40],
|
||||||
|
[3, 0],
|
||||||
|
],
|
||||||
|
columnOverrides: [
|
||||||
|
[2, 126],
|
||||||
|
[3, 0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 2, col: 2, text: 'B2', styleId: 0 }],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
const cell = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||||
|
const heading = (selector: string, text: string) =>
|
||||||
|
[...document.querySelectorAll(selector)].find((element) => element.textContent === text) as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
const columnB = heading('.spreadsheet-column-heading', 'B');
|
||||||
|
const row2 = heading('.spreadsheet-row-heading', '2');
|
||||||
|
expect(columnB?.style.width).toBe(cell.style.width);
|
||||||
|
expect(columnB?.style.left).toBe(cell.style.left);
|
||||||
|
expect(row2?.style.height).toBe(cell.style.height);
|
||||||
|
expect(row2?.style.top).toBe(cell.style.top);
|
||||||
|
expect(heading('.spreadsheet-column-heading', 'A')?.style.width).toBe('64px');
|
||||||
|
expect(heading('.spreadsheet-row-heading', '1')?.style.height).toBe('20px');
|
||||||
|
// A hidden row or column has no size, so it gets no heading at all.
|
||||||
|
expect(heading('.spreadsheet-column-heading', 'C')).toBeUndefined();
|
||||||
|
expect(heading('.spreadsheet-row-heading', '3')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Past MAX_SCROLL_PX only the scroll position may be scaled: dividing every
|
||||||
|
// cell and heading by the scale drew a 1,048,576-row sheet 7.6 px a row.
|
||||||
|
it('lays rows out at their real height on a sheet taller than the scroll cap', 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());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const cellAt = (row: number) => document.querySelector(`.spreadsheet-cell[data-row="${row}"]`) as HTMLElement;
|
||||||
|
const rowHeading = (row: number) =>
|
||||||
|
[...document.querySelectorAll('.spreadsheet-row-heading')].find(
|
||||||
|
(element) => element.textContent === String(row)
|
||||||
|
) as HTMLElement;
|
||||||
|
const px = (value: string) => Number.parseFloat(value);
|
||||||
|
|
||||||
|
const first = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(first.range.r1).toBe(1);
|
||||||
|
const cell = (row: number) => ({ row, col: 1, text: `A${row}`, styleId: 0 });
|
||||||
|
worker.emit({ type: 'tile', requestId: first.requestId, sheetId: '1', cells: [cell(1), cell(2)], warnings: [] });
|
||||||
|
expect(cellAt(1).style.top).toBe('20px');
|
||||||
|
expect(cellAt(1).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(2).style.top) - px(cellAt(1).style.top)).toBe(20);
|
||||||
|
expect(rowHeading(2).style.height).toBe('20px');
|
||||||
|
// At scale 1 the first tile asks for about a viewport of rows, not a scaled one.
|
||||||
|
expect(first.range.r2).toBeLessThan(40);
|
||||||
|
|
||||||
|
// Scrolled to the end, the last row is requested, drawn at its real height,
|
||||||
|
// and ends exactly at the bottom of the scroll area.
|
||||||
|
const grid = document.querySelector('.spreadsheet-grid') as HTMLElement;
|
||||||
|
const spacerHeight = px((document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height);
|
||||||
|
grid.scrollTop = spacerHeight - 500;
|
||||||
|
grid.dispatchEvent(new window.Event('scroll'));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(worker.postMessage.mock.calls.at(-1)?.[0].requestId).toBeGreaterThan(first.requestId)
|
||||||
|
);
|
||||||
|
const last = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
expect(last.range.r2).toBe(1_048_576);
|
||||||
|
expect(last.range.r2 - last.range.r1).toBeLessThan(40);
|
||||||
|
worker.emit({ type: 'tile', requestId: last.requestId, sheetId: '1', cells: [cell(1_048_576)], warnings: [] });
|
||||||
|
expect(cellAt(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(rowHeading(1_048_576).style.height).toBe('20px');
|
||||||
|
expect(px(cellAt(1_048_576).style.top) + 20).toBeCloseTo(spacerHeight, 6);
|
||||||
|
expect(px(rowHeading(1_048_575).style.top)).toBeCloseTo(px(cellAt(1_048_576).style.top) - 20, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a merge spanning a too-tall sheet inside the scroll area', 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());
|
||||||
|
const sheet = { id: '1', name: 'Tall', rows: 1_048_576, cols: 1, defaultRowHeight: 20, defaultColumnWidth: 64 };
|
||||||
|
worker.emit({ type: 'metadata', styles: [], sheets: [{ ...sheet, rowOverrides: [], columnOverrides: [] }] });
|
||||||
|
const request = worker.postMessage.mock.calls.at(-1)?.[0];
|
||||||
|
worker.emit({
|
||||||
|
type: 'tile',
|
||||||
|
requestId: request.requestId,
|
||||||
|
sheetId: '1',
|
||||||
|
cells: [{ row: 1, col: 1, text: 'whole column', styleId: 0 }],
|
||||||
|
merges: ['A1:A1048576'],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
const merged = document.querySelector('.spreadsheet-cell') as HTMLElement;
|
||||||
|
const spacerHeight = Number.parseFloat(
|
||||||
|
(document.querySelector('.spreadsheet-grid-spacer') as HTMLElement).style.height
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(merged.style.top) + Number.parseFloat(merged.style.height)).toBeLessThanOrEqual(
|
||||||
|
spacerHeight
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,805 @@
|
|||||||
|
/** @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;
|
||||||
|
createXmlCounter(
|
||||||
|
name: string,
|
||||||
|
counts: { cells: number; merges: number; styles: number },
|
||||||
|
limits: Record<string, number>
|
||||||
|
): { push(chunk: Uint8Array, final: boolean): void };
|
||||||
|
buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array;
|
||||||
|
excelJsEntryName(name: string): string;
|
||||||
|
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 };
|
||||||
|
foldWarnings(warnings: string[]): 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[];
|
||||||
|
};
|
||||||
|
// The 2x2 merge costs its four covered cells on top of the one real cell.
|
||||||
|
// `elements` is every start tag across all six parts: 1 + 3 + 3 + 4 + 1 + 1.
|
||||||
|
expect(result.counts).toEqual({ worksheets: 1, cells: 5, rows: 0, merges: 1, styles: 1, elements: 13 });
|
||||||
|
expect(result.features).toEqual(expect.arrayContaining(['charts', 'externalLinks']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds a STORE-only archive from exactly the entries admission inflated', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
const admission = core.admitXlsx(zip, fflate) as { entries: Record<string, Uint8Array>; inflatedBytes: number };
|
||||||
|
expect(Object.keys(admission.entries).sort()).toEqual(Object.keys(fflate.unzipSync(zip)).sort());
|
||||||
|
const rebuilt = core.buildAdmittedArchive(admission, fflate);
|
||||||
|
const directory = core.inspectZipDirectory(rebuilt).entries as Array<{
|
||||||
|
name: string;
|
||||||
|
compressedSize: number;
|
||||||
|
declaredSize: number;
|
||||||
|
}>;
|
||||||
|
for (const entry of directory) expect(entry.compressedSize).toBe(entry.declaredSize);
|
||||||
|
const roundTrip = fflate.unzipSync(rebuilt);
|
||||||
|
for (const [name, data] of Object.entries(admission.entries)) expect(roundTrip[name]).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a local entry name streamed twice, since the rebuilt archive could hold only one', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
class DuplicateUnzip {
|
||||||
|
constructor(private readonly onFile: (file: any) => void) {}
|
||||||
|
register() {}
|
||||||
|
push(_bytes: Uint8Array, final: boolean) {
|
||||||
|
if (!final) return;
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
const file: Record<string, any> = {
|
||||||
|
name: 'xl/workbook.xml',
|
||||||
|
start: () => file.ondata(null, new Uint8Array(1), true),
|
||||||
|
};
|
||||||
|
this.onFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duplicate = { Unzip: DuplicateUnzip, UnzipInflate: class {} } as unknown as typeof fflate;
|
||||||
|
expect(() => core.admitXlsx(zip, duplicate)).toThrowError(/duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every entry the way JSZip and ExcelJS will see it', () => {
|
||||||
|
expect(core.excelJsEntryName('xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('/xl/worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/./worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl//worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/foo/../worksheets/sheet1.xml')).toBe('xl/worksheets/sheet1.xml');
|
||||||
|
expect(core.excelJsEntryName('../xl/styles.xml')).toBe('xl/styles.xml');
|
||||||
|
expect(core.excelJsEntryName('//xl/styles.xml')).toBe('xl/styles.xml');
|
||||||
|
expect(core.excelJsEntryName('xl/media/')).toBe('xl/media/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses two entries that resolve to the same name', () => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip());
|
||||||
|
const big = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||||
|
for (const twin of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'xl/x/../worksheets/sheet1.xml']) {
|
||||||
|
const zip = fflate.zipSync({ ...entries, [twin]: fflate.strToU8(big) });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate), twin).toThrowError(/same name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts and rebuilds entries under the names ExcelJS will see', () => {
|
||||||
|
const cells = '<worksheet><sheetData>' + '<c r="A1"/>'.repeat(4) + '</sheetData></worksheet>';
|
||||||
|
const renamed = (name: string) => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip(cells));
|
||||||
|
const sheet = entries['xl/worksheets/sheet1.xml'];
|
||||||
|
delete entries['xl/worksheets/sheet1.xml'];
|
||||||
|
return fflate.zipSync({ ...entries, [name]: sheet });
|
||||||
|
};
|
||||||
|
for (const name of ['/xl/worksheets/sheet1.xml', 'xl/./worksheets/sheet1.xml', 'a/xl/worksheets/sheet2.xml.x']) {
|
||||||
|
expect(() => core.admitXlsx(renamed(name), fflate, { maxCellsPerSheet: 3 }), name).toThrowError(/cells/i);
|
||||||
|
expect(() => core.admitXlsx(renamed(name), fflate, { maxWorksheets: 0 }), name).toThrowError(/worksheet/i);
|
||||||
|
}
|
||||||
|
const admission = core.admitXlsx(renamed('/xl/./worksheets/sheet1.xml'), fflate) as {
|
||||||
|
entries: Record<string, Uint8Array>;
|
||||||
|
};
|
||||||
|
expect(Object.keys(admission.entries)).toContain('xl/worksheets/sheet1.xml');
|
||||||
|
expect(Object.keys(fflate.unzipSync(core.buildAdmittedArchive(admission, fflate)))).toContain(
|
||||||
|
'xl/worksheets/sheet1.xml'
|
||||||
|
);
|
||||||
|
|
||||||
|
// `/xl/styles.xml` is ExcelJS's `xl/styles.xml`, so its <xf> tags count.
|
||||||
|
const styles = fflate.unzipSync(workbookZip());
|
||||||
|
const xf = styles['xl/styles.xml'];
|
||||||
|
delete styles['xl/styles.xml'];
|
||||||
|
const leading = fflate.zipSync({ ...styles, '/xl/styles.xml': xf });
|
||||||
|
expect(() => core.admitXlsx(leading, fflate, { maxStyles: 0 })).toThrowError(/styles limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges a merged range its full area and refuses one that does not parse', () => {
|
||||||
|
const merged = (ref: string) =>
|
||||||
|
workbookZip(
|
||||||
|
`<worksheet><sheetData><row><c r="A1"/></row></sheetData><mergeCells><mergeCell ref="${ref}"/></mergeCells></worksheet>`
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(merged('A1:CV30000'), fflate)).toThrowError(/cells limit/i);
|
||||||
|
// Reversed corners describe the same area.
|
||||||
|
expect(() => core.admitXlsx(merged('CV30000:A1'), fflate)).toThrowError(/cells limit/i);
|
||||||
|
expect(() => core.admitXlsx(merged('A1:XFE2'), fflate)).toThrowError(/merged range/i);
|
||||||
|
expect(() => core.admitXlsx(merged('not-a-range'), fflate)).toThrowError(/merged range/i);
|
||||||
|
const admitted = core.admitXlsx(merged('A1:J10'), fflate) as { counts: { cells: number } };
|
||||||
|
expect(admitted.counts.cells).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a <col> whose min or max is past the last Excel column', () => {
|
||||||
|
const cols = (attrs: string) =>
|
||||||
|
workbookZip(
|
||||||
|
`<worksheet><cols><col ${attrs} width="9"/></cols><sheetData><row><c r="A1"/></row></sheetData></worksheet>`
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="3000000"'), fflate)).toThrowError(/column max/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="16385" max="16385"'), fflate)).toThrowError(/column min/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="1e9"'), fflate)).toThrowError(/column max/i);
|
||||||
|
expect(() => core.admitXlsx(cols('min="1" max="16384"'), fflate)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a merge or col tag whole even when a stream chunk boundary cuts through it', () => {
|
||||||
|
// Markup on both sides, so a cut can land 128+ bytes past the tag's start.
|
||||||
|
const pad = '<sheetView workbookViewId="0"/>'.repeat(12);
|
||||||
|
const cases: Array<[string, RegExp]> = [
|
||||||
|
[`<worksheet>${pad}<cols><col min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||||
|
[`<worksheet>${pad}<cols><col x=">" min="1" max="99999"/></cols>${pad}</worksheet>`, /column max/i],
|
||||||
|
[`<worksheet>${pad}<mergeCells><mergeCell ref="A1:CV30000"/></mergeCells>${pad}</worksheet>`, /cells limit/i],
|
||||||
|
];
|
||||||
|
for (const [xml, pattern] of cases) {
|
||||||
|
const bytes = fflate.strToU8(xml);
|
||||||
|
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||||
|
const counter = core.createXmlCounter(
|
||||||
|
'xl/worksheets/sheet1.xml',
|
||||||
|
{ cells: 0, merges: 0, styles: 0 },
|
||||||
|
core.LIMITS
|
||||||
|
);
|
||||||
|
expect(() => {
|
||||||
|
counter.push(bytes.subarray(0, cut), false);
|
||||||
|
counter.push(bytes.subarray(cut), true);
|
||||||
|
}, `cut at ${cut}`).toThrowError(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads attributes in order, so a quoted value cannot hide or fake one', () => {
|
||||||
|
const counter = () =>
|
||||||
|
core.createXmlCounter(
|
||||||
|
'xl/worksheets/sheet1.xml',
|
||||||
|
{ cells: 0, merges: 0, styles: 0, rows: 0 } as never,
|
||||||
|
core.LIMITS
|
||||||
|
);
|
||||||
|
const push = (xml: string) => counter().push(fflate.strToU8(xml), true);
|
||||||
|
// A raw `>` or the other quote character is legal inside a value.
|
||||||
|
expect(() => push(`<mergeCell x=' ref="A1"' ref="A1:CV30000"/>`)).toThrowError(/cells limit/i);
|
||||||
|
expect(() => push('<col x=">" min="1" max="3000000"/>')).toThrowError(/column max/i);
|
||||||
|
expect(() => push(`<col x=' max="1"' min="1" max="3000000"/>`)).toThrowError(/column max/i);
|
||||||
|
// Anything the walk cannot read up to `>` is refused, as is a repeated name.
|
||||||
|
expect(() => push('<mergeCell ref="A1:B2" junk/>')).toThrowError(/do not parse/i);
|
||||||
|
expect(() => push('<mergeCell ref="A1" ref="A1:CV30000"/>')).toThrowError(/do not parse/i);
|
||||||
|
expect(() => push('<cols><col min="1" max="3" width="9"></col></cols><mergeCell ref="A1:B2" />')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts every <row>, empty or not, against per-sheet and total caps', () => {
|
||||||
|
const rows = (n: number) => '<worksheet><sheetData>' + '<row r="1"/>'.repeat(n) + '</sheetData></worksheet>';
|
||||||
|
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRowsPerSheet: 3 })).toThrowError(/rows limit/i);
|
||||||
|
expect(() => core.admitXlsx(workbookZip(rows(4)), fflate, { maxRows: 3 })).toThrowError(/rows limit/i);
|
||||||
|
const admitted = core.admitXlsx(workbookZip(rows(3)), fflate, { maxRowsPerSheet: 3 }) as {
|
||||||
|
counts: { rows: number };
|
||||||
|
};
|
||||||
|
expect(admitted.counts.rows).toBe(3);
|
||||||
|
// <rowBreaks>/<rows...> style names are not rows.
|
||||||
|
expect(
|
||||||
|
(core.admitXlsx(workbookZip('<worksheet><rowBreaks/></worksheet>'), fflate) as { counts: { rows: number } })
|
||||||
|
.counts.rows
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest
|
||||||
|
// index on every eachRow and `sheet.model`, so the index a row CLAIMS is cost.
|
||||||
|
it('refuses a <row r> that is not plain digits in 1-1048576 and a <row> whose attributes do not parse', () => {
|
||||||
|
const sheet = (rowTag: string) =>
|
||||||
|
workbookZip(`<worksheet><sheetData><row r="1"><c r="A1"/></row>${rowTag}</sheetData></worksheet>`);
|
||||||
|
for (const r of ['50000000', '1048577', '0', '1a', '-1', '1e3', ' 2', '']) {
|
||||||
|
expect(() => core.admitXlsx(sheet(`<row r="${r}"/>`), fflate), r).toThrowError(/row index/i);
|
||||||
|
}
|
||||||
|
expect(() => core.admitXlsx(sheet('<row r="2" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||||
|
// A quoted fake `r` cannot stand in for the real one.
|
||||||
|
expect(() => core.admitXlsx(sheet(`<row x=' r="2"' r="50000000"/>`), fflate)).toThrowError(/row index/i);
|
||||||
|
for (const ok of ['<row r="1048576"/>', '<row/>', '<row spans="1:1"><c r="A2"/></row>']) {
|
||||||
|
expect(() => core.admitXlsx(sheet(ok), fflate), ok).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS stores a sheet at `_worksheets[sheetId]`, and the `worksheets`
|
||||||
|
// getter slices and sorts that array, so a large id allocates a huge array.
|
||||||
|
it('refuses a <sheet sheetId> in xl/workbook.xml that is not plain digits or is above the cap', () => {
|
||||||
|
expect(core.LIMITS.maxSheetId).toBe(65535);
|
||||||
|
const book = (sheets: string, name = 'xl/workbook.xml') => {
|
||||||
|
const entries = fflate.unzipSync(workbookZip());
|
||||||
|
delete entries['xl/workbook.xml'];
|
||||||
|
entries[name] = fflate.strToU8(`<workbook><sheets>${sheets}</sheets></workbook>`);
|
||||||
|
return fflate.zipSync(entries);
|
||||||
|
};
|
||||||
|
const one = (id: string) => `<sheet name="S" sheetId="${id}" r:id="rId1"/>`;
|
||||||
|
for (const id of ['30000000', '65536', '1a', '-1', '1e3', ' 1', '']) {
|
||||||
|
expect(() => core.admitXlsx(book(one(id)), fflate), id).toThrowError(/sheetId/i);
|
||||||
|
}
|
||||||
|
// Every <sheet> is read, not just the first; the name is the one ExcelJS sees.
|
||||||
|
expect(() => core.admitXlsx(book(one('1') + one('30000000')), fflate)).toThrowError(/sheetId/i);
|
||||||
|
expect(() => core.admitXlsx(book(one('30000000'), '/xl/./workbook.xml'), fflate)).toThrowError(/sheetId/i);
|
||||||
|
expect(() => core.admitXlsx(book('<sheet name="S" sheetId="1" junk/>'), fflate)).toThrowError(/do not parse/i);
|
||||||
|
expect(() => core.admitXlsx(book(`<sheet x=' sheetId="1"' sheetId="30000000"/>`), fflate)).toThrowError(/sheetId/i);
|
||||||
|
// `<sheets>` is the container, never a sheet; an absent sheetId is harmless.
|
||||||
|
expect(() => core.admitXlsx(book(one('1') + one('65535') + '<sheet name="S"/>'), fflate)).not.toThrow();
|
||||||
|
// Only the workbook part is read this way.
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const other = core.createXmlCounter('xl/other.xml', counts as never, core.LIMITS);
|
||||||
|
expect(() => other.push(fflate.strToU8(one('30000000')), true)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS builds an object per element in every part it parses, not only
|
||||||
|
// worksheets: each <r> run and <si> in sharedStrings.xml, each <font>, <fill>
|
||||||
|
// and <border> in styles, comments, drawings, VML and tables.
|
||||||
|
it('budgets every start tag outside xl/media, whatever part it sits in', () => {
|
||||||
|
expect(core.LIMITS.maxElements).toBe(2_000_000);
|
||||||
|
const withPart = (name: string, xml: string) =>
|
||||||
|
fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: fflate.strToU8(xml) });
|
||||||
|
const elementsOf = (zip: Uint8Array) =>
|
||||||
|
(core.admitXlsx(zip, fflate) as { counts: { elements: number } }).counts.elements;
|
||||||
|
const base = elementsOf(workbookZip());
|
||||||
|
const runs = '<sst><si>' + '<r/>'.repeat(50) + '</si></sst>';
|
||||||
|
const fonts = '<styleSheet><fonts>' + '<font/>'.repeat(50) + '</fonts></styleSheet>';
|
||||||
|
const cases: Array<[string, string, number]> = [
|
||||||
|
['xl/sharedStrings.xml', runs, 52],
|
||||||
|
['xl/comments1.xml', '<comments>' + '<comment/>'.repeat(50) + '</comments>', 51],
|
||||||
|
['xl/drawings/vmlDrawing1.vml', '<xml>' + '<v:shape/>'.repeat(50) + '</xml>', 51],
|
||||||
|
// The name ExcelJS sees, so `/xl/./` cannot slip a part past the budget.
|
||||||
|
['/xl/./sharedStrings.xml', runs, 52],
|
||||||
|
];
|
||||||
|
for (const [name, xml, added] of cases) {
|
||||||
|
expect(elementsOf(withPart(name, xml)), name).toBe(base + added);
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
core.admitXlsx(withPart(name, xml), fflate, { maxElements: base + added - 1 });
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
expect((thrown as { code?: string })?.code, name).toBe('element-limit');
|
||||||
|
}
|
||||||
|
// styles.xml is counted on top of its own <xf>/<numFmt> checks.
|
||||||
|
const styled = fflate.unzipSync(workbookZip());
|
||||||
|
styled['xl/styles.xml'] = fflate.strToU8(fonts);
|
||||||
|
expect(() => core.admitXlsx(fflate.zipSync(styled), fflate, { maxElements: 50 })).toThrowError(/element/i);
|
||||||
|
// Processing instructions, comments and end tags are not elements; a tag
|
||||||
|
// INSIDE a comment is still counted, which errs toward refusing.
|
||||||
|
expect(elementsOf(withPart('xl/other.xml', '<?xml version="1.0"?><!-- c --><a></a><_b/>'))).toBe(base + 2);
|
||||||
|
expect(elementsOf(withPart('xl/other.xml', '<!-- <a> --><a/>'))).toBe(base + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never budgets an xl/media part, which ExcelJS keeps as bytes', () => {
|
||||||
|
// Stored, so the compression-ratio cap stays out of the way.
|
||||||
|
const tags = fflate.strToU8('<r/>'.repeat(500));
|
||||||
|
for (const name of ['xl/media/image1.png', '/xl/./media/image1.jpeg']) {
|
||||||
|
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).not.toThrow();
|
||||||
|
}
|
||||||
|
// A name under xl/media/ that ExcelJS parses as XML (its patterns are
|
||||||
|
// unanchored) is budgeted like any other part.
|
||||||
|
for (const name of ['xl/media/xl/drawings/drawing1.xml', 'xl/media/xl/worksheets/sheet2.xml']) {
|
||||||
|
const zip = fflate.zipSync({ ...fflate.unzipSync(workbookZip()), [name]: [tags, { level: 0 }] });
|
||||||
|
expect(() => core.admitXlsx(zip, fflate, { maxElements: 100 }), name).toThrowError(/element/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts start tags exactly when a chunk boundary cuts through one', () => {
|
||||||
|
const xml = '<sst><si>' + '<r/><t>x</t>'.repeat(20) + '</si></sst>';
|
||||||
|
const bytes = fflate.strToU8(xml);
|
||||||
|
for (const name of ['xl/sharedStrings.xml', 'xl/worksheets/sheet1.xml', 'xl/styles.xml']) {
|
||||||
|
for (let cut = 1; cut < bytes.length; cut += 1) {
|
||||||
|
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||||
|
const counter = core.createXmlCounter(name, counts as never, core.LIMITS);
|
||||||
|
counter.push(bytes.subarray(0, cut), false);
|
||||||
|
counter.push(bytes.subarray(cut), true);
|
||||||
|
expect(counts.elements, `${name} cut at ${cut}`).toBe(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry long text or binary in a non-worksheet part as an oversized tag', () => {
|
||||||
|
const counts = { worksheets: 0, cells: 0, rows: 0, merges: 0, styles: 0, elements: 0 };
|
||||||
|
const text = fflate.strToU8('<sst><si><t>' + 'x'.repeat(600_000) + '</t></si></sst>');
|
||||||
|
const strings = core.createXmlCounter('xl/sharedStrings.xml', counts as never, core.LIMITS);
|
||||||
|
const binary = core.createXmlCounter('xl/embeddings/oleObject1.bin', counts as never, core.LIMITS);
|
||||||
|
const zeros = new Uint8Array(600_000);
|
||||||
|
expect(() => {
|
||||||
|
for (let at = 0; at < text.length; at += 65_536) {
|
||||||
|
strings.push(text.subarray(at, at + 65_536), at + 65_536 >= text.length);
|
||||||
|
}
|
||||||
|
for (let at = 0; at < zeros.length; at += 65_536) {
|
||||||
|
binary.push(zeros.subarray(at, at + 65_536), at + 65_536 >= zeros.length);
|
||||||
|
}
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(counts.elements).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts every <xf> in styles.xml, so a </cellXfs> inside a comment cannot hide styles', () => {
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, { ...core.LIMITS, maxStyles: 100 });
|
||||||
|
const xml = '<styleSheet><cellXfs><!-- </cellXfs> -->' + '<xf/>'.repeat(200) + '</cellXfs></styleSheet>';
|
||||||
|
expect(() => counter.push(fflate.strToU8(xml), true)).toThrowError(/styles limit/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS's `_mergeCellsInternal` checks every new merge against every earlier
|
||||||
|
// one on its sheet, so a sheet costs the SQUARE of its merge count: one sheet
|
||||||
|
// at the old 5,000 cap took 1.6 s, and 20 such sheets ran past the page timeout.
|
||||||
|
it('caps merges per sheet and across the workbook', () => {
|
||||||
|
expect(core.LIMITS.maxMergesPerSheet).toBe(2000);
|
||||||
|
expect(core.LIMITS.maxMerges).toBe(10000);
|
||||||
|
const merges = (n: number, row = 1) =>
|
||||||
|
Array.from({ length: n }, (_, i) => `<mergeCell ref="A${row + i * 2}:A${row + i * 2 + 1}"/>`).join('');
|
||||||
|
const sheetXml = (n: number) => `<worksheet><sheetData/><mergeCells>${merges(n)}</mergeCells></worksheet>`;
|
||||||
|
expect(() => core.admitXlsx(workbookZip(sheetXml(4)), fflate, { maxMergesPerSheet: 3 })).toThrowError(
|
||||||
|
/merged ranges limit/i
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(workbookZip(sheetXml(3)), fflate, { maxMergesPerSheet: 3 })).not.toThrow();
|
||||||
|
// Two sheets each under the per-sheet cap still trip the workbook-wide one.
|
||||||
|
const twoSheets = fflate.zipSync({
|
||||||
|
...fflate.unzipSync(workbookZip(sheetXml(3))),
|
||||||
|
'xl/worksheets/sheet2.xml': fflate.strToU8(sheetXml(3)),
|
||||||
|
});
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||||
|
/merged ranges limit/i
|
||||||
|
);
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 6 })).not.toThrow();
|
||||||
|
expect(() => core.admitXlsx(twoSheets, fflate, { maxMergesPerSheet: 3, maxMerges: 5 })).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'merge-limit' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ExcelJS runs `isDateFmt` once per numeric cell, and its first step,
|
||||||
|
// `fmt.replace(/\[[^\]]*]/g, '')`, rescans to the end of the code for every
|
||||||
|
// `[` with no later `]`. The code is also echoed into the notice bar.
|
||||||
|
it('refuses a <numFmt> formatCode over 255 characters or with a [ after its last ]', () => {
|
||||||
|
const styles = (numFmts: string) => {
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||||
|
const xml = `<styleSheet><numFmts count="1">${numFmts}</numFmts><cellXfs count="1"><xf/></cellXfs></styleSheet>`;
|
||||||
|
return () => counter.push(fflate.strToU8(xml), true);
|
||||||
|
};
|
||||||
|
const fmt = (code: string) => `<numFmt numFmtId="164" formatCode="${code}"/>`;
|
||||||
|
expect(styles(fmt('0'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||||
|
expect(styles(fmt('0'.repeat(255)))).not.toThrow();
|
||||||
|
for (const code of ['[', '0[', '[Red]0[', '[[[[', '[Red]0.00;[']) {
|
||||||
|
expect(styles(fmt(code)), code).toThrowError(/unclosed bracket/i);
|
||||||
|
}
|
||||||
|
for (const code of ['[Red]0.00', '[$-409]mmm d, yyyy', '[h]:mm:ss', '0.00', '#,##0;[Red]-#,##0', ']', '[[]']) {
|
||||||
|
expect(styles(fmt(code)), code).not.toThrow();
|
||||||
|
}
|
||||||
|
// The code is checked as ExcelJS decodes it: an entity cannot hide a `[`,
|
||||||
|
// and an entity-heavy code is measured by its decoded length.
|
||||||
|
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles(fmt('0['))).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles(fmt('"x"'.repeat(60)))).not.toThrow();
|
||||||
|
expect(styles(fmt('&'.repeat(256)))).toThrowError(/longer than 255/i);
|
||||||
|
// Attributes are read in order, so a quoted fake formatCode cannot mask the real one.
|
||||||
|
expect(styles(`<numFmt x=' formatCode="0"' numFmtId="164" formatCode="0["/>`)).toThrowError(/unclosed bracket/i);
|
||||||
|
expect(styles('<numFmt numFmtId="164" formatCode="0" junk/>')).toThrowError(/do not parse/i);
|
||||||
|
// `<numFmts>` is the container, not a format.
|
||||||
|
expect(styles('')).not.toThrow();
|
||||||
|
// The refusal never echoes the code itself.
|
||||||
|
for (const code of ['QZJX[', `${'QZJX'.repeat(64)}0`]) {
|
||||||
|
expect(styles(fmt(code))).toThrowError(
|
||||||
|
expect.objectContaining({ code: 'number-format', message: expect.not.stringContaining('QZJX') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every <numFmt> in the file is read, wherever it sits (dxfs carry them too).
|
||||||
|
const counts = { cells: 0, merges: 0, styles: 0, rows: 0 };
|
||||||
|
const counter = core.createXmlCounter('xl/styles.xml', counts as never, core.LIMITS);
|
||||||
|
const dxf = `<styleSheet><dxfs count="1"><dxf>${fmt('0[')}</dxf></dxfs></styleSheet>`;
|
||||||
|
expect(() => counter.push(fflate.strToU8(dxf), true)).toThrowError(/unclosed bracket/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps cell display text at 1,000 characters for every value shape', () => {
|
||||||
|
const long = 'x'.repeat(50_000);
|
||||||
|
const shapes: Array<[string, unknown]> = [
|
||||||
|
['string', long],
|
||||||
|
['rich text', { richText: [{ text: long }, { text: long }] }],
|
||||||
|
['hyperlink', { text: long, hyperlink: 'https://example.invalid/' }],
|
||||||
|
['rich hyperlink', { text: { richText: [{ text: long }] }, hyperlink: 'https://example.invalid/' }],
|
||||||
|
['formula source', { formula: long }],
|
||||||
|
['formula result', { formula: 'A1', result: long }],
|
||||||
|
['error', { error: long }],
|
||||||
|
];
|
||||||
|
for (const [label, value] of shapes) {
|
||||||
|
const { text } = core.formatCellValue(value, 'General');
|
||||||
|
expect(text.length, label).toBeLessThanOrEqual(1000);
|
||||||
|
expect(text.endsWith('…'), label).toBe(true);
|
||||||
|
}
|
||||||
|
expect(core.formatCellValue('y'.repeat(1000), 'General').text).toBe('y'.repeat(1000));
|
||||||
|
expect(core.formatCellValue({ richText: [{ text: 'a' }, { text: 'b' }] }, 'General').text).toBe('ab');
|
||||||
|
// A cut never leaves half of a surrogate pair.
|
||||||
|
// 'aa' puts a high surrogate at index 998, exactly where a naive cut lands.
|
||||||
|
const emoji = core.formatCellValue('aa' + '😀'.repeat(600), 'General').text;
|
||||||
|
expect(emoji.length).toBeLessThanOrEqual(1000);
|
||||||
|
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(emoji)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An empty run adds no text, so stopping on the text length alone still
|
||||||
|
// visited every run, once per cell referencing the shared string, per tile.
|
||||||
|
it('visits at most maxCellTextChars + 1 rich-text runs, however many are empty', () => {
|
||||||
|
const bound = core.LIMITS.maxCellTextChars + 1;
|
||||||
|
const runs: Array<{ text: string }> = Array.from({ length: 5_000 }, () => ({ text: '' }));
|
||||||
|
runs[0] = { text: 'head' };
|
||||||
|
for (let index = bound; index < runs.length; index += 1) {
|
||||||
|
Object.defineProperty(runs, index, {
|
||||||
|
get() {
|
||||||
|
throw new Error(`rich-text run ${index} visited`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(core.formatCellValue({ richText: runs }, 'General')).toEqual({ text: 'head' });
|
||||||
|
// A run inside the bound still contributes.
|
||||||
|
runs[bound - 1] = { text: 'tail' };
|
||||||
|
expect(core.formatCellValue({ richText: runs }, 'General').text).toBe('headtail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an entry whose declared compressed size runs past the file', () => {
|
||||||
|
const zip = workbookZip();
|
||||||
|
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
||||||
|
const eocd = zip.length - 22;
|
||||||
|
const centralStart = view.getUint32(eocd + 16, true);
|
||||||
|
view.setUint32(centralStart + 20, 0x7fffffff, true);
|
||||||
|
expect(() => core.inspectZipDirectory(zip)).toThrowError(/compressed bytes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives bounded extents from real cells and merges', () => {
|
||||||
|
expect(core.parseCellRef('XFD1048576')).toEqual({ row: 1_048_576, col: 16_384 });
|
||||||
|
expect(core.parseCellRef('XFE1')).toBeNull();
|
||||||
|
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('matches a plain walk over the overrides at every index', () => {
|
||||||
|
const overrides: Array<[number, number]> = [];
|
||||||
|
for (let index = 3; index <= 400; index += 7) overrides.push([index, (index * 13) % 50]);
|
||||||
|
const axis = core.createSparseAxis(500, 20, overrides);
|
||||||
|
for (let index = 0; index <= 502; index += 1) {
|
||||||
|
const bounded = Math.max(1, Math.min(501, index));
|
||||||
|
let expected = (bounded - 1) * 20;
|
||||||
|
for (const [at, size] of overrides) if (at < bounded) expected += size - 20;
|
||||||
|
expect(core.axisOffset(axis, index), `index ${index}`).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Excel displays at most 15 significant digits; String() printed the binary noise.
|
||||||
|
it('shows General and unsupported-format numbers at 15 significant digits, as Excel does', () => {
|
||||||
|
expect(core.formatCellValue(0.1 + 0.2, 'General').text).toBe('0.3');
|
||||||
|
expect(core.formatCellValue(10.1 * 3, 'General').text).toBe('30.3');
|
||||||
|
expect(core.formatCellValue({ formula: '0.1+0.2', result: 0.1 + 0.2 }, 'General').text).toBe('0.3');
|
||||||
|
expect(core.formatCellValue(0.1 + 0.2, '0.00E+00')).toEqual({
|
||||||
|
text: '0.3',
|
||||||
|
warning: 'Unsupported number format: 0.00E+00',
|
||||||
|
});
|
||||||
|
expect(core.formatCellValue(1234.5, 'General').text).toBe('1234.5');
|
||||||
|
expect(core.formatCellValue(-42, 'General').text).toBe('-42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a time-only AM/PM format as a 12-hour time, not a date', () => {
|
||||||
|
expect(core.formatCellValue(14.5 / 24, 'h:mm AM/PM')).toEqual({ text: '2:30 PM' });
|
||||||
|
expect(core.formatCellValue(0, 'h:mm AM/PM').text).toBe('12:00 AM');
|
||||||
|
expect(core.formatCellValue(0.5, 'h:mm AM/PM').text).toBe('12:00 PM');
|
||||||
|
expect(core.formatCellValue(9.25 / 24, 'hh:mm:ss AM/PM').text).toBe('09:15:00 AM');
|
||||||
|
// ExcelJS loads a time-formatted cell as a Date on the 1899-12-31 epoch day.
|
||||||
|
expect(core.formatCellValue(new Date(Date.UTC(1899, 11, 31, 14, 30)), 'h:mm AM/PM')).toEqual({
|
||||||
|
text: '2:30 PM',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds unsupported number format warnings into one counted entry', () => {
|
||||||
|
const many = Array.from({ length: 800 }, (_, index) => `Unsupported number format: 0.0${'0'.repeat(index)}E+0`);
|
||||||
|
const folded = core.foldWarnings(['charts', ...many, 'Formula has no cached result']);
|
||||||
|
expect(folded).toEqual(['charts', '800 unsupported number formats', 'Formula has no cached result']);
|
||||||
|
expect(core.foldWarnings(['Unsupported number format: 0.00E+00'])).toEqual(['Unsupported number format: 0.00E+00']);
|
||||||
|
expect(core.foldWarnings(['charts'])).toEqual(['charts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// toLocaleString throws a RangeError above 100 fraction digits; Excel caps at 30.
|
||||||
|
it('caps a number format at 30 decimals instead of throwing', () => {
|
||||||
|
expect(core.formatCellValue(1.5, `0.${'0'.repeat(120)}`).text).toBe(`1.5${'0'.repeat(29)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
// The clrScheme and slot patterns rescan to the end of the text for every
|
||||||
|
// unclosed opening tag, so a padded theme is quadratic.
|
||||||
|
it('uses the default palette for a theme above 64 KB, even a 1 MB run of unclosed clrScheme tags', () => {
|
||||||
|
const padded = '<a:clrScheme>'.repeat(Math.ceil((1024 * 1024) / 13));
|
||||||
|
expect(padded.length).toBeGreaterThanOrEqual(1024 * 1024);
|
||||||
|
const started = performance.now();
|
||||||
|
expect(core.parseThemePalette(padded)).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
expect(performance.now() - started).toBeLessThan(1_000);
|
||||||
|
|
||||||
|
// The cap is on characters: a real theme padded to exactly 64 KB still parses, one more does not.
|
||||||
|
const fill = (length: number) =>
|
||||||
|
themeXml.replace('</a:theme>', `<!--${' '.repeat(length - themeXml.length - 7)}--></a:theme>`);
|
||||||
|
expect(fill(64 * 1024)).toHaveLength(64 * 1024);
|
||||||
|
expect(core.parseThemePalette(fill(64 * 1024))[4]).toBe('#ff0000');
|
||||||
|
expect(core.parseThemePalette(fill(64 * 1024 + 1))).toEqual(core.DEFAULT_THEME_PALETTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
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