From 087dc65e89a9ec0cb6d54fb8c7573b063a2ddcad Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 15:50:45 +0800 Subject: [PATCH 1/9] feat(preview): render Excel workbooks in the browser --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- ...2026-09-16-browser-excel-preview.i18n.yaml | 6 + .../2026-09-16-browser-excel-preview.md | 31 ++ .../2026-09-16-browser-excel-preview.zh.md | 31 ++ THIRD_PARTY_NOTICES.md | 5 + apps/web/tests/document-preview.e2e.ts | 49 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 9 + docs/config-catalog.zh.md | 9 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 22 +- .../ui-sidebar-documentpreview/README.zh.md | 22 +- .../licenses/FortuneSheet.txt | 21 + .../ui-sidebar-documentpreview/package.json | 5 + .../src/client/excel/ExcelBody.module.css | 48 ++ .../src/client/excel/LazyExcelBody.tsx | 25 + .../src/client/excel/asset-imports.d.ts | 11 + .../src/client/excel/convert.ts | 209 ++++++++ .../src/client/excel/excel.tsx | 51 ++ .../src/client/excel/index.ts | 24 + .../src/client/excel/locales.ts | 33 ++ .../src/client/excel/parse.ts | 64 +++ .../src/client/excel/worker.ts | 9 + .../src/client/index.ts | 2 + .../ui-sidebar-documentpreview/src/config.ts | 16 +- .../tests/apply.client.spec.ts | 2 + .../tests/config.host.spec.ts | 6 +- .../tests/excel-body.client.spec.tsx | 80 +++ .../tests/excel-convert.client.spec.ts | 119 +++++ .../tests/excel-fixture.ts | 49 ++ .../tests/excel-parse.client.spec.ts | 99 ++++ .../tests/excel-registration.client.spec.ts | 40 ++ .../tests/excel-worker.client.spec.ts | 37 ++ .../tests/pdf-license-bundle.client.spec.ts | 22 +- .../tsdown.config.ts | 40 +- .../src/client/slot-catalog.ts | 1 + patches/@fortune-sheet__react@1.0.4.patch | 26 + pnpm-lock.yaml | 456 ++++++++++++++++++ pnpm-workspace.yaml | 1 + .../web/document-preview/document.expected.md | 10 +- 42 files changed, 1666 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md create mode 100644 packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts create mode 100644 patches/@fortune-sheet__react@1.0.4.patch diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index b40fa370ec..c8e12581a6 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 132ac8b3dd709f9f16254a5569dec59f01d29fda -2026-09-11-node-office-kit.zh.md: c7bdab2322766c42cfee4092f158d7b6b530e875 +2026-09-11-node-office-kit.md: 9042e23b674d208af19b0829f4c6ec7961f2464c +2026-09-11-node-office-kit.zh.md: 2707b26ae1a25e8d8819142567201e28a768346a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 132ac8b3dd..9042e23b67 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. -The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. +The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. Client PDF previews use this path for Word and PowerPoint; [browser Excel preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX independently. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index c7bdab2322..2707b26ae1 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 -服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 +服务和 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器 Excel 预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 XLSX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml new file mode 100644 index 0000000000..8d9f266392 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +2026-09-16-browser-excel-preview.md: 914d772229ada1e5f84524460510779f7624d3bc +2026-09-16-browser-excel-preview.zh.md: 1022ea293a36dfc032c9152bbbbc5682b12f9a98 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md new file mode 100644 index 0000000000..914d772229 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -0,0 +1,31 @@ +# Agent Note: Browser Excel preview with saved formula results + +Status: implemented + +English | [中文](2026-09-16-browser-excel-preview.zh.md) + +## Problem + +PDF conversion loses spreadsheet navigation and the relationship between a formula and its saved value. Spreadsheet preview also needlessly depends on the Host Office engine when the browser can read the workbook. + +## Decision + +The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX with FortuneSheet and a package-local ExcelJS adapter. The adapter accepts bytes and limits and returns worksheet data; it has no React state setters, timers, or component references. ExcelJS owns OOXML parsing, while the adapter owns the display mapping. The existing complete-file reader retains Session authorization, file versions, reload, and transient bytes. + +Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. + +A disposable browser Worker contains parser CPU work. A compressed-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. + +The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. The browser scenario covers the failure through the bundled ESM entry; the CommonJS entry receives the same fix. Recheck this patch when upgrading FortuneSheet. + +## Alternatives considered + +**Adopt FortuneExcel's React-oriented conversion helper.** Its component setters and post-render sizing couple file conversion to one UI lifecycle. The local adapter can be tested without mounting a workbook and needs no second intermediate workbook model. + +**Write an OOXML parser or create a new public package.** ExcelJS already owns the container and file semantics. One preview consumer does not justify another public API, Cordis service, or generic workbook abstraction; a second independent consumer can motivate extraction. + +**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. Legacy XLS receives explicit XLSX guidance. The [Office engine decision](../architecture/2026-09-11-node-office-kit.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets. + +## Consequences + +The renderer trades complete Excel fidelity for browser-local navigation and reusable conversion code. The package README owns supported formats, resource defaults, and unsupported features. Real XLSX fixtures cover conversion, Worker tests cover cancellation and cleanup, and the shipped-profile browser scenario opens a workbook with Office conversion disabled and copies a saved XLOOKUP result under read-only settings. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md new file mode 100644 index 0000000000..1022ea293a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -0,0 +1,31 @@ +# Agent Note:显示已保存公式结果的浏览器 Excel 预览 + +Status: implemented + +[English](2026-09-16-browser-excel-preview.md) | 中文 + +## 问题 + +PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。浏览器能够读取工作簿时,表格预览也没有必要依赖 Host Office 引擎。 + +## 决策 + +[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS 适配层打开 XLSX。适配层接收字节和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。ExcelJS 负责 OOXML 解析,适配层负责显示映射。现有完整文件读取器保留 Session 授权、文件版本、重新载入和临时字节。 + +预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空,并显示工作簿提示。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 + +独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查压缩字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 + +固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。浏览器场景通过打包后的 ESM 入口覆盖该故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 + +## 考虑过的替代方案 + +**采用 FortuneExcel 面向 React 的转换辅助函数。** 其组件状态设置器和渲染后尺寸调整把文件转换耦合到一种 UI 生命周期。本地适配层无需挂载工作簿即可测试,也不需要第二种中间工作簿模型。 + +**自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。 + +**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。旧版 XLS 显示明确的 XLSX 引导。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 + +## 影响 + +渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX fixture 覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2ad45f06c7..6348295f6a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | | [`@lexical/history`](https://github.com/facebook/lexical) | MIT | @@ -128,6 +129,7 @@ pnpm applies local patches to the following packages at install time, so shipped - `@earendil-works/pi-ai@0.85.1` — [`patches/@earendil-works__pi-ai@0.85.1.patch`](patches/@earendil-works__pi-ai@0.85.1.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) +- `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -165,6 +167,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@electron/get`](https://github.com/electron/get) | MIT | | [`@electron/notarize`](https://github.com/electron/notarize) | MIT | +| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | @@ -206,8 +209,10 @@ External packages **directly declared** for development, tests, types, or toolin | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index adefac10ee..d7351609f8 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,6 +9,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' +import { excelFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -243,6 +244,7 @@ else process.exit(1); writeFile(join(cwd, 'user-unit.pdf'), pdfFixture(2)), ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))), writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), + writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), ...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))), writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])), ]) @@ -751,16 +753,37 @@ else process.exit(1); '- Plain-text option and viewer picker: hidden', ].join('\n')) - const spreadsheetStates: string[] = [] - for (const extension of ['xls', 'xlsx']) { - await openFile(`unavailable.${extension}`) - const spreadsheet = column.locator('[data-textpreview-state="unsupported"]') - await spreadsheet.waitFor({ timeout: 15_000 }) - expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1) - await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 }) - spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`) - } - sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n')) + await openFile('budget.xlsx') + const excel = preview.locator('[data-excel-preview]') + await excel.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' }) + await successShot(page, 'excel-budget') + expect(await preview.locator('[data-pdf-preview]').count()).toBe(0) + expect(await excel.locator('.fortune-toolbar').count()).toBe(0) + await excel.getByText('公式与格式', { exact: true }).click() + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') + expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') + await page.keyboard.type('999') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') + await successShot(page, 'excel-cached-formula') + await openFile('unavailable.xls') + const legacyGuidance = 'Save this legacy Excel file as .xlsx to preview it.' + await preview.getByText(legacyGuidance, { exact: true }).waitFor() + await openFile('unavailable.xlsx') + const invalidExcel = 'This Excel file could not be opened. It may be damaged or password protected.' + await preview.getByText(invalidExcel, { exact: true }).waitFor() + sections.push([ + '## Browser Excel preview', '', + '- Opens without the Office conversion service', + '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', + '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', + '- Formula bar is read-only; PDF body and editing toolbar absent', + `- Legacy guidance: ${legacyGuidance}`, + `- Invalid XLSX: ${invalidExcel}`, + ].join('\n')) await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') @@ -958,12 +981,6 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await successShot(page, `office-${extension}`) } expect(convert).toHaveBeenCalledTimes(4) - for (const extension of ['xls', 'xlsx']) { - await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) - await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 }) - expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0) - } - expect(convert).toHaveBeenCalledTimes(4) await openPreviewFile(column, filesTab, preview, 'chinese.docx') await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c1cf209b2c..1db0fed05c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 1fac327a8a50a09cf694f6fc31a0af1f031f596f -config-catalog.zh.md: fbc043ab62d57d1b7f754e28d3469cc5ebd4fb49 +config-catalog.md: 020bc3327493ab1c82b5cbb4b0742168ee8fb393 +config-catalog.zh.md: e25e7331601301972afa29dfc893f0e6f5dafe6d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1fac327a8a..020bc33274 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -502,6 +502,15 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fbc043ab62..e25e733160 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -506,6 +506,15 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } ``` diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index faad4651ee..8ddc5d9ec2 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 79c7253887cd580bdcc725054c0bbf5b2877e90b -README.zh.md: 77847e8ce7a9203a85fee360ec8a481e32e7e871 +README.md: b37a393b1a41d78198a5c2a7dfab786c42b3d770 +README.zh.md: c867d34c544434b2541bfd9ab1ae4fef45469a35 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 79c7253887..b37a393b1a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,13 +9,14 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks use the unsupported-preview state. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and XLSX receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents - [What it registers](#what-it-registers) - [Addresses](#addresses) - [How it reads](#how-it-reads) +- [Excel preview](#excel-preview) - [Office preview](#office-preview) - [Navigation](#navigation) - [Model Experience](#model-experience) @@ -62,10 +63,25 @@ Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. + +## Excel preview + +Open `.xlsx` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. The viewer retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. It displays saved formula results without recalculating; missing results receive a notice. Legacy `.xls` files require saving as `.xlsx`. Excel preview does not call the Office conversion service. + +Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits complement the Host's complete-file read limit; they do not cap browser process memory or decompression allocations. + +| Field | Default | Meaning | +|---|---|---| +| `excel.maxBytes` | `16777216` (16 MiB) | Maximum compressed XLSX bytes | +| `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | +| `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | + +The lazy Excel chunk bundles MIT-licensed FortuneSheet and ExcelJS. A package-local, React-independent adapter maps ExcelJS data to FortuneSheet cells. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. + ## Office preview -Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. `.xls` and `.xlsx` do not register a Sidebar renderer: they show the unsupported-preview message without requesting conversion and offer the system default application when the Host reports a desktop. The [Host provider](../../document/office-to-pdf/README.md) retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. +Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML. @@ -108,7 +124,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. -- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Excel workbooks are not previewed and instead offer the system default application on desktop Hosts. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). +- **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 77847e8ce7..c867d34c54 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,13 +9,14 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和 XLSX 接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 - [注册了什么](#what-it-registers) - [地址](#addresses) - [怎么读](#how-it-reads) +- [Excel 预览](#excel-preview) - [Office 预览](#office-preview) - [导航](#navigation) - [模型体验](#model-experience) @@ -62,10 +63,25 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 + +## Excel 预览 + +直接在浏览器中打开 `.xlsx`,支持工作表标签、单元格选择、复制和只读公式栏。查看器保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。它显示已保存的公式结果而不重新计算;缺少结果时显示提示。旧版 `.xls` 文件需要另存为 `.xlsx`。Excel 预览不调用 Office 转换服务。 + +在同一个 `ui-sidebar-documentpreview` 条目上配置 `excel`。这些限制补充 Host 的完整文件读取限制,但不限制浏览器进程内存或解压分配量。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `excel.maxBytes` | `16777216`(16 MiB) | 最大压缩 XLSX 字节数 | +| `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | +| `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | + +惰性 Excel chunk 打包 MIT 许可的 FortuneSheet 和 ExcelJS。包内独立于 React 的适配层将 ExcelJS 数据映射为 FortuneSheet 单元格。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 + ## Office 预览 -将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。`.xls` 与 `.xlsx` 不注册 Sidebar 渲染器:它们不请求转换,直接显示不支持预览的提示;Host 报告存在桌面时,还提供用系统默认应用打开的控件。[Host 提供方](../../document/office-to-pdf/README.zh.md)仍为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 +将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 [Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。 @@ -108,7 +124,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 -- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。Excel 工作簿不提供预览,在桌面 Host 上改为提供用系统默认应用打开的控件。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 +- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 diff --git a/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt b/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt new file mode 100644 index 0000000000..065d1f9e7e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/licenses/FortuneSheet.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 2a30d10fac..ef9a2bad2b 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -69,11 +69,16 @@ "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@fortune-sheet/core": "1.0.4", + "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", "dompurify": "^3.4.11", + "exceljs": "4.4.0", + "fast-xml-parser": "5.11.1", + "fflate": "^0.8.3", "pdfjs-dist": "6.3.289", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css new file mode 100644 index 0000000000..25d00bc7eb --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -0,0 +1,48 @@ +.body { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + white-space: normal; + font-family: var(--dsw-font, sans-serif); +} + +.workbook { + flex: 1; + min-height: 0; + min-width: 0; + color-scheme: light; +} + +.notice { + flex: none; + margin: 0; + padding: 6px 12px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.status { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + height: 100%; + padding: 16px; + color: var(--dsw-alias-label-secondary); +} + +.loadingIcon { + display: flex; + animation: turn 0.8s linear infinite; +} + +@keyframes turn { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .loadingIcon { animation: none; } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx new file mode 100644 index 0000000000..90cf288a01 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx @@ -0,0 +1,25 @@ +/** Load the spreadsheet renderer only when a supported workbook is opened. */ +import { lazy, Suspense, type ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { DocumentPreviewProps } from '../document/contract.ts' +import type { ExcelLimits } from './convert.ts' +import { hostFileOf } from '../rpc.ts' +import { LoadingIndicator } from '../LoadingIndicator.tsx' +import css from '../TextPreview.module.css' + +/** Document input plus parser limits and localized copy. */ +export type ExcelBodyProps = DocumentPreviewProps & PropsLocale<'sidebarExcel'> & { readonly limits: ExcelLimits } + +const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx')).ExcelBody })) + +/** + * Show legacy-format guidance or load the browser workbook renderer. + * @param props - Complete file bytes and standard document seats. + * @returns Localized loading state or Excel preview. + */ +export function LazyExcelBody(props: ExcelBodyProps): ReactNode { + if (/\.xls$/iu.test(hostFileOf(props.resourceAddress).path)) return

{props.t('legacy')}

+ return }> + + +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts new file mode 100644 index 0000000000..6f49c8ee5a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/asset-imports.d.ts @@ -0,0 +1,11 @@ +/** Build-owned parser Worker source embedded in the lazy Excel chunk. */ +declare module '*.ts?raw' { + const source: string + export default source +} + +/** FortuneSheet's stylesheet is scoped by the preview component. */ +declare module '@fortune-sheet/react/dist/index.css?inline' { + const source: string + export default source +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts new file mode 100644 index 0000000000..6ea4c81ca5 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -0,0 +1,209 @@ +/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ +import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' +import { update as formatNumber, type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' +import { XMLParser } from 'fast-xml-parser' + +/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ +export interface ExcelLimits { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum time allowed for one parser Worker. */ + timeoutMs: number +} + +/** Preview data and a count of formulas without saved results. */ +export interface ExcelPreview { + sheets: Sheet[] + missingResults: number +} + +/** Parser failures with locale-independent categories. */ +export class ExcelPreviewError extends Error { + /** @param code - User-actionable parser failure. */ + constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout', options?: ErrorOptions) { + super(code, options) + this.name = 'ExcelPreviewError' + } +} + +const BORDER_STYLES: Record = { + thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, + double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, + mediumDashDotDot: 11, slantDashDot: 12, thick: 13, +} +const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] + +/** + * Decode an XLSX into display cells, retaining formulas without recalculation. + * @param bytes - Complete borrowed workbook bytes. + * @param limits - File and matrix allocation limits. + * @returns Sheets ready for a read-only FortuneSheet workbook. + */ +export async function convertExcel(bytes: Uint8Array, limits: ExcelLimits): Promise { + if (bytes.byteLength > limits.maxBytes) throw new ExcelPreviewError('tooLarge') + const workbook = new ExcelJS.Workbook() + try { + await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + } catch (error) { + throw new ExcelPreviewError('invalid', { cause: error }) + } + if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') + let area = 0 + for (const sheet of workbook.worksheets) { + area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) + if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + } + const colors = themeColors(workbook) + let missingResults = 0 + let active = false + const sheets = workbook.worksheets.map((worksheet, order): Sheet => { + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], + } + const celldata: NonNullable = [] + for (const range of worksheet.model.merges) { + // ExcelJS normalizes merged ranges to two cell addresses. + const [start, end] = range.split(':') as [string, string] + const first = worksheet.getCell(start) + const last = worksheet.getCell(end) + const merge = { + r: Number(first.row) - 1, c: Number(first.col) - 1, + rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, + } + config.merge[`${merge.r}_${merge.c}`] = merge + } + // ExcelJS's declarations omit absent parsed collections and style fields. + const columns = worksheet.columns as ExcelJS.Column[] | null + ;(columns ?? []).forEach((column, index) => { + if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) + if (column.hidden) config.colhidden[index] = 0 + }) + worksheet.eachRow({ includeEmpty: true }, (row) => { + const r = row.number - 1 + const height = row.height as number | undefined + if (height !== undefined) config.rowlen[r] = height * 96 / 72 + if (row.hidden) config.rowhidden[r] = 0 + row.eachCell({ includeEmpty: true }, (cell) => { + const c = Number(cell.col) - 1 + const v = convertCell(cell, colors) + if (v.f !== undefined && v.v === undefined) missingResults += 1 + if (cell.isMerged) { + v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } + if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } + } + celldata.push({ r, c, v }) + const border = cell.border as Partial | undefined + if (border !== undefined) { + const value: Record = { row_index: r, col_index: c } + for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { + const edge = border[side] + if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } + } + config.borderInfo.push({ rangeType: 'cell', value }) + } + }) + }) + const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] + const visible = worksheet.state === 'visible' + const status = visible && !active ? 1 : 0 + active ||= visible + const sheet: Sheet = { + id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, + row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), + config, celldata, showGridLines: view?.showGridLines !== false, + defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + } + if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) + if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { + sheet.frozen = { + type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', + range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, + } + } + return sheet + }) + return { sheets, missingResults } +} + +function convertCell( + cell: Omit & Partial>, + colors: readonly (string | undefined)[], +): Cell { + const font = cell.font + const alignment = cell.alignment + const result: Cell = fontStyle(font, colors) + const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined + if (bg !== undefined) result.bg = bg + if (alignment !== undefined) { + if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 + result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 + result.tb = alignment.wrapText ? '2' : '1' + if (alignment.textRotation === 'vertical') result.tr = '3' + else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation + } + const value = cell.value + let display: ExcelJS.CellValue = value + if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + result.f = `=${cell.formula}` + display = value.result ?? null + } + if (display instanceof Date) { + // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. + result.v = display.getTime() / 86_400_000 + 25569 + } else if (display !== null && typeof display === 'object') { + if ('richText' in display) { + result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } + result.v = display.richText.map(run => run.text).join('') + } else if ('text' in display) result.v = display.text + else result.v = (display as ExcelJS.CellErrorValue).error + } else if (display !== null && display !== undefined) result.v = display + if (result.ht === undefined) result.ht = typeof result.v === 'number' ? 2 : typeof result.v === 'boolean' ? 0 : 1 + if (result.ct === undefined) result.ct = { fa: cell.numFmt || 'General', t: typeof result.v === 'number' ? 'n' : typeof result.v === 'boolean' ? 'b' : 's' } + const formatted = typeof result.v === 'number' ? formatNumber(cell.numFmt || 'General', result.v) as string | number : result.v + result.m = formatted === undefined ? '' : String(formatted) + return result +} + +function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { + if (font === undefined) return {} + const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } + if (font.name !== undefined) result.ff = font.name + if (font.size !== undefined) result.fs = font.size + const fc = colorOf(font.color, colors) + if (fc !== undefined) result.fc = fc + return result +} + +function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } + +function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { + if (color === undefined) return undefined + const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) + if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined + // ExcelJS retains OOXML tint even though its Color declaration omits it. + const tint: unknown = Reflect.get(color, 'tint') + if (typeof tint !== 'number' || tint === 0) return `#${rgb}` + return `#${[0, 2, 4].map((offset) => { + const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) + return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') + }).join('')}` +} + +function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { + const themes: unknown = workbook.model.themes + const xml = record(themes).theme1 + if (typeof xml !== 'string') return [] + const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) + const scheme = record(record(record(parsed).theme).themeElements).clrScheme + return THEME_ORDER.map((name) => { + const color = record(record(scheme)[name]) + const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] + return typeof value === 'string' ? value : undefined + }) +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? value as Record : {} +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx new file mode 100644 index 0000000000..f0be53ffb8 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -0,0 +1,51 @@ +/** Read-only spreadsheet surface backed by browser-parsed workbook data. */ +import { useEffect, useState, type ReactNode } from 'react' +import { Workbook } from '@fortune-sheet/react' +import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' +import type { ExcelPreview } from './convert.ts' +import type { ExcelBodyProps } from './LazyExcelBody.tsx' +import { parseExcel } from './parse.ts' +import css from './ExcelBody.module.css' + +type State = { data: Uint8Array } & ({ value: ExcelPreview } | { error: string }) +const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` + +/** + * Display stored spreadsheet values and formats without editing or recalculation. + * @param props - Complete workbook bytes, limits, and locale. + * @returns An isolated spreadsheet surface with cancellable loading. + */ +export function ExcelBody({ content, limits, t }: ExcelBodyProps): ReactNode { + const data = content.kind === 'bytes' ? content.data : undefined + const [state, setState] = useState() + const [attempt, setAttempt] = useState(0) + useEffect(() => { + if (data === undefined) return + const controller = new AbortController() + setState(undefined) + void parseExcel(data, limits, controller.signal).then( + (value) => { if (!controller.signal.aborted) setState({ data, value }) }, + (error: unknown) => { if (!controller.signal.aborted) setState({ data, error: error instanceof Error ? error.message : 'invalid' }) }, + ) + return () => { controller.abort() } + }, [data, limits, attempt]) + if (data === undefined) return

{t('invalid')}

+ if (state?.data !== data) return + + + if ('error' in state) return
+ {t(state.error === 'tooLarge' ? 'tooLarge' : state.error === 'timeout' ? 'timeout' : 'invalid')} + +
+ return
+ +

{t('summary')}

+ {state.value.missingResults > 0 &&

{t('missingResults')}

} +
+ +
+
+} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts new file mode 100644 index 0000000000..2d85968a44 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/index.ts @@ -0,0 +1,24 @@ +/** Excel previews use ordinary authorized file bytes without Office conversion. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '../index.ts' +import type { Config } from '../../config.ts' +import { LazyExcelBody } from './LazyExcelBody.tsx' +import { en, zh } from './locales.ts' + +/** + * Register the browser Excel viewer and its lifecycle-owned slot. + * @param ctx - Preview and locale registries. + * @param limits - Resolved parser limits. + */ +export function apply(ctx: Context, limits: Config['excel']): void { + const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel' + ctx.effect(() => ctx.locale.register('sidebarExcel', { zh, en })) + const t = ctx.locale.bind('sidebarExcel') + ctx.effect(() => ctx.documentPreviews.register({ + id, extensions: ['xlsx', 'xls'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', + title: () => t('title'), loading: 'bytes-complete', wrap: false, + })) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ + name: 'sidebar.right.tab.document', key: id, locale: 'sidebarExcel', inject: () => ({ limits }), + }, LazyExcelBody))) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts new file mode 100644 index 0000000000..b25bd503a2 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -0,0 +1,33 @@ +/** Locale-owned Excel preview controls and parser feedback. */ +export const zh = { + title: 'Excel', language: 'zh', loading: '正在打开表格…', + invalid: '无法打开此 Excel 文件。文件可能已损坏或受密码保护。', + tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', + legacy: '请将此旧版 Excel 文件另存为 .xlsx 后预览。', + summary: '只读预览 · 显示已保存的公式结果', + limitations: '图表、图片、数据透视表和条件格式暂不显示。', + missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', + retry: '重试', +} satisfies Record + +/** Excel preview dictionary keys. */ +export type ExcelPreviewKey = keyof typeof zh + +/** English Excel preview copy. */ +export const en = { + title: 'Excel', language: 'en', loading: 'Opening spreadsheet…', + invalid: 'This Excel file could not be opened. It may be damaged or password protected.', + tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', + legacy: 'Save this legacy Excel file as .xlsx to preview it.', + summary: 'Read-only preview · Saved formula results', + limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', + missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', + retry: 'Retry', +} satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Excel preview status and third-party locale selection. */ + sidebarExcel: ExcelPreviewKey + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts new file mode 100644 index 0000000000..3c7eb83bb3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts @@ -0,0 +1,64 @@ +/** Browser lifetime for the isolated XLSX parser. */ +import workerSource from './worker.ts?raw' +import type { ExcelLimits, ExcelPreview } from './convert.ts' + +/** + * Parse workbook bytes in a disposable Worker, copying the retained preview buffer. + * @param bytes - Borrowed complete file bytes. + * @param limits - File, matrix, and elapsed-time limits. + * @param signal - Document body's lifetime. + * @returns Parsed sheets; rejects with a locale key on parser failures. + */ +export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal: AbortSignal): Promise { + signal.throwIfAborted() + if (bytes.byteLength > limits.maxBytes) return Promise.reject(new Error('tooLarge')) + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })) + let worker: Worker + try { + worker = new Worker(url, { name: 'dsh-excel' }) + } catch (error) { + URL.revokeObjectURL(url) + reject(new Error('invalid', { cause: error })) + return + } + const finish = (): void => { + clearTimeout(timer) + signal.removeEventListener('abort', abort) + worker.onmessage = null + worker.onerror = null + worker.onmessageerror = null + worker.terminate() + URL.revokeObjectURL(url) + } + const abort = (): void => { finish(); reject(new DOMException('Excel preview closed', 'AbortError')) } + const fail = (): void => { finish(); reject(new Error('invalid')) } + const timer = setTimeout(() => { finish(); reject(new Error('timeout')) }, limits.timeoutMs) + worker.onerror = fail + worker.onmessageerror = fail + worker.onmessage = (event: MessageEvent) => { + const message = event.data + if (typeof message !== 'object' || message === null || !('ok' in message)) { fail(); return } + if (message.ok === true && 'value' in message && validPreview(message.value)) { + finish() + resolve(message.value) + } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout'].includes(String(message.code))) { + finish() + reject(new Error(String(message.code))) + } else fail() + } + signal.addEventListener('abort', abort, { once: true }) + try { + const copy = bytes.slice() + worker.postMessage({ bytes: copy, limits }, [copy.buffer]) + } catch (error) { finish(); reject(new Error('invalid', { cause: error })) } + }) +} + +function validPreview(value: unknown): value is ExcelPreview { + if (typeof value !== 'object' || value === null || !('sheets' in value) || !('missingResults' in value)) return false + return typeof value.missingResults === 'number' && Number.isSafeInteger(value.missingResults) && value.missingResults >= 0 + && Array.isArray(value.sheets) && value.sheets.length > 0 + && value.sheets.every((sheet: unknown) => typeof sheet === 'object' && sheet !== null && 'name' in sheet && typeof sheet.name === 'string' + && 'celldata' in sheet && Array.isArray(sheet.celldata)) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts new file mode 100644 index 0000000000..408fa82b10 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts @@ -0,0 +1,9 @@ +/** One workbook per disposable parser Worker; formulas and external links are never executed. */ +import { convertExcel, ExcelPreviewError, type ExcelLimits } from './convert.ts' + +globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; limits: ExcelLimits }>) => { + void convertExcel(event.data.bytes, event.data.limits).then( + (value) => { globalThis.postMessage({ ok: true, value }) }, + (error: unknown) => { globalThis.postMessage({ ok: false, code: error instanceof ExcelPreviewError ? error.code : 'invalid' }) }, + ) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index 7c8b6907b2..f9b52dff5f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -37,6 +37,7 @@ import { apply as registerImage } from './image/index.ts' import { apply as registerPdf } from './pdf/index.ts' import { apply as registerCode } from './code/index.ts' import { apply as registerOffice } from './office/index.ts' +import { apply as registerExcel } from './excel/index.ts' import { Config } from '../config.ts' // Values stay package-private unless another package needs them; the plugin @@ -125,4 +126,5 @@ export function apply(ctx: ClientContext): void { registerPdf(ctx) registerCode(ctx) registerOffice(ctx, config.office) + registerExcel(ctx, config.excel) } diff --git a/packages/client/ui-sidebar-documentpreview/src/config.ts b/packages/client/ui-sidebar-documentpreview/src/config.ts index 41e947c82f..8949cf2e9f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/config.ts +++ b/packages/client/ui-sidebar-documentpreview/src/config.ts @@ -14,14 +14,28 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } + /** Browser Excel parser and dense spreadsheet allocation limits. */ + excel: { + /** Maximum compressed workbook bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum parser Worker lifetime in milliseconds. */ + timeoutMs: number + } } /** Deployment limits applied before Office preview registration. */ -export const Config: z<{ office?: Partial }, Config> = z.object({ +export const Config: z<{ office?: Partial; excel?: Partial }, Config> = z.object({ office: z.object({ maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), maxCachedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(64 * 1024 * 1024), maxPending: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32), }), + excel: z.object({ + maxBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(16 * 1024 * 1024), + maxCells: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250_000), + timeoutMs: z.natural().min(1).max(2_147_483_647).default(15_000), + }), }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 5e82256d60..79963d3c42 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -28,6 +28,7 @@ import { HTML_BODY_ID } from '../src/client/html/index.ts' import { ImageBody } from '../src/client/image/ImageBody.tsx' import { IMAGE_BODY_ID } from '../src/client/image/index.ts' import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.tsx' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' import { en, zh } from '../src/client/locales.ts' @@ -99,6 +100,7 @@ describe('ui-sidebar-documentpreview apply', () => { ['sidebar.right.tab.document.action', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeFontAction], ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeBody], ['sidebar.right.tab.document.office.pdf', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarPdf', LazyPdfBody], + ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel', 'sidebarExcel', LazyExcelBody], ]) expect(registered[0]?.store).toBeDefined() expect(typeof registered[0]?.inject).toBe('function') diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 05e14300b5..6646008850 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -5,12 +5,14 @@ import { expect, it, onTestFinished } from 'vitest' import { Config } from '../src/config.ts' import * as host from '../src/index.ts' -it('exposes only Client reuse limits on the Host entry', () => { +it('exposes Client cache and spreadsheet limits on the Host entry', () => { expect(host.Config).toBe(Config) expect(Config({})).toEqual({ office: { maxCachedEntries: 8, maxCachedBytes: 64 * 1024 * 1024, maxPending: 8, maxReaders: 32, - } }) + }, excel: { maxBytes: 16 * 1024 * 1024, maxCells: 250_000, timeoutMs: 15_000 } }) for (const key of ['maxCachedEntries', 'maxCachedBytes']) expect(() => Config({ office: { [key]: 0 } })).toThrow() + for (const key of ['maxBytes', 'maxCells', 'timeoutMs']) expect(() => Config({ excel: { [key]: 0 } })).toThrow() + expect(() => Config({ excel: { timeoutMs: 2_147_483_648 } })).toThrow() }) it('embeds YAML cache settings in browser pages and withdraws them on disposal', async () => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx new file mode 100644 index 0000000000..ebdb006f5a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +/** Excel preview lifecycle and read-only renderer settings. */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { Config } from '../src/config.ts' +import { en } from '../src/client/excel/locales.ts' +import type { ExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' + +const mocked = vi.hoisted(() => ({ parse: vi.fn(), workbook: vi.fn((_props: unknown) => null) })) +vi.mock('../src/client/excel/parse.ts', () => ({ parseExcel: mocked.parse })) +vi.mock('@fortune-sheet/react', () => ({ Workbook: mocked.workbook })) +import { ExcelBody } from '../src/client/excel/excel.tsx' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' + +const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps +const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } +afterEach(() => { cleanup(); vi.resetAllMocks() }) + +it('shows loading then a workbook with editing and recalculation disabled', async () => { + mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) + const view = render() + expect(screen.getByRole('status', { name: en.loading })).toBeDefined() + await screen.findByText(en.summary) + expect(screen.getByText(en.missingResults)).toBeDefined() + expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) + const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + view.unmount() + expect(signal.aborted).toBe(true) +}) + +it('ignores a retired file result and cancels parsing when the bytes change', async () => { + let finish!: (value: unknown) => void + mocked.parse.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })).mockResolvedValueOnce(value) + const view = render() + const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + view.rerender() + await screen.findByText(en.summary) + expect(signal.aborted).toBe(true) + finish({ ...value, missingResults: 1 }) + await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) +}) + +it.each(['invalid', 'tooLarge', 'timeout', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { + mocked.parse.mockRejectedValueOnce(new Error(error)).mockResolvedValueOnce(value) + render() + const key = error === 'tooLarge' || error === 'timeout' ? error : 'invalid' + await screen.findByText(en[key]) + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await screen.findByText(en.summary) + expect(mocked.parse).toHaveBeenCalledTimes(2) +}) + +it('rejects non-byte content and shows legacy XLS guidance without loading a parser', () => { + const view = render() + expect(screen.getByRole('alert').textContent).toBe(en.invalid) + view.rerender() + expect(screen.getByRole('alert').textContent).toBe(en.legacy) + expect(mocked.parse).not.toHaveBeenCalled() +}) + +it('loads the spreadsheet chunk for an XLSX file', async () => { + mocked.parse.mockResolvedValue(value) + render() + await screen.findByText(en.summary) +}) + +it('uses generic guidance for non-Error failures and ignores rejection after unmount', async () => { + mocked.parse.mockRejectedValueOnce('parser failure') + const view = render() + await screen.findByText(en.invalid) + view.unmount() + let reject!: (error: Error) => void + mocked.parse.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) + const pending = render() + const signal = mocked.parse.mock.calls[1]![2] as AbortSignal + pending.unmount() + reject(new Error('closed')) + await waitFor(() => { expect(signal.aborted).toBe(true) }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts new file mode 100644 index 0000000000..4f084ab7f3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -0,0 +1,119 @@ +/** XLSX formatting, cached formula results, and resource admission. */ +import ExcelJS from 'exceljs' +import { strToU8, unzipSync, zipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { convertExcel } from '../src/client/excel/convert.ts' +import { Config } from '../src/config.ts' +import { excelFixture } from './excel-fixture.ts' + +const limits = Config({}).excel + +describe('Excel conversion', () => { + it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { + const input = await excelFixture() + const { sheets, missingResults } = await convertExcel(input, limits) + expect(input.byteLength).toBeGreaterThan(0) + expect(sheets.map(sheet => sheet.name)).toEqual(['季度预算', '公式与格式', '隐藏页']) + const first = sheets[0]! + const cell = (r: number, c: number) => first.celldata!.find(value => value.r === r && value.c === c)!.v + expect(cell(0, 0)).toMatchObject({ v: 'DeepSeek · 项目预算', bl: 1, fs: 18, fc: '#FFFFFF', bg: '#3B5CCC', ht: 0, vt: 0, mc: { r: 0, c: 0, rs: 1, cs: 4 } }) + expect(cell(0, 1)).toMatchObject({ mc: { r: 0, c: 0 } }) + expect(cell(0, 1)).not.toHaveProperty('v') + expect(cell(2, 3)).toMatchObject({ f: '=C3/B3', v: 0.8, m: '80.0%', ct: { fa: '0.0%', t: 'n' } }) + expect(cell(4, 1)).toMatchObject({ f: '=SUM(B3:B4)', v: 57000, m: '57,000.00' }) + expect(first.config).toMatchObject({ rowlen: { 0: 48 }, columnlen: { 0: 173 }, merge: { '0_0': { rs: 1, cs: 4 } } }) + expect(first.config!.borderInfo).toContainEqual({ rangeType: 'cell', value: { row_index: 1, col_index: 0, b: { style: 1, color: '#DCE2ED' } } }) + expect(first.frozen).toEqual({ type: 'rangeBoth', range: { row_focus: 1, column_focus: 0 } }) + const second = sheets[1]! + expect(second.celldata).toEqual(expect.arrayContaining([ + expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), + expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), + expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) as unknown }), + expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) as unknown } }) as unknown }), + expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) as unknown }), + expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) as unknown }), + expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) as unknown }), + expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) as unknown }), + ])) + expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } }) + expect(sheets[2]).toMatchObject({ hide: 1, status: 0 }) + expect(missingResults).toBe(1) + }) + + it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { + await expect(convertExcel(new Uint8Array([1, 2, 3]), limits)).rejects.toMatchObject({ code: 'invalid' }) + const input = await excelFixture() + await expect(convertExcel(input, { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Sparse').getCell('Z1000').value = 1 + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) + }) + + it('opens an empty worksheet and rejects workbooks with no visible sheet', async () => { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Empty') + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]).toMatchObject({ name: 'Empty', row: 1, column: 1, status: 1 }) + workbook.getWorksheet('Empty')!.state = 'veryHidden' + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits)).rejects.toMatchObject({ code: 'invalid' }) + }) + + it.each([false, true])('retains dates for the 1904 epoch flag %s', async (date1904) => { + const workbook = new ExcelJS.Workbook() + workbook.properties.date1904 = date1904 + const cell = workbook.addWorksheet('Dates').getCell('A1') + cell.value = new Date('2024-02-29T12:00:00Z') + cell.numFmt = 'yyyy-mm-dd hh:mm' + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]!.celldata![0]!.v).toMatchObject({ m: '2024-02-29 12:00' }) + }) + + it('maps custom colors, tints, alignment, decoration, and default dimensions', async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet('Styles') + sheet.properties.defaultColWidth = 12 + sheet.getColumn(1).width = 0.5 + const light = { theme: 4, tint: 0.5 } + const dark = { theme: 4, tint: -0.5 } + sheet.getCell('A1').value = 'Light' + sheet.getCell('A1').font = { color: light, strike: true, underline: true } + sheet.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: dark } + sheet.getCell('A1').alignment = { horizontal: 'right', vertical: 'top', wrapText: true, textRotation: 'vertical' } + sheet.getCell('B1').value = 'Rotated' + sheet.getCell('B1').alignment = { horizontal: 'left', vertical: 'bottom', textRotation: 45 } + sheet.getCell('B1').border = { top: { style: 'double' } } + sheet.getCell('C1').value = 'Automatic color' + sheet.getCell('C1').font = { color: { indexed: 64 } as Partial } + sheet.getCell('D1').value = 'Invalid color' + sheet.getCell('D1').font = { color: { argb: 'invalid' } } + sheet.getCell('E1').value = 'Untinted' + sheet.getCell('E1').font = { color: { argb: 'FFFF0000', tint: 0 } as Partial } + sheet.getCell('F1').value = 42 + sheet.getCell('F1').alignment = { vertical: 'middle' } + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]).toMatchObject({ defaultColWidth: 89, config: { columnlen: { 0: 6 } } }) + const cells = result.sheets[0]!.celldata! + expect(cells[0]!.v).toMatchObject({ fc: '#a7c0de', bg: '#28415f', cl: 1, un: 1, ht: 2, vt: 1, tb: '2', tr: '3' }) + expect(cells[1]!.v).toMatchObject({ ht: 1, vt: 2, rt: 45 }) + expect(cells[2]!.v).not.toHaveProperty('fc') + expect(cells[3]!.v).not.toHaveProperty('fc') + expect(cells[4]!.v).toMatchObject({ fc: '#FF0000' }) + expect(cells[5]!.v).toMatchObject({ v: 42, ht: 2, vt: 0, ct: { t: 'n' } }) + }) + + it.each([{ ySplit: 2 }, { xSplit: 1 }, {}])('maps one-axis or empty freeze settings %j', async (freeze) => { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Frozen', { views: [{ state: 'frozen', ...freeze, showGridLines: false }] }).getCell('A1').value = 1 + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + expect(result.sheets[0]!.showGridLines).toBe(false) + expect(result.sheets[0]!.frozen?.type).toBe('ySplit' in freeze ? 'rangeRow' : 'xSplit' in freeze ? 'rangeColumn' : undefined) + }) + + it('accepts workbooks without a theme and ignores absent theme colors', async () => { + const files = unzipSync(await excelFixture()) + delete files['xl/theme/theme1.xml'] + expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + files['xl/theme/theme1.xml'] = strToU8('') + expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts new file mode 100644 index 0000000000..41ecf38986 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -0,0 +1,49 @@ +/** Real XLSX fixture shared by adapter and shipped-profile browser tests. */ +import ExcelJS from 'exceljs' + +/** + * Build a styled, multi-sheet workbook with cached and unsupported formulas. + * @returns Complete XLSX file bytes. + */ +export async function excelFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet('季度预算', { views: [{ state: 'frozen', xSplit: 1, ySplit: 2 }] }) + sheet.columns = [{ width: 24 }, { width: 18 }, { width: 18 }, { width: 18 }] + sheet.mergeCells('A1:D1') + sheet.getCell('A1').value = 'DeepSeek · 项目预算' + sheet.getCell('A1').font = { name: 'Arial', size: 18, bold: true, color: { argb: 'FFFFFFFF' } } + sheet.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF3B5CCC' } } + sheet.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle' } + sheet.getRow(1).height = 36 + sheet.getRow(2).values = ['项目', '预算', '支出', '执行率'] + sheet.getRow(3).values = ['设计', 12000, 9600, { formula: 'C3/B3', result: 0.8 }] + sheet.getRow(4).values = ['研发', 45000, 31500, { formula: 'C4/B4', result: 0.7 }] + sheet.getRow(5).values = ['合计', { formula: 'SUM(B3:B4)', result: 57000 }, { formula: 'SUM(C3:C4)', result: 41100 }, { formula: 'C5/B5', result: 41100 / 57000 }] + for (let row = 2; row <= 5; row += 1) { + sheet.getRow(row).height = 28 + for (let column = 1; column <= 4; column += 1) { + const cell = sheet.getCell(row, column) + cell.border = { bottom: { style: 'thin', color: { argb: 'FFDCE2ED' } } } + if (row === 2 || row === 5) { + cell.font = { bold: true, color: { argb: 'FF24345C' } } + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEDF1FC' } } + } + if (row > 2 && column > 1) cell.numFmt = column === 4 ? '0.0%' : '#,##0.00' + } + } + const details = workbook.addWorksheet('公式与格式') + details.getCell('A1').value = { formula: '_xlfn.XLOOKUP(1,{1},{42})', result: 42 } + details.getCell('A2').value = { formula: 'SUM(1,2)' } + details.getCell('B1').value = new Date('2026-09-16T00:00:00Z') + details.getCell('B1').numFmt = 'yyyy-mm-dd' + details.getCell('C1').value = { richText: [{ text: '富文本', font: { bold: true } }, { text: ' 示例', font: { italic: true } }] } + details.getCell('D1').value = { text: 'Link text', hyperlink: 'https://example.com' } + details.getCell('A3').value = { sharedFormula: 'A2', result: 3 } + details.getCell('A4').value = true + details.getCell('A5').value = { error: '#DIV/0!' } + details.getRow(7).height = 40 + details.getRow(7).hidden = true + details.getColumn(5).hidden = true + workbook.addWorksheet('隐藏页', { state: 'hidden' }).getCell('A1').value = 'Hidden data' + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts new file mode 100644 index 0000000000..bf57fe5b30 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts @@ -0,0 +1,99 @@ +/** Parser Worker allocation, cancellation, timeout, and message failures. */ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { parseExcel } from '../src/client/excel/parse.ts' +import { Config } from '../src/config.ts' + +const limits = Config({}).excel +const preview = { sheets: [{ name: 'Sheet', celldata: [] }], missingResults: 0 } +let instance: ParserWorker +const revokeUrl = vi.fn() + +class ParserWorker { + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onmessageerror: (() => void) | null = null + postMessage = vi.fn<(message: { bytes: Uint8Array }, transfer: Transferable[]) => void>() + terminate = vi.fn() +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('Worker', vi.fn(function () { instance = new ParserWorker(); return instance })) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:excel-test') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(revokeUrl) +}) +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); revokeUrl.mockClear() }) + +it('transfers a private copy and releases the Worker and URL after parsing', async () => { + const input = new Uint8Array([1, 2, 3]) + const result = parseExcel(input, limits, new AbortController().signal) + const [message, transfer] = instance.postMessage.mock.calls[0]! + expect(message.bytes).toEqual(input) + expect(message.bytes).not.toBe(input) + expect(transfer).toEqual([message.bytes.buffer]) + instance.onmessage!({ data: { ok: true, value: preview } }) + await expect(result).resolves.toEqual(preview) + expect(instance.terminate).toHaveBeenCalledOnce() + expect(revokeUrl).toHaveBeenCalledWith('blob:excel-test') + expect(vi.getTimerCount()).toBe(0) +}) + +it('does not allocate a Worker for aborted or oversized input', async () => { + const worker = vi.fn() + vi.stubGlobal('Worker', worker) + expect(() => parseExcel(new Uint8Array(), limits, AbortSignal.abort())).toThrow() + await expect(parseExcel(new Uint8Array(2), { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') + expect(worker).not.toHaveBeenCalled() +}) + +it('cancels an active parser without retaining callbacks or timers', async () => { + const controller = new AbortController() + const result = parseExcel(new Uint8Array(), limits, controller.signal) + const rejection = expect(result).rejects.toThrow('closed') + controller.abort(new Error('closed')) + await rejection + expect(instance.terminate).toHaveBeenCalledOnce() + expect(instance.onmessage).toBeNull() + expect(vi.getTimerCount()).toBe(0) +}) + +it('terminates a parser that exceeds its time budget', async () => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const rejection = expect(result).rejects.toThrow('timeout') + await vi.advanceTimersByTimeAsync(limits.timeoutMs) + await rejection + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it.each([null, 7, {}, { ok: true, value: null }, { ok: true, value: { sheets: [], missingResults: 0 } }, + { ok: true, value: { sheets: [{}], missingResults: 0 } }, { ok: true, value: { ...preview, missingResults: -1 } }, + { ok: false, code: 'surprise' }, { ok: false, code: 'invalid' }])('rejects invalid Worker message %j', async (data) => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + instance.onmessage!({ data }) + await expect(result).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s', async (event) => { + const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + instance[event]!() + await expect(result).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() +}) + +it('revokes the URL when Worker construction fails', async () => { + vi.stubGlobal('Worker', vi.fn(function () { throw new Error('unavailable') })) + await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + expect(revokeUrl).toHaveBeenCalledOnce() +}) + +it('terminates the Worker when posting the copied buffer fails', async () => { + vi.stubGlobal('Worker', vi.fn(function () { + instance = new ParserWorker() + instance.postMessage.mockImplementation(() => { throw new Error('transfer failed') }) + return instance + })) + await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + expect(instance.terminate).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts new file mode 100644 index 0000000000..ca4161c026 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -0,0 +1,40 @@ +/** Excel registration remains independent of the Office conversion service. */ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { Config } from '../src/config.ts' +import { apply } from '../src/client/excel/index.ts' +import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' +import { en, zh } from '../src/client/excel/locales.ts' + +it('registers complete binary reads and removes the slot and locale on disposal', async () => { + const ctx = new Context() + const registry = new DocumentPreviewRegistry() + const removeLocale = vi.fn() + const removeBody = vi.fn() + const registerLocale = vi.fn(() => removeLocale) + const register = vi.fn((_options: unknown, _component: unknown) => removeBody) + ctx.provide('documentPreviews', registry) + ctx.provide('locale', { register: registerLocale, bind: () => makeTranslate(en) } as never) + ctx.provide('slots', { inject: (_name: string, callback: () => () => void) => callback(), register } as never) + const limits = Config({}).excel + const fiber = ctx.plugin({ apply: (scope) => { apply(scope, limits) } }) + try { + await fiber.await() + for (const path of ['budget.XLSX', 'legacy.xls']) { + const candidate = registry.candidates(path)[0]! + expect(candidate).toMatchObject({ loading: 'bytes-complete', wrap: false, binaryExtensions: ['xlsx', 'xls'] }) + expect(candidate.title()).toBe('Excel') + expect(candidate.read).toBeUndefined() + expect(candidate.bodyId).toBeUndefined() + } + expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en }) + expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) as unknown }), LazyExcelBody) + const options = register.mock.calls[0]![0] as { inject: () => unknown } + expect(options.inject()).toEqual({ limits }) + } finally { await fiber.dispose() } + expect(registry.getSnapshot()).toEqual([]) + expect(removeLocale).toHaveBeenCalledOnce() + expect(removeBody).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts new file mode 100644 index 0000000000..606e7e30a6 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts @@ -0,0 +1,37 @@ +/** Worker entry translates parser failures into its private response protocol. */ +import { afterEach, expect, it, vi } from 'vitest' +import { Config } from '../src/config.ts' +import { excelFixture } from './excel-fixture.ts' + +afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); vi.doUnmock('../src/client/excel/convert.ts') }) + +it('returns parsed sheets and bounded failure categories', async () => { + const post = vi.fn() + vi.stubGlobal('postMessage', post) + vi.stubGlobal('onmessage', null) + await import('../src/client/excel/worker.ts') + const handle: (event: MessageEvent) => void = globalThis.onmessage! + const message = (bytes: Uint8Array, maxCells = 250_000) => { + handle({ data: { bytes, limits: { ...Config({}).excel, maxCells } } } as MessageEvent) + } + message(await excelFixture()) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ ok: true })) }) + message(new Uint8Array([1, 2])) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) + message(await excelFixture(), 1) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'tooLarge' }) }) +}) + +it('reports an unexpected parser failure as an invalid workbook', async () => { + const post = vi.fn() + vi.stubGlobal('postMessage', post) + vi.stubGlobal('onmessage', null) + vi.doMock('../src/client/excel/convert.ts', async (importOriginal) => { + const original = await importOriginal() + return { ...original, convertExcel: vi.fn().mockRejectedValue(new Error('parser crashed')) } + }) + await import('../src/client/excel/worker.ts') + const handle: (event: MessageEvent) => void = globalThis.onmessage! + handle({ data: { bytes: new Uint8Array(), limits: Config({}).excel } } as MessageEvent) + await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts index 5b9d775206..d583495e9e 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' const packageRoot = resolve(import.meta.dirname, '..') @@ -56,13 +57,32 @@ describe('published PDF.js licenses', () => { const client = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.js'], packageRoot, task.timeout) const pdf = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.pdf.js'], packageRoot, task.timeout) expect([...client.matchAll(/require\.async\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1])) - .toEqual(['./client.pdf.js']) + .toEqual(['./client.pdf.js', './client.excel.js']) expect(client).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u) expect([...pdf.matchAll(/require\("(\.\/client[^"/]*\.js)"\)/gu)].map(match => match[1])) .toEqual([]) expect(client).not.toContain('//! Bundled PDF.js license notices') expect(client).not.toContain('/pdfjs-dist/') expect(pdf).toContain('//! Bundled PDF.js license notices') + const excel = run('tar', ['-xOf', resolve(packageRoot, packed.filename), 'package/lib/client.excel.js'], packageRoot, task.timeout) + expect(excel).not.toMatch(/\brequire\("\.\/client[^"/]*\.js"\)/u) + expect(client).not.toContain('FortuneSheet') + expect(excel).toContain('//! Bundled spreadsheet license notices') + expect(excel).toContain('Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd') + expect(excel).toContain('Permission is hereby granted, free of charge') + let initialized = false + runInNewContext(excel, { window: { __ModuleLoader__: { load: (registration: { + factory: (resolve: (specifier: string) => unknown) => { ExcelBody: unknown } + }) => { + const loaded = registration.factory((specifier) => { + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return {} + if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) as unknown + throw new Error(`Unexpected browser dependency: ${specifier}`) + }) + expect(typeof loaded.ExcelBody).toBe('function') + initialized = true + } } } }) + expect(initialized).toBe(true) const pdfRoot = dirname(require.resolve('pdfjs-dist/package.json')) for (const name of licenseNames) { const source = readFileSync(join(pdfRoot, name), 'utf8').trimEnd() diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index 742a5f46e3..950f7bb930 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -1,16 +1,25 @@ import { readFileSync, readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' -import type { UserConfig } from 'tsdown' +import { Rolldown, type UserConfig } from 'tsdown' import { clientBundle } from '../tsdown.client.ts' const bundle = clientBundle('@deepseek-ai/dsh-client-ui-sidebar-documentpreview', ['lib/types/index.js'], { - clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() : undefined, + clientBanner: fileName => fileName.endsWith('client.pdf.js') ? pdfLicenseBanner() + : fileName.endsWith('client.excel.js') ? excelLicenseBanner() : undefined, }) const require = createRequire(import.meta.url) const workerSpecifier = 'pdfjs-dist/build/pdf.worker.min.mjs?raw' const workerModule = '\0dsh-pdf-worker.mjs' +/** FortuneSheet omits its repository license from the npm payload. */ +function excelLicenseBanner(): string { + const fortune = readFileSync(join(import.meta.dirname, 'licenses/FortuneSheet.txt'), 'utf8') + const excel = readFileSync(join(dirname(require.resolve('exceljs/package.json')), 'LICENSE'), 'utf8') + const xml = readFileSync(join(dirname(dirname(require.resolve('fast-xml-parser'))), 'LICENSE'), 'utf8') + return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') +} + /** License files for PDF.js and the data embedded beside its runtime. */ function pdfLicenseFiles(root: string): string[] { return ['LICENSE', ...['cmaps', 'standard_fonts', 'wasm'].flatMap(directory => @@ -53,10 +62,35 @@ const pdfWorker: NonNullable = [{ }, }] +/** Embed a self-contained browser parser without giving it a loader-module dependency. */ +const excelWorker: NonNullable = [{ + name: 'dsh-excel-worker-source', + resolveId(source) { return source === './worker.ts?raw' ? '\0dsh-excel-worker-source' : null }, + async load(id) { + if (id !== '\0dsh-excel-worker-source') return null + const worker = await Rolldown.rolldown({ + input: join(import.meta.dirname, 'src/client/excel/worker.ts'), platform: 'browser', + resolve: { mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, + transform: { define: { 'process.env.NODE_ENV': JSON.stringify('production') } }, + }) + try { + const result = await worker.generate({ format: 'iife', minify: true }) + const chunk = result.output[0] + if (chunk?.type !== 'chunk') throw new Error('Excel parser did not emit a JavaScript chunk') + for (const path of Object.keys(chunk.modules)) this.addWatchFile(path) + return `export default ${JSON.stringify(chunk.code)};` + } finally { await worker.close() } + }, +}] + export default (options: Parameters[0]): UserConfig[] => bundle(options).map(config => config.name?.endsWith('/client') === true ? { ...config, - plugins: [config.plugins, pdfWorker], + inputOptions: { + ...config.inputOptions, + resolve: { ...config.inputOptions?.resolve, mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, + }, + plugins: [config.plugins, pdfWorker, excelWorker], define: { ...config.define, __DSH_PDFJS_ASSETS__: pdfAssets() }, } : config, ) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index af3043fd78..2954e2b935 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2953,6 +2953,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', occupants: [ 'client-ui-sidebar-documentpreview CodeBody', + 'client-ui-sidebar-documentpreview LazyExcelBody', 'client-ui-sidebar-documentpreview HtmlBody', 'client-ui-sidebar-documentpreview ImageBody', 'client-ui-sidebar-documentpreview MarkdownBody', diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch new file mode 100644 index 0000000000..dfbcb4cd74 --- /dev/null +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.esm.js b/dist/index.esm.js +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95393d9e8c 100644 +--- a/dist/index.esm.js ++++ b/dist/index.esm.js +@@ -921,7 +921,7 @@ var InputBox = function InputBox() { + return {}; + }, [context.luckysheetfile, context.currentSheetId, context.luckysheetCellUpdate, firstSelection]); + useLayoutEffect(function () { +- if (!context.allowEdit) { ++ if (!context.allowEdit && context.forceFormulaRef) { + setContext(function (ctx) { + var flowdata = getFlowdata(ctx); + if (!_.isNil(flowdata) && ctx.forceFormulaRef) { +diff --git a/dist/index.js b/dist/index.js +index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e659e229d21 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -932,7 +932,7 @@ var InputBox = function InputBox() { + return {}; + }, [context.luckysheetfile, context.currentSheetId, context.luckysheetCellUpdate, firstSelection]); + React.useLayoutEffect(function () { +- if (!context.allowEdit) { ++ if (!context.allowEdit && context.forceFormulaRef) { + setContext(function (ctx) { + var flowdata = core.getFlowdata(ctx); + if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a78e83473..c6ef213fde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 + '@fortune-sheet/react@1.0.4': a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4540,6 +4541,12 @@ importers: '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path + '@fortune-sheet/core': + specifier: 1.0.4 + version: 1.0.4 + '@fortune-sheet/react': + specifier: 1.0.4 + version: 1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4555,6 +4562,15 @@ importers: dompurify: specifier: ^3.4.11 version: 3.4.11 + exceljs: + specifier: 4.4.0 + version: 4.4.0 + fast-xml-parser: + specifier: 5.11.1 + version: 5.11.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 pdfjs-dist: specifier: 6.3.289 version: 6.3.289 @@ -14196,6 +14212,28 @@ packages: '@noble/hashes': optional: true + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@formulajs/formulajs@2.9.3': + resolution: {integrity: sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g==} + hasBin: true + + '@fortune-sheet/core@1.0.4': + resolution: {integrity: sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw==} + + '@fortune-sheet/formula-parser@0.2.13': + resolution: {integrity: sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw==} + + '@fortune-sheet/react@1.0.4': + resolution: {integrity: sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw==} + peerDependencies: + react: '>= 18.2' + react-dom: '>= 18.2' + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -14749,6 +14787,9 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@octokit/openapi-types@28.0.0': resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} @@ -15948,6 +15989,9 @@ packages: '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -15989,6 +16033,9 @@ packages: '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/regenerator-runtime@0.13.8': + resolution: {integrity: sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -16408,6 +16455,9 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + app-builder-lib@26.15.3: resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} @@ -16415,6 +16465,18 @@ packages: dmg-builder: 26.15.3 electron-builder-squirrel-windows: 26.15.3 + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -16530,12 +16592,23 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bessel@1.0.2: + resolution: {integrity: sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==} + engines: {node: '>=0.8'} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -16545,6 +16618,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -16577,6 +16653,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -16587,12 +16666,20 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + builder-util-runtime@9.7.0: resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} @@ -16650,6 +16737,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -16762,6 +16852,10 @@ packages: resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} engines: {node: '>=0.10.0'} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -16833,6 +16927,15 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -17378,6 +17481,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + execa@10.0.0: resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} engines: {node: '>=22'} @@ -17419,6 +17526,10 @@ packages: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -17440,6 +17551,10 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + fast-xml-parser@5.11.1: + resolution: {integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==} + hasBin: true + fast-xml-parser@5.7.3: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true @@ -17578,6 +17693,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -17801,6 +17921,9 @@ packages: immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -17896,6 +18019,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@2.0.2: + resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -18068,6 +18194,9 @@ packages: resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} engines: {node: '>=0.6.0'} + jstat@1.9.6: + resolution: {integrity: sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==} + jsx-ast-utils-x@0.1.0: resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -18103,6 +18232,10 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -18246,6 +18379,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -18253,16 +18389,49 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -18683,6 +18852,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -18691,6 +18864,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + numeral@2.0.6: + resolution: {integrity: sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==} + oauth-sign@0.9.0: resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} @@ -18826,6 +19002,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -19076,6 +19256,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -19088,6 +19271,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -19228,6 +19414,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -19462,6 +19652,9 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} @@ -19524,6 +19717,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -19578,6 +19774,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -19767,6 +19966,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -19800,6 +20002,11 @@ packages: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -20124,6 +20331,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -20186,6 +20397,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -21291,6 +21506,54 @@ snapshots: optionalDependencies: '@noble/hashes': 2.3.0 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@formulajs/formulajs@2.9.3': + dependencies: + bessel: 1.0.2 + jstat: 1.9.6 + + '@fortune-sheet/core@1.0.4': + dependencies: + '@fortune-sheet/formula-parser': 0.2.13 + dayjs: 1.11.21 + immer: 9.0.21 + lodash: 4.18.1 + numeral: 2.0.6 + uuid: 8.3.2 + + '@fortune-sheet/formula-parser@0.2.13': + dependencies: + '@formulajs/formulajs': 2.9.3 + tiny-emitter: 2.1.0 + + '@fortune-sheet/react@1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@fortune-sheet/core': 1.0.4 + '@types/regenerator-runtime': 0.13.8 + immer: 9.0.21 + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + regenerator-runtime: 0.14.1 + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 @@ -21794,6 +22057,8 @@ snapshots: '@nodable/entities@2.2.0': {} + '@nodable/entities@3.0.0': {} + '@octokit/openapi-types@28.0.0': {} '@octokit/openapi-webhooks-types@12.1.0': {} @@ -22775,6 +23040,8 @@ snapshots: '@types/node': 22.20.0 form-data: 4.0.6 + '@types/node@14.18.63': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -22818,6 +23085,8 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/regenerator-runtime@0.13.8': {} + '@types/responselike@1.0.3': dependencies: '@types/node': 22.20.0 @@ -23277,6 +23546,8 @@ snapshots: anynum@1.0.0: {} + anynum@1.0.1: {} + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): dependencies: '@electron/asar': 3.4.1 @@ -23325,6 +23596,42 @@ snapshots: transitivePeerDependencies: - supports-color + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + argparse@2.0.1: {} aria-query@5.3.0: @@ -23416,12 +23723,21 @@ snapshots: dependencies: tweetnacl: 0.14.5 + bessel@1.0.2: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + birpc@2.9.0: {} birpc@4.0.0: {} @@ -23432,6 +23748,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + bluebird@3.7.2: {} body-parser@2.3.0: @@ -23478,6 +23796,8 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -23486,6 +23806,8 @@ snapshots: dependencies: '@types/node': 22.20.0 + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -23496,6 +23818,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + builder-util-runtime@9.7.0: dependencies: debug: 4.4.3(supports-color@9.4.0) @@ -23564,6 +23888,10 @@ snapshots: chai@6.2.2: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -23645,6 +23973,13 @@ snapshots: compare-version@0.1.2: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -23729,6 +24064,13 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + cross-dirname@0.1.0: optional: true @@ -24411,6 +24753,18 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.21 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + execa@10.0.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -24489,6 +24843,11 @@ snapshots: dependencies: pure-rand: 8.4.0 + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -24506,6 +24865,15 @@ snapshots: path-expression-matcher: 1.5.0 xml-naming: 0.1.0 + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.2.0 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + fast-xml-parser@5.7.3: dependencies: '@nodable/entities': 2.2.0 @@ -24665,6 +25033,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.6.3 + function-bind@1.1.2: {} functional-red-black-tree@1.0.1: {} @@ -24961,6 +25336,8 @@ snapshots: immer@10.2.0: {} + immer@9.0.21: {} + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} @@ -25020,6 +25397,8 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} + is-what@5.5.0: {} is-wsl@3.1.1: @@ -25180,6 +25559,8 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jstat@1.9.6: {} + jsx-ast-utils-x@0.1.0: {} jszip@3.10.1: @@ -25234,6 +25615,10 @@ snapshots: lazy-val@1.0.5: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + lefthook-darwin-arm64@2.1.9: optional: true @@ -25341,18 +25726,42 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + listenercount@1.0.1: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash-es@4.18.1: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} long@5.3.2: {} @@ -25943,6 +26352,8 @@ snapshots: dependencies: abbrev: 4.0.0 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} npm-run-path@6.0.0: @@ -25950,6 +26361,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + numeral@2.0.6: {} + oauth-sign@0.9.0: {} object-assign@4.1.1: {} @@ -26104,6 +26517,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-expression-matcher@1.6.2: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -26362,6 +26777,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -26370,6 +26789,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.12.2 + regenerator-runtime@0.14.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -26562,6 +26983,10 @@ snapshots: sax@1.6.1: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -26855,6 +27280,10 @@ snapshots: dependencies: anynum: 1.0.0 + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + stylis@4.4.0: {} sumchecker@3.0.1: @@ -26952,6 +27381,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-emitter@2.1.0: {} + tiny-inflate@1.0.3: {} tiny-typed-emitter@2.1.0: {} @@ -26998,6 +27429,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -27158,6 +27591,19 @@ snapshots: unpipe@1.0.0: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -27193,6 +27639,8 @@ snapshots: uuid@14.0.1: {} + uuid@8.3.2: {} + vary@1.1.2: {} verror@1.10.0: @@ -27541,6 +27989,8 @@ snapshots: xml-naming@0.1.0: {} + xml-naming@0.3.0: {} + xmlbuilder@15.1.1: {} xmlchars@2.2.0: {} @@ -27598,6 +28048,12 @@ snapshots: yoctocolors@2.1.2: {} + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 58172c0252..fd9e8fd510 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -97,5 +97,6 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch + '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index a2489ba886..af06b3f429 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -72,10 +72,14 @@ - Binary text shown: false - Plain-text option and viewer picker: hidden -## Spreadsheet preview +## Browser Excel preview -- XLS: unsupported / Open in default app -- XLSX: unsupported / Open in default app +- Opens without the Office conversion service +- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted +- Cached XLOOKUP result copied: 42; typing leaves it unchanged +- Formula bar is read-only; PDF body and editing toolbar absent +- Legacy guidance: Save this legacy Excel file as .xlsx to preview it. +- Invalid XLSX: This Excel file could not be opened. It may be damaged or password protected. ## Unknown suffix From 30b6abad5c327e8a36c3b53d109aea2485de38ed Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:10:35 +0800 Subject: [PATCH 2/9] fix(preview): retain Excel worksheet selections --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 1 + .../src/client/excel/convert.ts | 5 +++ .../tests/excel-convert.client.spec.ts | 2 + patches/@fortune-sheet__react@1.0.4.patch | 44 ++++++++++++++++++- pnpm-lock.yaml | 6 +-- 8 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 8d9f266392..2517c700a5 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 914d772229ada1e5f84524460510779f7624d3bc -2026-09-16-browser-excel-preview.zh.md: 1022ea293a36dfc032c9152bbbbc5682b12f9a98 +2026-09-16-browser-excel-preview.md: 10de50d05d7daf3a94ccc9c93e619c212b6ce54e +2026-09-16-browser-excel-preview.zh.md: 5e702ce95196da3566d05b8bf1ff5aaf923f1585 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 914d772229..10de50d05d 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -16,7 +16,7 @@ Preview is read-only and retains formula text alongside saved results. It does n A disposable browser Worker contains parser CPU work. A compressed-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. -The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. The browser scenario covers the failure through the bundled ESM entry; the CommonJS entry receives the same fix. Recheck this patch when upgrading FortuneSheet. +The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry; the CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 1022ea293a..5e702ce951 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -16,7 +16,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查压缩字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 -固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。浏览器场景通过打包后的 ESM 入口覆盖该故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 +固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 ## 考虑过的替代方案 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index d7351609f8..0f1b77a698 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -760,6 +760,7 @@ else process.exit(1); expect(await preview.locator('[data-pdf-preview]').count()).toBe(0) expect(await excel.locator('.fortune-toolbar').count()).toBe(0) await excel.getByText('公式与格式', { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts index 6ea4c81ca5..40dc53b547 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -114,6 +114,11 @@ export async function convertExcel(bytes: Uint8Array, limits: Excel row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), config, celldata, showGridLines: view?.showGridLines !== false, defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + luckysheet_select_save: [{ + row: [0, (config.merge['0_0']?.rs ?? 1) - 1], + column: [0, (config.merge['0_0']?.cs ?? 1) - 1], + row_focus: 0, column_focus: 0, + }], } if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 4f084ab7f3..2008eafe53 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -24,7 +24,9 @@ describe('Excel conversion', () => { expect(first.config).toMatchObject({ rowlen: { 0: 48 }, columnlen: { 0: 173 }, merge: { '0_0': { rs: 1, cs: 4 } } }) expect(first.config!.borderInfo).toContainEqual({ rangeType: 'cell', value: { row_index: 1, col_index: 0, b: { style: 1, color: '#DCE2ED' } } }) expect(first.frozen).toEqual({ type: 'rangeBoth', range: { row_focus: 1, column_focus: 0 } }) + expect(first.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 3], row_focus: 0, column_focus: 0 }]) const second = sheets[1]! + expect(second.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }]) expect(second.celldata).toEqual(expect.arrayContaining([ expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch index dfbcb4cd74..dd76b04e78 100644 --- a/patches/@fortune-sheet__react@1.0.4.patch +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95393d9e8c 100644 +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb87459dbe7 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -921,7 +921,7 @@ var InputBox = function InputBox() { @@ -11,8 +11,28 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..f866bc54a2787e3fbe27256ed7513c95 setContext(function (ctx) { var flowdata = getFlowdata(ctx); if (!_.isNil(flowdata) && ctx.forceFormulaRef) { +@@ -6908,7 +6908,8 @@ var SheetItem = function SheetItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); +@@ -10693,7 +10694,8 @@ var SheetListItem = function SheetListItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); diff --git a/dist/index.js b/dist/index.js -index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e659e229d21 100644 +index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c002653a92b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -932,7 +932,7 @@ var InputBox = function InputBox() { @@ -24,3 +44,23 @@ index e4045518773578e3faaa3a58985a5585f9683088..469b0159ee6e9b308276c971fe9f7e65 setContext(function (ctx) { var flowdata = core.getFlowdata(ctx); if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { +@@ -6919,7 +6919,8 @@ var SheetItem = function SheetItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); +@@ -10704,7 +10705,8 @@ var SheetListItem = function SheetListItem(_ref) { + draftCtx.scrollLeft = 0; + draftCtx.scrollTop = 0; + draftCtx.luckysheet_select_status = false; +- draftCtx.luckysheet_select_save = undefined; ++ var currentSheet = draftCtx.luckysheetfile.find(function (s) { return s.id === draftCtx.currentSheetId; }); ++ draftCtx.luckysheet_select_save = currentSheet && currentSheet.luckysheet_select_save; + } + draftCtx.luckysheet_selection_range = []; + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6ef213fde..8f8f5e478a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/react@1.0.4': a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38 + '@fortune-sheet/react@1.0.4': 7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4546,7 +4546,7 @@ importers: version: 1.0.4 '@fortune-sheet/react': specifier: 1.0.4 - version: 1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -21544,7 +21544,7 @@ snapshots: '@formulajs/formulajs': 2.9.3 tiny-emitter: 2.1.0 - '@fortune-sheet/react@1.0.4(patch_hash=a278da43acb4f5f15b3703592688b952db02aeedb1e6900d20dbd5db38d4cb38)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@fortune-sheet/react@1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@fortune-sheet/core': 1.0.4 '@types/regenerator-runtime': 0.13.8 From e70fa894002d500d36e559360bba6a432d744af4 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 17:27:27 +0800 Subject: [PATCH 3/9] feat(preview): support xls csv and tsv --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 8 +- .../2026-09-16-browser-excel-preview.zh.md | 8 +- THIRD_PARTY_NOTICES.md | 9 +- apps/web/tests/document-preview.e2e.ts | 63 ++++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 10 +- .../ui-sidebar-documentpreview/README.zh.md | 10 +- .../ui-sidebar-documentpreview/package.json | 6 +- .../src/client/excel/LazyExcelBody.tsx | 12 +- .../src/client/excel/convert.ts | 223 ++---------------- .../src/client/excel/delimited.ts | 43 ++++ .../src/client/excel/error.ts | 8 + .../src/client/excel/excel.tsx | 24 +- .../src/client/excel/format.ts | 16 ++ .../src/client/excel/index.ts | 2 +- .../src/client/excel/locales.ts | 16 +- .../src/client/excel/model.ts | 42 ++++ .../src/client/excel/parse.ts | 14 +- .../src/client/excel/worker.ts | 9 +- .../src/client/excel/xls.ts | 98 ++++++++ .../src/client/excel/xlsx.ts | 182 ++++++++++++++ .../ui-sidebar-documentpreview/src/config.ts | 4 +- .../tests/excel-body.client.spec.tsx | 38 +-- .../tests/excel-convert.client.spec.ts | 38 +-- .../tests/excel-formats.client.spec.ts | 140 +++++++++++ .../tests/excel-parse.client.spec.ts | 18 +- .../tests/excel-registration.client.spec.ts | 9 +- .../tests/excel-worker.client.spec.ts | 4 +- .../tests/pdf-license-bundle.client.spec.ts | 11 +- .../tests/xls-fixture.ts | 41 ++++ .../tsdown.config.ts | 15 +- pnpm-lock.yaml | 29 +++ .../web/document-preview/document.expected.md | 11 +- 40 files changed, 847 insertions(+), 342 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/format.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c8e12581a6..9f2569bdfb 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 9042e23b674d208af19b0829f4c6ec7961f2464c -2026-09-11-node-office-kit.zh.md: 2707b26ae1a25e8d8819142567201e28a768346a +2026-09-11-node-office-kit.md: 8a974903f91c75d111a798bd9797120fef547f9f +2026-09-11-node-office-kit.zh.md: f4dd249c8e5d098308bf8f4cce7fcea1202a9efb diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 9042e23b67..8a974903f9 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. -The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. Client PDF previews use this path for Word and PowerPoint; [browser Excel preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX independently. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. +The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. Client PDF previews use this path for Word and PowerPoint; [browser spreadsheet preview](../feature/2026-09-16-browser-excel-preview.md) reads XLSX, XLS, CSV, and TSV independently. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 2707b26ae1..f4dd249c8e 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 -服务和 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器 Excel 预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 XLSX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 +服务和 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client PDF 预览通过此路径显示 Word 和 PowerPoint;[浏览器表格预览](../feature/2026-09-16-browser-excel-preview.zh.md)独立读取 XLSX、XLS、CSV 和 TSV。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 2517c700a5..5e63afa914 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 10de50d05d7daf3a94ccc9c93e619c212b6ce54e -2026-09-16-browser-excel-preview.zh.md: 5e702ce95196da3566d05b8bf1ff5aaf923f1585 +2026-09-16-browser-excel-preview.md: 6a33fbccdace9172fcee0863519b5c05d0595c7b +2026-09-16-browser-excel-preview.zh.md: 190102ed86925b28dea434d3c2f3ded4de11ddf6 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 10de50d05d..6a33fbccda 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -10,11 +10,11 @@ PDF conversion loses spreadsheet navigation and the relationship between a formu ## Decision -The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX with FortuneSheet and a package-local ExcelJS adapter. The adapter accepts bytes and limits and returns worksheet data; it has no React state setters, timers, or component references. ExcelJS owns OOXML parsing, while the adapter owns the display mapping. The existing complete-file reader retains Session authorization, file versions, reload, and transient bytes. +The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX, XLS, CSV, and TSV with FortuneSheet and package-local adapters for ExcelJS, SheetJS CE, and PapaParse. The adapter accepts bytes, format, and limits and returns worksheet data; it has no React state setters, timers, or component references. Dependencies own file parsing, while the adapters share display formatting and initial selection without introducing another workbook model. CSV/TSV retain literal strings and a plain-text option; implicit type inference could otherwise discard leading zeros or interpret source text as formulas. The shared document reader calls `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)` and receives native bytes through the binary Remote, retaining Session authorization, file versions, reload, and transient bytes without base64 decoding. Workspace Files owns bounded file reads; format parsing stays in the Client. The registration marks only XLSX/XLS as `binaryExtensions`, so the shared document container offers Plain text for CSV/TSV. Each renderer registers its body under its own id. Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. -A disposable browser Worker contains parser CPU work. A compressed-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. +A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry; the CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. @@ -24,8 +24,8 @@ The pinned FortuneSheet React dependency carries a [small patch](../../../../pat **Write an OOXML parser or create a new public package.** ExcelJS already owns the container and file semantics. One preview consumer does not justify another public API, Cordis service, or generic workbook abstraction; a second independent consumer can motivate extraction. -**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. Legacy XLS receives explicit XLSX guidance. The [Office engine decision](../architecture/2026-09-11-node-office-kit.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets. +**Keep PDF as an Excel fallback or calculate every formula on import.** PDF retains neither spreadsheet interaction nor formula inspection, while recalculation can change saved results. The [Office engine decision](../architecture/2026-09-11-node-office-kit.md) remains applicable to Word/PowerPoint previews and independent conversion consumers; its conversion capability still supports spreadsheets. ## Consequences -The renderer trades complete Excel fidelity for browser-local navigation and reusable conversion code. The package README owns supported formats, resource defaults, and unsupported features. Real XLSX fixtures cover conversion, Worker tests cover cancellation and cleanup, and the shipped-profile browser scenario opens a workbook with Office conversion disabled and copies a saved XLOOKUP result under read-only settings. +The renderer trades complete Excel fidelity for browser-local navigation and reusable conversion code. The package README owns supported formats, resource defaults, and unsupported features. Real XLSX/XLS fixtures and delimited-text cases cover conversion, Worker tests cover cancellation and cleanup, and the shipped-profile browser scenario opens a workbook with Office conversion disabled and copies a saved XLOOKUP result under read-only settings. It also exercises XLS sheet switching and CSV/TSV literal copying, text-view switching, and reload. SheetJS CE is pinned to its official release tarball because the public npm registry is stale; its Apache-2.0 license follows the existing permissive-license and bundled-notice policy. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 5e702ce951..190102ed86 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -10,11 +10,11 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 ## 决策 -[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS 适配层打开 XLSX。适配层接收字节和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。ExcelJS 负责 OOXML 解析,适配层负责显示映射。现有完整文件读取器保留 Session 授权、文件版本、重新载入和临时字节。 +[文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS、SheetJS CE 和 PapaParse 适配层打开 XLSX、XLS、CSV 和 TSV。适配层接收字节、格式和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。依赖库负责文件解析,适配层复用显示格式化和初始选区,不引入另一种工作簿模型。CSV/TSV 保留字面字符串和纯文本选项;隐式类型推断可能丢失前导零或把源文本解释为公式。共享文档读取器调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`,并通过二进制 Remote 直接接收原生字节,保留 Session 授权、文件版本、重新载入和临时字节,无需 base64 解码。Workspace Files 负责有界文件读取,格式解析留在 Client。注册仅将 XLSX/XLS 标为 `binaryExtensions`,共享文档容器因此为 CSV/TSV 提供纯文本选项。每个渲染器以自己的 id 注册正文。 预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空,并显示工作簿提示。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 -独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查压缩字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 +独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 固定版本的 FortuneSheet React 依赖带有一个[小补丁](../../../../patches/@fortune-sheet__react@1.0.4.patch):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障;CommonJS 入口应用相同修复。升级 FortuneSheet 时需要重新检查此补丁。 @@ -24,8 +24,8 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 **自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。 -**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。旧版 XLS 显示明确的 XLSX 引导。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 +**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。 ## 影响 -渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX fixture 覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。 +渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX/XLS fixture 和分隔文本用例覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。该场景还验证 XLS 工作表切换,以及 CSV/TSV 字面值复制、纯文本切换和重新载入。由于公共 npm 仓库版本过旧,SheetJS CE 固定使用官方发布的 tarball;其 Apache-2.0 许可证沿用现有宽松许可证及打包声明规则。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6348295f6a..820d843095 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | @@ -80,7 +81,9 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`execa`](https://github.com/sindresorhus/execa) | MIT | +| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | @@ -104,6 +107,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`open`](https://github.com/sindresorhus/open) | MIT | +| [`papaparse`](https://github.com/mholt/PapaParse) | MIT | | [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | | [`react`](https://github.com/facebook/react) | MIT | @@ -121,6 +125,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`undici`](https://github.com/nodejs/undici) | MIT | | [`use-sync-external-store`](https://github.com/facebook/react) | MIT | | [`ws`](https://github.com/websockets/ws) | MIT | +| [`xlsx`](https://git.sheetjs.com/SheetJS/sheetjs) | Apache-2.0 | | [`yaml`](https://github.com/eemeli/yaml) | ISC | | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | @@ -167,7 +172,6 @@ External packages **directly declared** for development, tests, types, or toolin | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | | [`@electron/get`](https://github.com/electron/get) | MIT | | [`@electron/notarize`](https://github.com/electron/notarize) | MIT | -| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | | [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | @@ -185,6 +189,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/papaparse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -209,10 +214,8 @@ External packages **directly declared** for development, tests, types, or toolin | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | -| [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | -| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT | | [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 0f1b77a698..2360bf6c35 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vit import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' import { excelFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' +import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -245,6 +246,9 @@ else process.exit(1); ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))), writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'budget.xls'), xlsFixture()), + writeFile(join(cwd, 'table.csv'), '00123,"中文,字段","=SUM(1,2)"\n2024-03-01,,TRUE'), + writeFile(join(cwd, 'table.tsv'), '00123\t"中文\t字段"\t=SUM(1,2)\n2024-03-01\t\tTRUE'), ...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))), writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])), ]) @@ -770,20 +774,63 @@ else process.exit(1); await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') await successShot(page, 'excel-cached-formula') - await openFile('unavailable.xls') - const legacyGuidance = 'Save this legacy Excel file as .xlsx to preview it.' - await preview.getByText(legacyGuidance, { exact: true }).waitFor() - await openFile('unavailable.xlsx') - const invalidExcel = 'This Excel file could not be opened. It may be damaged or password protected.' - await preview.getByText(invalidExcel, { exact: true }).waitFor() + await openFile('budget.xls') + await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' }) + await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('旧版预算') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('旧版预算') + await excel.getByText('明细', { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') + await successShot(page, 'excel-legacy') + const invalidExcel = 'This spreadsheet could not be opened. Check its format, contents, or password protection.' + for (const extension of ['xls', 'xlsx']) { + await openFile(`unavailable.${extension}`) + await preview.getByText(invalidExcel, { exact: true }).waitFor() + } sections.push([ '## Browser Excel preview', '', '- Opens without the Office conversion service', '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', '- Formula bar is read-only; PDF body and editing toolbar absent', - `- Legacy guidance: ${legacyGuidance}`, - `- Invalid XLSX: ${invalidExcel}`, + '- XLS: merged title copied; worksheet selection retained', + `- Invalid XLS/XLSX: ${invalidExcel}`, + ].join('\n')) + + for (const extension of ['csv', 'tsv']) { + await openFile(`table.${extension}`) + await expect.poll(() => viewer.innerText()).toBe('Spreadsheet') + await excel.getByText(extension.toUpperCase(), { exact: true }).waitFor() + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('00123') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('00123\n') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('=SUM(1,2)\n') + await viewer.click() + await page.getByRole('menuitem', { name: 'Plain text', exact: true }).click() + await expect.poll(() => preview.locator('[data-textpreview-line]').count()).toBe(2) + expect((await preview.locator('[data-textpreview-line]').allTextContents()).join('\n')).toContain('00123') + await viewer.click() + await page.getByRole('menuitem', { name: 'Spreadsheet', exact: true }).click() + await excel.getByText(extension.toUpperCase(), { exact: true }).waitFor() + await writeFile(join(cwd, `table.${extension}`), extension === 'csv' ? '00999,更新' : '00999\t更新') + await preview.locator('[data-textpreview-tool="reload"]').click() + await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('00999') + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('00999\n') + await successShot(page, `excel-${extension}`) + } + sections.push(['## Delimited spreadsheets', '', + '- CSV and TSV default to Spreadsheet; Plain text remains selectable', + '- Copied ID: 00123; copied literal formula: =SUM(1,2)', + '- Plain-text round trip retains complete source lines', + '- Reload replaces parsed cells: 00123 -> 00999', ].join('\n')) await openFile('notes.unknown') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1db0fed05c..3e8a9ce6d2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 020bc3327493ab1c82b5cbb4b0742168ee8fb393 -config-catalog.zh.md: e25e7331601301972afa29dfc893f0e6f5dafe6d +config-catalog.md: cf76e5aaf35415088cbb35df689a278fb2ddccf7 +config-catalog.zh.md: 04db740aa06ca6ab47e819445303436505a3e480 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 020bc33274..cf76e5aaf3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -502,9 +502,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e25e733160..04db740aa0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -506,9 +506,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 8ddc5d9ec2..01384a6bee 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: b37a393b1a41d78198a5c2a7dfab786c42b3d770 -README.zh.md: c867d34c544434b2541bfd9ab1ae4fef45469a35 +README.md: d4392dc889b1e1cb052412900fe719174aabb508 +README.zh.md: b05257bcf3f816b6d1040a6d5489a545d6d25d5d diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index b37a393b1a..d4392dc889 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and XLSX receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; Excel workbooks open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, common images, and spreadsheets receive complete bytes; unknown extensions use plain text. Word and PowerPoint documents convert locally to PDF; spreadsheets open in the browser. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents @@ -66,17 +66,19 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. The viewer retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. It displays saved formula results without recalculating; missing results receive a notice. Legacy `.xls` files require saving as `.xlsx`. Excel preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results receive a notice. Spreadsheet preview does not call the Office conversion service. + +CSV and TSV default to the spreadsheet viewer and also offer Plain text. Commas and tabs delimit their fields respectively; quoted separators, escaped quotes, multiline fields, empty fields, and unequal row lengths are supported. The first row remains data. Values remain literal strings, including leading zeros, dates, booleans, and formula-looking text. Text files accept UTF-8 or BOM-marked UTF-16; invalid encoding receives conversion guidance. Malformed quoted fields fail the table preview rather than silently dropping data. Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits complement the Host's complete-file read limit; they do not cap browser process memory or decompression allocations. | Field | Default | Meaning | |---|---|---| -| `excel.maxBytes` | `16777216` (16 MiB) | Maximum compressed XLSX bytes | +| `excel.maxBytes` | `16777216` (16 MiB) | Maximum source file bytes | | `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | | `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | -The lazy Excel chunk bundles MIT-licensed FortuneSheet and ExcelJS. A package-local, React-independent adapter maps ExcelJS data to FortuneSheet cells. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. +The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ## Office preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index c867d34c54..b05257bcf3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和 XLSX 接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和表格接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;表格在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 @@ -66,17 +66,19 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`,支持工作表标签、单元格选择、复制和只读公式栏。查看器保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。它显示已保存的公式结果而不重新计算;缺少结果时显示提示。旧版 `.xls` 文件需要另存为 `.xlsx`。Excel 预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。 + +CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。 在同一个 `ui-sidebar-documentpreview` 条目上配置 `excel`。这些限制补充 Host 的完整文件读取限制,但不限制浏览器进程内存或解压分配量。 | 字段 | 默认值 | 含义 | |---|---|---| -| `excel.maxBytes` | `16777216`(16 MiB) | 最大压缩 XLSX 字节数 | +| `excel.maxBytes` | `16777216`(16 MiB) | 最大源文件字节数 | | `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | | `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | -惰性 Excel chunk 打包 MIT 许可的 FortuneSheet 和 ExcelJS。包内独立于 React 的适配层将 ExcelJS 数据映射为 FortuneSheet 单元格。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 +惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 ## Office 预览 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index ef9a2bad2b..e28dbd9900 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-office-to-pdf": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -72,6 +73,7 @@ "@fortune-sheet/core": "1.0.4", "@fortune-sheet/react": "1.0.4", "@testing-library/react": "^16.1.0", + "@types/papaparse": "^5.3.16", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "clsx": "^2.0.0", @@ -79,9 +81,11 @@ "exceljs": "4.4.0", "fast-xml-parser": "5.11.1", "fflate": "^0.8.3", + "papaparse": "5.5.3", "pdfjs-dist": "6.3.289", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx index 90cf288a01..8657e67114 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx @@ -2,7 +2,8 @@ import { lazy, Suspense, type ReactNode } from 'react' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { DocumentPreviewProps } from '../document/contract.ts' -import type { ExcelLimits } from './convert.ts' +import { excelFormat, type ExcelFormat } from './format.ts' +import type { ExcelLimits } from './model.ts' import { hostFileOf } from '../rpc.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' import css from '../TextPreview.module.css' @@ -10,16 +11,19 @@ import css from '../TextPreview.module.css' /** Document input plus parser limits and localized copy. */ export type ExcelBodyProps = DocumentPreviewProps & PropsLocale<'sidebarExcel'> & { readonly limits: ExcelLimits } +/** Lazily loaded renderer input with the filename-derived parser choice. */ +export type LoadedExcelBodyProps = ExcelBodyProps & { readonly format: ExcelFormat } + const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx')).ExcelBody })) /** - * Show legacy-format guidance or load the browser workbook renderer. + * Load the browser spreadsheet renderer for every registered format. * @param props - Complete file bytes and standard document seats. * @returns Localized loading state or Excel preview. */ export function LazyExcelBody(props: ExcelBodyProps): ReactNode { - if (/\.xls$/iu.test(hostFileOf(props.resourceAddress).path)) return

{props.t('legacy')}

+ const format = excelFormat(hostFileOf(props.resourceAddress).path) return }> - + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts index 40dc53b547..0ad1e5b9a7 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/convert.ts @@ -1,214 +1,21 @@ -/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ -import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' -import { update as formatNumber, type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' -import { XMLParser } from 'fast-xml-parser' - -/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ -export interface ExcelLimits { - /** Maximum compressed workbook bytes. */ - maxBytes: number - /** Maximum combined rectangular cell area across worksheets. */ - maxCells: number - /** Maximum time allowed for one parser Worker. */ - timeoutMs: number -} - -/** Preview data and a count of formulas without saved results. */ -export interface ExcelPreview { - sheets: Sheet[] - missingResults: number -} - -/** Parser failures with locale-independent categories. */ -export class ExcelPreviewError extends Error { - /** @param code - User-actionable parser failure. */ - constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout', options?: ErrorOptions) { - super(code, options) - this.name = 'ExcelPreviewError' - } -} - -const BORDER_STYLES: Record = { - thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, - double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, - mediumDashDotDot: 11, slantDashDot: 12, thick: 13, -} -const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] +/** Format-specific parsing into one read-only FortuneSheet representation. */ +import type { ExcelFormat } from './format.ts' +import { ExcelPreviewError } from './error.ts' +import type { ExcelLimits, ExcelPreview } from './model.ts' +import { convertXlsx } from './xlsx.ts' +import { convertXls } from './xls.ts' +import { convertDelimited } from './delimited.ts' /** - * Decode an XLSX into display cells, retaining formulas without recalculation. - * @param bytes - Complete borrowed workbook bytes. + * Decode spreadsheet bytes without recalculating saved formulas. + * @param bytes - Borrowed complete file bytes. + * @param format - Format selected by the registered filename suffix. * @param limits - File and matrix allocation limits. - * @returns Sheets ready for a read-only FortuneSheet workbook. + * @returns Display sheets and missing formula-cache count. */ -export async function convertExcel(bytes: Uint8Array, limits: ExcelLimits): Promise { +export async function convertExcel(bytes: Uint8Array, format: ExcelFormat, limits: ExcelLimits): Promise { if (bytes.byteLength > limits.maxBytes) throw new ExcelPreviewError('tooLarge') - const workbook = new ExcelJS.Workbook() - try { - await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) - } catch (error) { - throw new ExcelPreviewError('invalid', { cause: error }) - } - if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') - let area = 0 - for (const sheet of workbook.worksheets) { - area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) - if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') - } - const colors = themeColors(workbook) - let missingResults = 0 - let active = false - const sheets = workbook.worksheets.map((worksheet, order): Sheet => { - const config: Required> = { - merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], - } - const celldata: NonNullable = [] - for (const range of worksheet.model.merges) { - // ExcelJS normalizes merged ranges to two cell addresses. - const [start, end] = range.split(':') as [string, string] - const first = worksheet.getCell(start) - const last = worksheet.getCell(end) - const merge = { - r: Number(first.row) - 1, c: Number(first.col) - 1, - rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, - } - config.merge[`${merge.r}_${merge.c}`] = merge - } - // ExcelJS's declarations omit absent parsed collections and style fields. - const columns = worksheet.columns as ExcelJS.Column[] | null - ;(columns ?? []).forEach((column, index) => { - if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) - if (column.hidden) config.colhidden[index] = 0 - }) - worksheet.eachRow({ includeEmpty: true }, (row) => { - const r = row.number - 1 - const height = row.height as number | undefined - if (height !== undefined) config.rowlen[r] = height * 96 / 72 - if (row.hidden) config.rowhidden[r] = 0 - row.eachCell({ includeEmpty: true }, (cell) => { - const c = Number(cell.col) - 1 - const v = convertCell(cell, colors) - if (v.f !== undefined && v.v === undefined) missingResults += 1 - if (cell.isMerged) { - v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } - if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } - } - celldata.push({ r, c, v }) - const border = cell.border as Partial | undefined - if (border !== undefined) { - const value: Record = { row_index: r, col_index: c } - for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { - const edge = border[side] - if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } - } - config.borderInfo.push({ rangeType: 'cell', value }) - } - }) - }) - const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] - const visible = worksheet.state === 'visible' - const status = visible && !active ? 1 : 0 - active ||= visible - const sheet: Sheet = { - id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, - row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), - config, celldata, showGridLines: view?.showGridLines !== false, - defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, - luckysheet_select_save: [{ - row: [0, (config.merge['0_0']?.rs ?? 1) - 1], - column: [0, (config.merge['0_0']?.cs ?? 1) - 1], - row_focus: 0, column_focus: 0, - }], - } - if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) - if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { - sheet.frozen = { - type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', - range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, - } - } - return sheet - }) - return { sheets, missingResults } -} - -function convertCell( - cell: Omit & Partial>, - colors: readonly (string | undefined)[], -): Cell { - const font = cell.font - const alignment = cell.alignment - const result: Cell = fontStyle(font, colors) - const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined - if (bg !== undefined) result.bg = bg - if (alignment !== undefined) { - if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 - result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 - result.tb = alignment.wrapText ? '2' : '1' - if (alignment.textRotation === 'vertical') result.tr = '3' - else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation - } - const value = cell.value - let display: ExcelJS.CellValue = value - if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { - result.f = `=${cell.formula}` - display = value.result ?? null - } - if (display instanceof Date) { - // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. - result.v = display.getTime() / 86_400_000 + 25569 - } else if (display !== null && typeof display === 'object') { - if ('richText' in display) { - result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } - result.v = display.richText.map(run => run.text).join('') - } else if ('text' in display) result.v = display.text - else result.v = (display as ExcelJS.CellErrorValue).error - } else if (display !== null && display !== undefined) result.v = display - if (result.ht === undefined) result.ht = typeof result.v === 'number' ? 2 : typeof result.v === 'boolean' ? 0 : 1 - if (result.ct === undefined) result.ct = { fa: cell.numFmt || 'General', t: typeof result.v === 'number' ? 'n' : typeof result.v === 'boolean' ? 'b' : 's' } - const formatted = typeof result.v === 'number' ? formatNumber(cell.numFmt || 'General', result.v) as string | number : result.v - result.m = formatted === undefined ? '' : String(formatted) - return result -} - -function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { - if (font === undefined) return {} - const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } - if (font.name !== undefined) result.ff = font.name - if (font.size !== undefined) result.fs = font.size - const fc = colorOf(font.color, colors) - if (fc !== undefined) result.fc = fc - return result -} - -function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } - -function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { - if (color === undefined) return undefined - const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) - if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined - // ExcelJS retains OOXML tint even though its Color declaration omits it. - const tint: unknown = Reflect.get(color, 'tint') - if (typeof tint !== 'number' || tint === 0) return `#${rgb}` - return `#${[0, 2, 4].map((offset) => { - const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) - return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') - }).join('')}` -} - -function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { - const themes: unknown = workbook.model.themes - const xml = record(themes).theme1 - if (typeof xml !== 'string') return [] - const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) - const scheme = record(record(record(parsed).theme).themeElements).clrScheme - return THEME_ORDER.map((name) => { - const color = record(record(scheme)[name]) - const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] - return typeof value === 'string' ? value : undefined - }) -} - -function record(value: unknown): Record { - return typeof value === 'object' && value !== null ? value as Record : {} + if (format === 'xlsx') return convertXlsx(bytes, limits) + if (format === 'xls') return convertXls(bytes, limits) + return convertDelimited(bytes, format, limits) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts new file mode 100644 index 0000000000..d66f106cf3 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/delimited.ts @@ -0,0 +1,43 @@ +/** CSV and TSV parsing preserves every field as literal text. */ +import Papa from 'papaparse' +import type { Sheet } from '@fortune-sheet/core' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +/** + * Parse delimited UTF-8 or BOM-marked UTF-16 without type inference. + * @param bytes - Borrowed complete file bytes. + * @param format - Comma or tab-separated input. + * @param limits - Maximum worksheet area. + * @returns A single sheet with literal field values. + */ +export function convertDelimited(bytes: Uint8Array, format: 'csv' | 'tsv', limits: ExcelLimits): ExcelPreview { + const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' + : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8' + let text: string + try { text = new TextDecoder(encoding, { fatal: true }).decode(bytes) } + catch (error) { throw new ExcelPreviewError('encoding', { cause: error }) } + const celldata: NonNullable = [] + let row = 0 + let column = 1 + let failure: ExcelPreviewError | undefined + Papa.parse(text, { + delimiter: format === 'csv' ? ',' : '\t', header: false, dynamicTyping: false, skipEmptyLines: false, + step(result, parser) { + row += 1 + column = Math.max(column, result.data.length) + if (result.errors.length > 0 || row * column > limits.maxCells) { + failure = new ExcelPreviewError(result.errors.length > 0 ? 'invalid' : 'tooLarge') + parser.abort() + return + } + result.data.forEach((value, c) => { celldata.push({ r: row - 1, c, v: formatCell({ v: value }, '@') }) }) + }, + }) + if (failure !== undefined) throw failure + return { + sheets: [{ id: '1', name: format.toUpperCase(), order: 0, status: 1, hide: 0, + row: Math.max(1, row), column, celldata, config: {}, luckysheet_select_save: initialSelection({}) }], + missingResults: 0, + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts new file mode 100644 index 0000000000..e3e6affd62 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/error.ts @@ -0,0 +1,8 @@ +/** Locale-independent spreadsheet parser failure categories. */ +export class ExcelPreviewError extends Error { + /** @param code - User-actionable parser failure. */ + constructor(readonly code: 'invalid' | 'tooLarge' | 'timeout' | 'encoding', options?: ErrorOptions) { + super(code, options) + this.name = 'ExcelPreviewError' + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index f0be53ffb8..2aaa7ad200 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -3,12 +3,13 @@ import { useEffect, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' -import type { ExcelPreview } from './convert.ts' -import type { ExcelBodyProps } from './LazyExcelBody.tsx' +import type { ExcelFormat } from './format.ts' +import type { ExcelPreview } from './model.ts' +import type { LoadedExcelBodyProps } from './LazyExcelBody.tsx' import { parseExcel } from './parse.ts' import css from './ExcelBody.module.css' -type State = { data: Uint8Array } & ({ value: ExcelPreview } | { error: string }) +type State = { data: Uint8Array; format: ExcelFormat } & ({ value: ExcelPreview } | { error: string }) const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` /** @@ -16,31 +17,32 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` * @param props - Complete workbook bytes, limits, and locale. * @returns An isolated spreadsheet surface with cancellable loading. */ -export function ExcelBody({ content, limits, t }: ExcelBodyProps): ReactNode { +export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined + const delimited = format === 'csv' || format === 'tsv' const [state, setState] = useState() const [attempt, setAttempt] = useState(0) useEffect(() => { if (data === undefined) return const controller = new AbortController() setState(undefined) - void parseExcel(data, limits, controller.signal).then( - (value) => { if (!controller.signal.aborted) setState({ data, value }) }, - (error: unknown) => { if (!controller.signal.aborted) setState({ data, error: error instanceof Error ? error.message : 'invalid' }) }, + void parseExcel(data, format, limits, controller.signal).then( + (value) => { if (!controller.signal.aborted) setState({ data, format, value }) }, + (error: unknown) => { if (!controller.signal.aborted) setState({ data, format, error: error instanceof Error ? error.message : 'invalid' }) }, ) return () => { controller.abort() } - }, [data, limits, attempt]) + }, [data, format, limits, attempt]) if (data === undefined) return

{t('invalid')}

- if (state?.data !== data) return + if (state?.data !== data || state.format !== format) return if ('error' in state) return
- {t(state.error === 'tooLarge' ? 'tooLarge' : state.error === 'timeout' ? 'timeout' : 'invalid')} + {t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}
return
-

{t('summary')}

+

{t(delimited ? 'textSummary' : 'summary')}

{state.value.missingResults > 0 &&

{t('missingResults')}

}
ctx.locale.register('sidebarExcel', { zh, en })) const t = ctx.locale.bind('sidebarExcel') ctx.effect(() => ctx.documentPreviews.register({ - id, extensions: ['xlsx', 'xls'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', + id, extensions: ['xlsx', 'xls', 'csv', 'tsv'], binaryExtensions: ['xlsx', 'xls'], priority: 'builtin', title: () => t('title'), loading: 'bytes-complete', wrap: false, })) ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index b25bd503a2..cdcde81ed3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -1,10 +1,12 @@ /** Locale-owned Excel preview controls and parser feedback. */ export const zh = { - title: 'Excel', language: 'zh', loading: '正在打开表格…', - invalid: '无法打开此 Excel 文件。文件可能已损坏或受密码保护。', + title: '表格', language: 'zh', loading: '正在打开表格…', + invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', - legacy: '请将此旧版 Excel 文件另存为 .xlsx 后预览。', + encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', summary: '只读预览 · 显示已保存的公式结果', + textSummary: '只读预览 · 按原始文本显示单元格', + xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。', limitations: '图表、图片、数据透视表和条件格式暂不显示。', missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', retry: '重试', @@ -15,11 +17,13 @@ export type ExcelPreviewKey = keyof typeof zh /** English Excel preview copy. */ export const en = { - title: 'Excel', language: 'en', loading: 'Opening spreadsheet…', - invalid: 'This Excel file could not be opened. It may be damaged or password protected.', + title: 'Spreadsheet', language: 'en', loading: 'Opening spreadsheet…', + invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', - legacy: 'Save this legacy Excel file as .xlsx to preview it.', + encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', summary: 'Read-only preview · Saved formula results', + textSummary: 'Read-only preview · Literal cell text', + xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.', limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', retry: 'Retry', diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts new file mode 100644 index 0000000000..74c8d4637e --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/model.ts @@ -0,0 +1,42 @@ +/** Shared display cells, resource limits, and parser failure categories. */ +import { update as formatNumber, type Cell, type Sheet, type SheetConfig } from '@fortune-sheet/core' + +/** Resource limits applied before allocating FortuneSheet's dense cell matrices. */ +export interface ExcelLimits { + /** Maximum source file bytes. */ + maxBytes: number + /** Maximum combined rectangular cell area across worksheets. */ + maxCells: number + /** Maximum time allowed for one parser Worker. */ + timeoutMs: number +} + +/** Preview data and a count of formulas without saved results. */ +export interface ExcelPreview { + sheets: Sheet[] + missingResults: number +} + +/** + * Retain an addressable A1 selection, including an A1 merge. + * @param config - Worksheet layout. + * @returns The initial selection used when activating a worksheet. + */ +export function initialSelection(config: SheetConfig): NonNullable { + const merge = config.merge?.['0_0'] + return [{ row: [0, (merge?.rs ?? 1) - 1], column: [0, (merge?.cs ?? 1) - 1], row_focus: 0, column_focus: 0 }] +} + +/** + * Apply number formatting and default alignment without evaluating formulas. + * @param cell - Owned display cell to complete. + * @param numberFormat - Excel number format code. + * @returns The same cell with its display text and value type. + */ +export function formatCell(cell: Cell, numberFormat: string): Cell { + if (cell.ht === undefined) cell.ht = typeof cell.v === 'number' ? 2 : typeof cell.v === 'boolean' ? 0 : 1 + if (cell.ct === undefined) cell.ct = { fa: numberFormat, t: typeof cell.v === 'number' ? 'n' : typeof cell.v === 'boolean' ? 'b' : 's' } + const formatted = typeof cell.v === 'number' ? formatNumber(numberFormat, cell.v) as string | number : cell.v + cell.m = formatted === undefined ? '' : String(formatted) + return cell +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts index 3c7eb83bb3..e173d6a8d3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/parse.ts @@ -1,15 +1,19 @@ -/** Browser lifetime for the isolated XLSX parser. */ +/** Browser lifetime for the isolated spreadsheet parser. */ import workerSource from './worker.ts?raw' -import type { ExcelLimits, ExcelPreview } from './convert.ts' +import type { ExcelFormat } from './format.ts' +import type { ExcelLimits, ExcelPreview } from './model.ts' /** * Parse workbook bytes in a disposable Worker, copying the retained preview buffer. * @param bytes - Borrowed complete file bytes. + * @param format - Format selected by the registered filename suffix. * @param limits - File, matrix, and elapsed-time limits. * @param signal - Document body's lifetime. * @returns Parsed sheets; rejects with a locale key on parser failures. */ -export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal: AbortSignal): Promise { +export function parseExcel( + bytes: Uint8Array, format: ExcelFormat, limits: ExcelLimits, signal: AbortSignal, +): Promise { signal.throwIfAborted() if (bytes.byteLength > limits.maxBytes) return Promise.reject(new Error('tooLarge')) return new Promise((resolve, reject) => { @@ -42,7 +46,7 @@ export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, if (message.ok === true && 'value' in message && validPreview(message.value)) { finish() resolve(message.value) - } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout'].includes(String(message.code))) { + } else if (message.ok === false && 'code' in message && ['invalid', 'tooLarge', 'timeout', 'encoding'].includes(String(message.code))) { finish() reject(new Error(String(message.code))) } else fail() @@ -50,7 +54,7 @@ export function parseExcel(bytes: Uint8Array, limits: ExcelLimits, signal.addEventListener('abort', abort, { once: true }) try { const copy = bytes.slice() - worker.postMessage({ bytes: copy, limits }, [copy.buffer]) + worker.postMessage({ bytes: copy, format, limits }, [copy.buffer]) } catch (error) { finish(); reject(new Error('invalid', { cause: error })) } }) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts index 408fa82b10..0fbea7a353 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/worker.ts @@ -1,8 +1,11 @@ /** One workbook per disposable parser Worker; formulas and external links are never executed. */ -import { convertExcel, ExcelPreviewError, type ExcelLimits } from './convert.ts' +import { convertExcel } from './convert.ts' +import type { ExcelFormat } from './format.ts' +import { ExcelPreviewError } from './error.ts' +import type { ExcelLimits } from './model.ts' -globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; limits: ExcelLimits }>) => { - void convertExcel(event.data.bytes, event.data.limits).then( +globalThis.onmessage = (event: MessageEvent<{ bytes: Uint8Array; format: ExcelFormat; limits: ExcelLimits }>) => { + void convertExcel(event.data.bytes, event.data.format, event.data.limits).then( (value) => { globalThis.postMessage({ ok: true, value }) }, (error: unknown) => { globalThis.postMessage({ ok: false, code: error instanceof ExcelPreviewError ? error.code : 'invalid' }) }, ) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts new file mode 100644 index 0000000000..437dc53b0c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts @@ -0,0 +1,98 @@ +/** Legacy BIFF workbooks mapped to saved values and basic spreadsheet layout. */ +import { read, set_cptable, utils, SSF, type CellObject, type WorkBook } from 'xlsx' +import * as codepages from 'xlsx/dist/cpexcel.full.mjs' +import type { Cell, Sheet, SheetConfig } from '@fortune-sheet/core' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +set_cptable(codepages) +// SheetJS publishes SSF as `any`; this is the one formatter capability used by the adapter. +const isDateFormat: (format: string) => boolean = SSF.is_date + +/** + * Decode a binary XLS workbook without accepting renamed text or HTML files. + * @param bytes - Borrowed complete workbook bytes. + * @param limits - Maximum combined worksheet area. + * @returns Saved values, formulas, and supported workbook layout. + */ +export function convertXls(bytes: Uint8Array, limits: ExcelLimits): ExcelPreview { + const compound = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1].every((value, index) => bytes[index] === value) + const biffVersion = bytes.at(1) + const biff = bytes[0] === 0x09 && biffVersion !== undefined && [0x00, 0x02, 0x04, 0x08].includes(biffVersion) + if (!compound && !biff) throw new ExcelPreviewError('invalid') + let workbook: ReturnType + try { workbook = read(bytes, { type: 'array', cellNF: true, cellStyles: true, cellFormula: true, sheetStubs: true }) } + catch (error) { throw new ExcelPreviewError('invalid', { cause: error }) } + return mapXlsWorkbook(workbook, limits) +} + +/** + * Map SheetJS values and optional BIFF layout metadata without retaining the workbook. + * @param workbook - Parsed legacy workbook. + * @param limits - Maximum combined worksheet area. + * @returns FortuneSheet display data. + */ +export function mapXlsWorkbook(workbook: WorkBook, limits: ExcelLimits): ExcelPreview { + let area = 0 + let missingResults = 0 + const visibleSheets = workbook.SheetNames.map((_name, order) => !workbook.Workbook?.Sheets?.[order]?.Hidden) + const activeOrder = visibleSheets.indexOf(true) + if (activeOrder === -1) throw new ExcelPreviewError('invalid') + const sheets = workbook.SheetNames.map((name, order): Sheet => { + const source = workbook.Sheets[name] + if (source === undefined) throw new ExcelPreviewError('invalid') + const range = utils.decode_range(source['!ref'] ?? 'A1') + for (const merge of source['!merges'] ?? []) { + range.e.r = Math.max(range.e.r, merge.e.r) + range.e.c = Math.max(range.e.c, merge.e.c) + } + const row = Math.max(range.e.r + 1, source['!rows']?.length ?? 1) + const column = Math.max(range.e.c + 1, source['!cols']?.length ?? 1) + area += row * column + if (!Number.isSafeInteger(area) || area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, + } + source['!rows']?.forEach((value, index) => { + const height = value.hpx ?? (value.hpt === undefined ? undefined : value.hpt * 96 / 72) + if (height !== undefined) config.rowlen[index] = height + if (value.hidden) config.rowhidden[index] = 0 + }) + source['!cols']?.forEach((value, index) => { + if (value.wpx !== undefined) config.columnlen[index] = value.wpx + if (value.hidden) config.colhidden[index] = 0 + }) + const cells = new Map() + for (const address of Object.keys(source)) { + if (address.startsWith('!')) continue + const cell = source[address] as CellObject + const v: Cell = {} + if (cell.f !== undefined) v.f = `=${cell.f}` + if (cell.t === 'e') v.v = utils.format_cell(cell) + else if (cell.v instanceof Date) v.v = cell.v.getTime() / 86_400_000 + 25569 + else if (cell.t !== 'z' && cell.v !== undefined) { + v.v = cell.v + if (typeof v.v === 'number' && workbook.Workbook?.WBProps?.date1904 && isDateFormat(String(cell.z ?? 'General'))) v.v += 1462 + } + if (v.f !== undefined && v.v === undefined) missingResults += 1 + const { r, c } = utils.decode_cell(address) + cells.set(`${r}_${c}`, { r, c, v: formatCell(v, String(cell.z ?? 'General')) }) + } + for (const { s, e } of source['!merges'] ?? []) { + const merge = { r: s.r, c: s.c, rs: e.r - s.r + 1, cs: e.c - s.c + 1 } + config.merge[`${s.r}_${s.c}`] = merge + for (let r = s.r; r <= e.r; r += 1) for (let c = s.c; c <= e.c; c += 1) { + const key = `${r}_${c}` + const cell = cells.get(key) ?? { r, c, v: {} } + if (r === s.r && c === s.c) cell.v.mc = merge + else cell.v = { mc: { r: s.r, c: s.c } } + cells.set(key, cell) + } + } + const visible = visibleSheets[order] === true + const status = order === activeOrder ? 1 : 0 + return { id: String(order + 1), name, order, status, hide: visible ? 0 : 1, row, column, + config, celldata: [...cells.values()], luckysheet_select_save: initialSelection(config) } + }) + return { sheets, missingResults } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts new file mode 100644 index 0000000000..2a5874939b --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/xlsx.ts @@ -0,0 +1,182 @@ +/** XLSX parsing and FortuneSheet conversion without React or file-service dependencies. */ +import ExcelJS, { type Cell as ExcelCell, type Color, type Font, type BorderStyle } from 'exceljs' +import { type Cell, type CellStyle, type Sheet, type SheetConfig } from '@fortune-sheet/core' +import { XMLParser } from 'fast-xml-parser' +import { ExcelPreviewError } from './error.ts' +import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } from './model.ts' + +const BORDER_STYLES: Record = { + thin: 1, hair: 2, dotted: 3, dashed: 4, dashDot: 5, dashDotDot: 6, + double: 7, medium: 8, mediumDashed: 9, mediumDashDot: 10, + mediumDashDotDot: 11, slantDashDot: 12, thick: 13, +} +const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'] + +/** + * Decode an XLSX into display cells, retaining formulas without recalculation. + * @param bytes - Complete borrowed workbook bytes. + * @param limits - File and matrix allocation limits. + * @returns Sheets ready for a read-only FortuneSheet workbook. + */ +export async function convertXlsx(bytes: Uint8Array, limits: ExcelLimits): Promise { + const workbook = new ExcelJS.Workbook() + try { + await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)) + } catch (error) { + throw new ExcelPreviewError('invalid', { cause: error }) + } + if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid') + let area = 0 + for (const sheet of workbook.worksheets) { + area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount) + if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge') + } + const colors = themeColors(workbook) + let missingResults = 0 + let active = false + const sheets = workbook.worksheets.map((worksheet, order): Sheet => { + const config: Required> = { + merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [], + } + const celldata: NonNullable = [] + for (const range of worksheet.model.merges) { + // ExcelJS normalizes merged ranges to two cell addresses. + const [start, end] = range.split(':') as [string, string] + const first = worksheet.getCell(start) + const last = worksheet.getCell(end) + const merge = { + r: Number(first.row) - 1, c: Number(first.col) - 1, + rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1, + } + config.merge[`${merge.r}_${merge.c}`] = merge + } + // ExcelJS's declarations omit absent parsed collections and style fields. + const columns = worksheet.columns as ExcelJS.Column[] | null + ;(columns ?? []).forEach((column, index) => { + if (column.width !== undefined) config.columnlen[index] = columnPixels(column.width) + if (column.hidden) config.colhidden[index] = 0 + }) + worksheet.eachRow({ includeEmpty: true }, (row) => { + const r = row.number - 1 + const height = row.height as number | undefined + if (height !== undefined) config.rowlen[r] = height * 96 / 72 + if (row.hidden) config.rowhidden[r] = 0 + row.eachCell({ includeEmpty: true }, (cell) => { + const c = Number(cell.col) - 1 + const v = convertCell(cell, colors) + if (v.f !== undefined && v.v === undefined) missingResults += 1 + if (cell.isMerged) { + v.mc = config.merge[`${r}_${c}`] ?? { r: Number(cell.master.row) - 1, c: Number(cell.master.col) - 1 } + if (cell.address !== cell.master.address) { delete v.v; delete v.m; delete v.f } + } + celldata.push({ r, c, v }) + const border = cell.border as Partial | undefined + if (border !== undefined) { + const value: Record = { row_index: r, col_index: c } + for (const [side, key] of [['top', 't'], ['bottom', 'b'], ['left', 'l'], ['right', 'r']] as const) { + const edge = border[side] + if (edge?.style !== undefined) value[key] = { style: BORDER_STYLES[edge.style], color: colorOf(edge.color, colors) ?? '#000000' } + } + config.borderInfo.push({ rangeType: 'cell', value }) + } + }) + }) + const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0] + const visible = worksheet.state === 'visible' + const status = visible && !active ? 1 : 0 + active ||= visible + const sheet: Sheet = { + id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1, + row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount), + config, celldata, showGridLines: view?.showGridLines !== false, + defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72, + luckysheet_select_save: initialSelection(config), + } + if (worksheet.properties.defaultColWidth !== undefined) sheet.defaultColWidth = columnPixels(worksheet.properties.defaultColWidth) + if (view?.state === 'frozen' && (view.xSplit || view.ySplit)) { + sheet.frozen = { + type: view.xSplit && view.ySplit ? 'rangeBoth' : view.ySplit ? 'rangeRow' : 'rangeColumn', + range: { row_focus: (view.ySplit || 0) - 1, column_focus: (view.xSplit || 0) - 1 }, + } + } + return sheet + }) + return { sheets, missingResults } +} + +function convertCell( + cell: Omit & Partial>, + colors: readonly (string | undefined)[], +): Cell { + const font = cell.font + const alignment = cell.alignment + const result: Cell = fontStyle(font, colors) + const bg = cell.fill?.type === 'pattern' && cell.fill.pattern === 'solid' ? colorOf(cell.fill.fgColor, colors) : undefined + if (bg !== undefined) result.bg = bg + if (alignment !== undefined) { + if (alignment.horizontal !== undefined) result.ht = alignment.horizontal === 'center' ? 0 : alignment.horizontal === 'right' ? 2 : 1 + result.vt = alignment.vertical === 'top' ? 1 : alignment.vertical === 'middle' ? 0 : 2 + result.tb = alignment.wrapText ? '2' : '1' + if (alignment.textRotation === 'vertical') result.tr = '3' + else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation + } + const value = cell.value + let display: ExcelJS.CellValue = value + if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + result.f = `=${cell.formula}` + display = value.result ?? null + } + if (display instanceof Date) { + // FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch. + result.v = display.getTime() / 86_400_000 + 25569 + } else if (display !== null && typeof display === 'object') { + if ('richText' in display) { + result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) } + result.v = display.richText.map(run => run.text).join('') + } else if ('text' in display) result.v = display.text + else result.v = (display as ExcelJS.CellErrorValue).error + } else if (display !== null && display !== undefined) result.v = display + return formatCell(result, cell.numFmt || 'General') +} + +function fontStyle(font: Partial | undefined, colors: readonly (string | undefined)[]): CellStyle { + if (font === undefined) return {} + const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 } + if (font.name !== undefined) result.ff = font.name + if (font.size !== undefined) result.fs = font.size + const fc = colorOf(font.color, colors) + if (fc !== undefined) result.fc = fc + return result +} + +function columnPixels(width: number): number { return Math.floor(width < 1 ? width * 12 : width * 7 + 5) } + +function colorOf(color: Partial | undefined, themes: readonly (string | undefined)[]): string | undefined { + if (color === undefined) return undefined + const rgb = color.argb?.slice(-6) ?? (color.theme === undefined ? undefined : themes[color.theme]) + if (rgb === undefined || !/^[\da-f]{6}$/iu.test(rgb)) return undefined + // ExcelJS retains OOXML tint even though its Color declaration omits it. + const tint: unknown = Reflect.get(color, 'tint') + if (typeof tint !== 'number' || tint === 0) return `#${rgb}` + return `#${[0, 2, 4].map((offset) => { + const channel = Number.parseInt(rgb.slice(offset, offset + 2), 16) + return Math.round(Math.max(0, Math.min(255, tint < 0 ? channel * (1 + tint) : channel * (1 - tint) + 255 * tint))).toString(16).padStart(2, '0') + }).join('')}` +} + +function themeColors(workbook: ExcelJS.Workbook): (string | undefined)[] { + const themes: unknown = workbook.model.themes + const xml = record(themes).theme1 + if (typeof xml !== 'string') return [] + const parsed: unknown = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true, processEntities: false }).parse(xml) + const scheme = record(record(record(parsed).theme).themeElements).clrScheme + return THEME_ORDER.map((name) => { + const color = record(record(scheme)[name]) + const value = record(color.srgbClr)['@_val'] ?? record(color.sysClr)['@_lastClr'] + return typeof value === 'string' ? value : undefined + }) +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? value as Record : {} +} diff --git a/packages/client/ui-sidebar-documentpreview/src/config.ts b/packages/client/ui-sidebar-documentpreview/src/config.ts index 8949cf2e9f..67fab69d44 100644 --- a/packages/client/ui-sidebar-documentpreview/src/config.ts +++ b/packages/client/ui-sidebar-documentpreview/src/config.ts @@ -14,9 +14,9 @@ export interface Config { /** Maximum readers including source and renderer metadata lookups. */ maxReaders: number } - /** Browser Excel parser and dense spreadsheet allocation limits. */ + /** Browser spreadsheet parser and dense cell allocation limits. */ excel: { - /** Maximum compressed workbook bytes. */ + /** Maximum source file bytes. */ maxBytes: number /** Maximum combined rectangular cell area across worksheets. */ maxCells: number diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx index ebdb006f5a..fa3bf9aaf2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { Config } from '../src/config.ts' import { en } from '../src/client/excel/locales.ts' -import type { ExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' +import type { ExcelBodyProps, LoadedExcelBodyProps } from '../src/client/excel/LazyExcelBody.tsx' const mocked = vi.hoisted(() => ({ parse: vi.fn(), workbook: vi.fn((_props: unknown) => null) })) vi.mock('../src/client/excel/parse.ts', () => ({ parseExcel: mocked.parse })) @@ -14,17 +14,18 @@ import { ExcelBody } from '../src/client/excel/excel.tsx' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps +const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows loading then a workbook with editing and recalculation disabled', async () => { mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) - const view = render() + const view = render() expect(screen.getByRole('status', { name: en.loading })).toBeDefined() await screen.findByText(en.summary) expect(screen.getByText(en.missingResults)).toBeDefined() expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) - const signal = mocked.parse.mock.calls[0]![2] as AbortSignal + const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.unmount() expect(signal.aborted).toBe(true) }) @@ -32,48 +33,47 @@ it('shows loading then a workbook with editing and recalculation disabled', asyn it('ignores a retired file result and cancels parsing when the bytes change', async () => { let finish!: (value: unknown) => void mocked.parse.mockReturnValueOnce(new Promise((resolve) => { finish = resolve })).mockResolvedValueOnce(value) - const view = render() - const signal = mocked.parse.mock.calls[0]![2] as AbortSignal - view.rerender() + const view = render() + const signal = mocked.parse.mock.calls[0]![3] as AbortSignal + view.rerender() await screen.findByText(en.summary) expect(signal.aborted).toBe(true) finish({ ...value, missingResults: 1 }) await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) }) -it.each(['invalid', 'tooLarge', 'timeout', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { +it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { mocked.parse.mockRejectedValueOnce(new Error(error)).mockResolvedValueOnce(value) - render() - const key = error === 'tooLarge' || error === 'timeout' ? error : 'invalid' + render() + const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid' await screen.findByText(en[key]) fireEvent.click(screen.getByRole('button', { name: en.retry })) await screen.findByText(en.summary) expect(mocked.parse).toHaveBeenCalledTimes(2) }) -it('rejects non-byte content and shows legacy XLS guidance without loading a parser', () => { - const view = render() +it('rejects non-byte content without allocating a parser', () => { + render() expect(screen.getByRole('alert').textContent).toBe(en.invalid) - view.rerender() - expect(screen.getByRole('alert').textContent).toBe(en.legacy) expect(mocked.parse).not.toHaveBeenCalled() }) -it('loads the spreadsheet chunk for an XLSX file', async () => { +it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => { mocked.parse.mockResolvedValue(value) - render() - await screen.findByText(en.summary) + render() + await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary) + expect(mocked.parse.mock.calls[0]![1]).toBe(format) }) it('uses generic guidance for non-Error failures and ignores rejection after unmount', async () => { mocked.parse.mockRejectedValueOnce('parser failure') - const view = render() + const view = render() await screen.findByText(en.invalid) view.unmount() let reject!: (error: Error) => void mocked.parse.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) - const pending = render() - const signal = mocked.parse.mock.calls[1]![2] as AbortSignal + const pending = render() + const signal = mocked.parse.mock.calls[1]![3] as AbortSignal pending.unmount() reject(new Error('closed')) await waitFor(() => { expect(signal.aborted).toBe(true) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 2008eafe53..31a1b35c58 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -11,7 +11,7 @@ const limits = Config({}).excel describe('Excel conversion', () => { it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { const input = await excelFixture() - const { sheets, missingResults } = await convertExcel(input, limits) + const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits) expect(input.byteLength).toBeGreaterThan(0) expect(sheets.map(sheet => sheet.name)).toEqual(['季度预算', '公式与格式', '隐藏页']) const first = sheets[0]! @@ -28,14 +28,14 @@ describe('Excel conversion', () => { const second = sheets[1]! expect(second.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }]) expect(second.celldata).toEqual(expect.arrayContaining([ - expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) as unknown }), - expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) as unknown }), - expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) as unknown }), - expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) as unknown } }) as unknown }), - expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) as unknown }), - expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) as unknown }), - expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) as unknown }), - expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) as unknown }), + expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) }), + expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) }), + expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) }), + expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) } }) }), + expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) }), + expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) }), + expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) }), + expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) }), ])) expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } }) expect(sheets[2]).toMatchObject({ hide: 1, status: 0 }) @@ -43,21 +43,21 @@ describe('Excel conversion', () => { }) it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { - await expect(convertExcel(new Uint8Array([1, 2, 3]), limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) const input = await excelFixture() - await expect(convertExcel(input, { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) + await expect(convertExcel(input, 'xlsx', { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' }) const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Sparse').getCell('Z1000').value = 1 - await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' }) }) it('opens an empty worksheet and rejects workbooks with no visible sheet', async () => { const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Empty') - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]).toMatchObject({ name: 'Empty', row: 1, column: 1, status: 1 }) workbook.getWorksheet('Empty')!.state = 'veryHidden' - await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) }) it.each([false, true])('retains dates for the 1904 epoch flag %s', async (date1904) => { @@ -66,7 +66,7 @@ describe('Excel conversion', () => { const cell = workbook.addWorksheet('Dates').getCell('A1') cell.value = new Date('2024-02-29T12:00:00Z') cell.numFmt = 'yyyy-mm-dd hh:mm' - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]!.celldata![0]!.v).toMatchObject({ m: '2024-02-29 12:00' }) }) @@ -92,7 +92,7 @@ describe('Excel conversion', () => { sheet.getCell('E1').font = { color: { argb: 'FFFF0000', tint: 0 } as Partial } sheet.getCell('F1').value = 42 sheet.getCell('F1').alignment = { vertical: 'middle' } - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]).toMatchObject({ defaultColWidth: 89, config: { columnlen: { 0: 6 } } }) const cells = result.sheets[0]!.celldata! expect(cells[0]!.v).toMatchObject({ fc: '#a7c0de', bg: '#28415f', cl: 1, un: 1, ht: 2, vt: 1, tb: '2', tr: '3' }) @@ -106,7 +106,7 @@ describe('Excel conversion', () => { it.each([{ ySplit: 2 }, { xSplit: 1 }, {}])('maps one-axis or empty freeze settings %j', async (freeze) => { const workbook = new ExcelJS.Workbook() workbook.addWorksheet('Frozen', { views: [{ state: 'frozen', ...freeze, showGridLines: false }] }).getCell('A1').value = 1 - const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), limits) + const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits) expect(result.sheets[0]!.showGridLines).toBe(false) expect(result.sheets[0]!.frozen?.type).toBe('ySplit' in freeze ? 'rangeRow' : 'xSplit' in freeze ? 'rangeColumn' : undefined) }) @@ -114,8 +114,8 @@ describe('Excel conversion', () => { it('accepts workbooks without a theme and ignores absent theme colors', async () => { const files = unzipSync(await excelFixture()) delete files['xl/theme/theme1.xml'] - expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + expect((await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).sheets).toHaveLength(3) files['xl/theme/theme1.xml'] = strToU8('') - expect((await convertExcel(new Uint8Array(zipSync(files)), limits)).sheets).toHaveLength(3) + expect((await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).sheets).toHaveLength(3) }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts new file mode 100644 index 0000000000..6ca70fd926 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts @@ -0,0 +1,140 @@ +/** Additional spreadsheet formats retain layout and literal delimited fields. */ +import { readFileSync } from 'node:fs' +import { utils, write } from 'xlsx' +import { expect, it } from 'vitest' +import { Config } from '../src/config.ts' +import { convertExcel } from '../src/client/excel/convert.ts' +import { excelFormat } from '../src/client/excel/format.ts' +import { mapXlsWorkbook } from '../src/client/excel/xls.ts' +import { legacyWorkbook, xlsFixture } from './xls-fixture.ts' + +const limits = Config({}).excel +const encode = (text: string) => new TextEncoder().encode(text) + +it.each(['xlsx', 'xls', 'csv', 'tsv'] as const)('resolves mixed-case %s paths and enforces byte limits', async (format) => { + expect(excelFormat(`/dir.name/sheet.${format.toUpperCase()}`)).toBe(format) + await expect(convertExcel(new Uint8Array(2), format, { ...limits, maxBytes: 1 })).rejects.toMatchObject({ code: 'tooLarge' }) +}) + +it('rejects unsupported filename suffixes', () => { + expect(() => excelFormat('file.xlsm')).toThrow('invalid') +}) + +it.each([false, true])('reads BIFF values, formulas, merges, layout, and dates with 1904 epoch %s', async (date1904) => { + const { sheets, missingResults } = await convertExcel(xlsFixture(legacyWorkbook(date1904)), 'xls', limits) + expect(sheets.map(sheet => [sheet.name, sheet.hide, sheet.status])).toEqual([['预算', 0, 1], ['明细', 0, 0], ['隐藏', 1, 0]]) + const first = sheets[0]! + const cell = (r: number, c: number) => first.celldata!.find(cell => cell.r === r && cell.c === c)!.v! + expect(cell(0, 0)).toMatchObject({ v: '旧版预算', mc: { r: 0, c: 0, rs: 1, cs: 3 } }) + expect(cell(0, 1)).toEqual({ mc: { r: 0, c: 0 } }) + expect(cell(2, 1)).toMatchObject({ v: 1234.5, m: '1,234.50' }) + expect(cell(2, 2)).toMatchObject({ v: 0.25, m: '25.0%' }) + expect(cell(3, 1)).toMatchObject({ f: '=1+2', v: 3, m: '3' }) + expect(cell(4, 1)).toMatchObject({ m: '2024-03-01' }) + expect(cell(4, 2)).toMatchObject({ v: true }) + expect(first.config).toMatchObject({ colhidden: { 2: 0 } }) + expect(first.config!.columnlen![0]).toBeGreaterThan(100) + expect(first.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 2], row_focus: 0, column_focus: 0 }]) + expect(missingResults).toBe(0) +}) + +it('reads an independently generated Office XLS fixture', async () => { + const bytes = readFileSync(new URL('../../../../apps/web/tests/fixtures/office/preview.xls', import.meta.url)) + const result = await convertExcel(new Uint8Array(bytes), 'xls', limits) + expect(result.sheets.flatMap(sheet => sheet.celldata).some(cell => String(cell?.v?.v).includes('Office preview'))).toBe(true) +}) + +it('maps optional row metadata, empty formula caches, errors, and Date values', () => { + // SheetJS reads row metadata but its BIFF writer omits ROW records. + const workbook = legacyWorkbook() + const sheet = workbook.Sheets['预算']! + sheet['!rows'] = [{ hpt: 30 }, { hpx: 18 }, { hidden: true }, {}] + sheet['!cols'] = [{ wpx: 130 }, { hidden: true }, {}] + sheet['B4'] = { t: 'n', f: '1+2' } + sheet['B5'] = { t: 'd', v: new Date('2024-03-01T00:00:00Z'), z: 'yyyy-mm-dd' } + sheet['C5'] = { t: 'e', v: 7, w: '#DIV/0!' } + const { sheets, missingResults } = mapXlsWorkbook(workbook, limits) + expect(sheets[0]!.config).toMatchObject({ rowlen: { 0: 40, 1: 18 }, rowhidden: { 2: 0 }, columnlen: { 0: 130 }, colhidden: { 1: 0 } }) + expect(sheets[0]!.celldata).toEqual(expect.arrayContaining([ + { r: 3, c: 1, v: expect.objectContaining({ f: '=1+2', m: '' }) }, + { r: 4, c: 1, v: expect.objectContaining({ m: '2024-03-01' }) }, + { r: 4, c: 2, v: expect.objectContaining({ v: '#DIV/0!' }) }, + ])) + expect(missingResults).toBe(1) +}) + +it('rejects damaged, renamed, hidden-only, and oversized legacy workbooks', async () => { + for (const bytes of [encode('a,b\n1,2'), encode('not a workbook'), new Uint8Array([9, 8, 0]), new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1])]) { + await expect(convertExcel(bytes, 'xls', limits)).rejects.toMatchObject({ code: 'invalid' }) + } + const workbook = legacyWorkbook() + const metadata = workbook.Workbook?.Sheets + if (metadata === undefined) throw new Error('legacy fixture has no workbook sheet metadata') + metadata.forEach((sheet) => { sheet.Hidden = 1 }) + await expect(convertExcel(xlsFixture(workbook), 'xls', limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(xlsFixture(), 'xls', { ...limits, maxCells: 10 })).rejects.toMatchObject({ code: 'tooLarge' }) + const sparse = utils.book_new() + utils.book_append_sheet(sparse, { '!ref': 'A1:Z1000', Z1000: { t: 'n', v: 1 } }, 'Sparse') + await expect(convertExcel(xlsFixture(sparse), 'xls', { ...limits, maxCells: 25_999 })).rejects.toMatchObject({ code: 'tooLarge' }) + const missing = utils.book_new() + missing.SheetNames.push('Missing') + expect(() => mapXlsWorkbook(missing, limits)).toThrow('invalid') +}) + +it('opens raw BIFF and empty legacy worksheets', async () => { + const workbook = utils.book_new() + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['BIFF']]), 'Sheet') + const biff = new Uint8Array(write(workbook, { type: 'array', bookType: 'biff2' }) as ArrayBuffer) + expect((await convertExcel(biff, 'xls', limits)).sheets[0]!.celldata![0]!.v!.v).toBe('BIFF') + workbook.Sheets['Sheet'] = {} + expect((await convertExcel(xlsFixture(workbook), 'xls', limits)).sheets[0]).toMatchObject({ row: 1, column: 1, status: 1 }) + const sheet = workbook.Sheets['Sheet'] + if (sheet === undefined) throw new Error('legacy fixture has no Sheet worksheet') + delete sheet['!ref'] + expect(mapXlsWorkbook(workbook, limits).sheets[0]).toMatchObject({ row: 1, column: 1 }) + workbook.Sheets['Sheet'] = { '!ref': 'A1', A1: { t: 'n', v: 12 } } + workbook.Workbook = { WBProps: { date1904: true } } + expect(mapXlsWorkbook(workbook, limits).sheets[0]!.celldata![0]!.v!.v).toBe(12) +}) + +it.each(['csv', 'tsv'] as const)('preserves %s strings, quoted separators, newlines, empty fields, and ragged rows', async (format) => { + const delimiter = format === 'csv' ? ',' : '\t' + const text = ['编号', '说明', '值'].join(delimiter) + '\r\n' + + ['00123', `"中文${delimiter}字段\n第二行 ""引号"""`, '=SUM(1,2)'].map((field, index) => index === 2 ? '"' + field + '"' : field).join(delimiter) + '\r\n' + + ['2024-03-01', '', 'TRUE', '99999999999999999999'].join(delimiter) + '\r\nshort\r\n\r\n' + const result = await convertExcel(encode(text), format, limits) + const sheet = result.sheets[0]! + const cell = (r: number, c: number) => sheet.celldata!.find(cell => cell.r === r && cell.c === c)!.v! + expect(sheet.column).toBe(4) + expect(cell(0, 0).v).toBe('编号') + expect(cell(1, 0)).toMatchObject({ v: '00123', ct: { t: 's', fa: '@' } }) + expect(cell(1, 1).v).toBe(`中文${delimiter}字段\n第二行 "引号"`) + expect(cell(1, 2)).toMatchObject({ v: '=SUM(1,2)', m: '=SUM(1,2)' }) + expect(cell(1, 2)).not.toHaveProperty('f') + expect(cell(2, 0).v).toBe('2024-03-01') + expect(cell(2, 1).v).toBe('') + expect(cell(2, 2).v).toBe('TRUE') + expect(cell(2, 3).v).toBe('99999999999999999999') + expect(cell(3, 0).v).toBe('short') + expect(sheet.row).toBeGreaterThan(4) + expect(result.missingResults).toBe(0) +}) + +it.each(['utf8', 'le', 'be'] as const)('decodes BOM-marked %s text', async (encoding) => { + const text = '\ufeff名称,编号\n中文,001' + const bytes = encoding === 'utf8' ? encode(text) : new Uint8Array(Buffer.from(text, 'utf16le')) + if (encoding === 'be') for (let i = 0; i < bytes.length; i += 2) [bytes[i], bytes[i + 1]] = [bytes[i + 1]!, bytes[i]!] + const sheet = (await convertExcel(bytes, 'csv', limits)).sheets[0]! + expect(sheet.celldata![0]!.v!.v).toBe('名称') + expect(sheet.celldata!.find(cell => cell.r === 1 && cell.c === 0)!.v!.v).toBe('中文') +}) + +it('handles empty text and rejects invalid encoding, broken quoting, and excess area', async () => { + expect((await convertExcel(encode(''), 'csv', limits)).sheets[0]).toMatchObject({ row: 1, column: 1 }) + for (const bytes of [new Uint8Array([0xff]), new Uint8Array([0xff, 0xfe, 1])]) { + await expect(convertExcel(bytes, 'csv', limits)).rejects.toMatchObject({ code: 'encoding' }) + } + await expect(convertExcel(encode('a,"unclosed'), 'csv', limits)).rejects.toMatchObject({ code: 'invalid' }) + await expect(convertExcel(encode('a,b\nc,d'), 'csv', { ...limits, maxCells: 3 })).rejects.toMatchObject({ code: 'tooLarge' }) + expect((await convertExcel(encode('a,b\nc,d'), 'csv', { ...limits, maxCells: 4 })).sheets[0]).toMatchObject({ row: 2, column: 2 }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts index bf57fe5b30..ad0cb07b82 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-parse.client.spec.ts @@ -26,7 +26,7 @@ afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals( it('transfers a private copy and releases the Worker and URL after parsing', async () => { const input = new Uint8Array([1, 2, 3]) - const result = parseExcel(input, limits, new AbortController().signal) + const result = parseExcel(input, 'xlsx', limits, new AbortController().signal) const [message, transfer] = instance.postMessage.mock.calls[0]! expect(message.bytes).toEqual(input) expect(message.bytes).not.toBe(input) @@ -41,14 +41,14 @@ it('transfers a private copy and releases the Worker and URL after parsing', asy it('does not allocate a Worker for aborted or oversized input', async () => { const worker = vi.fn() vi.stubGlobal('Worker', worker) - expect(() => parseExcel(new Uint8Array(), limits, AbortSignal.abort())).toThrow() - await expect(parseExcel(new Uint8Array(2), { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') + expect(() => parseExcel(new Uint8Array(), 'xlsx', limits, AbortSignal.abort())).toThrow() + await expect(parseExcel(new Uint8Array(2), 'xlsx', { ...limits, maxBytes: 1 }, new AbortController().signal)).rejects.toThrow('tooLarge') expect(worker).not.toHaveBeenCalled() }) it('cancels an active parser without retaining callbacks or timers', async () => { const controller = new AbortController() - const result = parseExcel(new Uint8Array(), limits, controller.signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, controller.signal) const rejection = expect(result).rejects.toThrow('closed') controller.abort(new Error('closed')) await rejection @@ -58,7 +58,7 @@ it('cancels an active parser without retaining callbacks or timers', async () => }) it('terminates a parser that exceeds its time budget', async () => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) const rejection = expect(result).rejects.toThrow('timeout') await vi.advanceTimersByTimeAsync(limits.timeoutMs) await rejection @@ -68,14 +68,14 @@ it('terminates a parser that exceeds its time budget', async () => { it.each([null, 7, {}, { ok: true, value: null }, { ok: true, value: { sheets: [], missingResults: 0 } }, { ok: true, value: { sheets: [{}], missingResults: 0 } }, { ok: true, value: { ...preview, missingResults: -1 } }, { ok: false, code: 'surprise' }, { ok: false, code: 'invalid' }])('rejects invalid Worker message %j', async (data) => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) instance.onmessage!({ data }) await expect(result).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() }) it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s', async (event) => { - const result = parseExcel(new Uint8Array(), limits, new AbortController().signal) + const result = parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal) instance[event]!() await expect(result).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() @@ -83,7 +83,7 @@ it.each(['onerror', 'onmessageerror'] as const)('releases a failed parser on %s' it('revokes the URL when Worker construction fails', async () => { vi.stubGlobal('Worker', vi.fn(function () { throw new Error('unavailable') })) - await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + await expect(parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal)).rejects.toThrow('invalid') expect(revokeUrl).toHaveBeenCalledOnce() }) @@ -93,7 +93,7 @@ it('terminates the Worker when posting the copied buffer fails', async () => { instance.postMessage.mockImplementation(() => { throw new Error('transfer failed') }) return instance })) - await expect(parseExcel(new Uint8Array(), limits, new AbortController().signal)).rejects.toThrow('invalid') + await expect(parseExcel(new Uint8Array(), 'xlsx', limits, new AbortController().signal)).rejects.toThrow('invalid') expect(instance.terminate).toHaveBeenCalledOnce() expect(vi.getTimerCount()).toBe(0) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index ca4161c026..4503dbaa97 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -22,15 +22,14 @@ it('registers complete binary reads and removes the slot and locale on disposal' const fiber = ctx.plugin({ apply: (scope) => { apply(scope, limits) } }) try { await fiber.await() - for (const path of ['budget.XLSX', 'legacy.xls']) { + for (const path of ['budget.XLSX', 'legacy.xls', 'table.CSV', 'table.tsv']) { const candidate = registry.candidates(path)[0]! expect(candidate).toMatchObject({ loading: 'bytes-complete', wrap: false, binaryExtensions: ['xlsx', 'xls'] }) - expect(candidate.title()).toBe('Excel') - expect(candidate.read).toBeUndefined() - expect(candidate.bodyId).toBeUndefined() + expect(candidate.title()).toBe('Spreadsheet') + expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path)) } expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en }) - expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) as unknown }), LazyExcelBody) + expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) }), LazyExcelBody) const options = register.mock.calls[0]![0] as { inject: () => unknown } expect(options.inject()).toEqual({ limits }) } finally { await fiber.dispose() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts index 606e7e30a6..e6e6e743a0 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-worker.client.spec.ts @@ -12,7 +12,7 @@ it('returns parsed sheets and bounded failure categories', async () => { await import('../src/client/excel/worker.ts') const handle: (event: MessageEvent) => void = globalThis.onmessage! const message = (bytes: Uint8Array, maxCells = 250_000) => { - handle({ data: { bytes, limits: { ...Config({}).excel, maxCells } } } as MessageEvent) + handle({ data: { bytes, format: 'xlsx', limits: { ...Config({}).excel, maxCells } } } as MessageEvent) } message(await excelFixture()) await vi.waitFor(() => { expect(post).toHaveBeenCalledWith(expect.objectContaining({ ok: true })) }) @@ -32,6 +32,6 @@ it('reports an unexpected parser failure as an invalid workbook', async () => { }) await import('../src/client/excel/worker.ts') const handle: (event: MessageEvent) => void = globalThis.onmessage! - handle({ data: { bytes: new Uint8Array(), limits: Config({}).excel } } as MessageEvent) + handle({ data: { bytes: new Uint8Array(), format: 'xlsx', limits: Config({}).excel } } as MessageEvent) await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts index d583495e9e..d5837ba7a7 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-license-bundle.client.spec.ts @@ -42,8 +42,8 @@ function runPnpm(args: string[], cwd: string, timeout: number): string { : run(entrypoint, args, cwd, timeout) } -describe('published PDF.js licenses', () => { - it.skipIf(!existsSync(bundlePath))('keeps every bundled license in the packed PDF chunk', ({ task }) => { +describe('published document preview licenses', () => { + it.skipIf(!existsSync(bundlePath))('keeps bundled licenses in the packed lazy chunks', ({ task }) => { expect(existsSync(pdfChunkPath)).toBe(true) const output = mkdtempSync(join(tmpdir(), 'dsh-document-preview-pack-')) try { @@ -70,13 +70,18 @@ describe('published PDF.js licenses', () => { expect(excel).toContain('//! Bundled spreadsheet license notices') expect(excel).toContain('Copyright (c) 2022 Suzhou Ruilisi Technology Co., Ltd') expect(excel).toContain('Permission is hereby granted, free of charge') + for (const dependency of ['xlsx', 'papaparse']) { + const root = dirname(require.resolve(dependency === 'xlsx' ? dependency : `${dependency}/package.json`)) + const license = readFileSync(join(root, 'LICENSE'), 'utf8').trimEnd() + expect(excel).toContain(license.split('\n').map(line => `// ${line}`).join('\n')) + } let initialized = false runInNewContext(excel, { window: { __ModuleLoader__: { load: (registration: { factory: (resolve: (specifier: string) => unknown) => { ExcelBody: unknown } }) => { const loaded = registration.factory((specifier) => { if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return {} - if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) as unknown + if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return require(specifier) throw new Error(`Unexpected browser dependency: ${specifier}`) }) expect(typeof loaded.ExcelBody).toBe('function') diff --git a/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts new file mode 100644 index 0000000000..7da6cea5b0 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts @@ -0,0 +1,41 @@ +/** Binary BIFF fixture with saved formulas, dates, merges, and hidden worksheets. */ +import { utils, write, type CellObject, type WorkBook } from 'xlsx' + +/** + * Build a legacy workbook for parser and browser scenarios. + * @param date1904 - Whether date serials use the 1904 epoch. + * @returns A workbook accepted by the BIFF writer. + */ +export function legacyWorkbook(date1904 = false): WorkBook { + const workbook = utils.book_new() + const sheet = utils.aoa_to_sheet([ + ['旧版预算', null, null], ['项目', '金额', '比例'], ['研发', 1234.5, 0.25], + ['公式结果', 3, null], ['日期', date1904 ? 43890 : 45352, true], + ]) + const amount = sheet['B3'] as CellObject | undefined + const ratio = sheet['C3'] as CellObject | undefined + const date = sheet['B5'] as CellObject | undefined + if (amount === undefined || ratio === undefined || date === undefined) throw new Error('legacy fixture cells are missing') + amount.z = '#,##0.00' + ratio.z = '0.0%' + date.z = 'yyyy-mm-dd' + // MS-XLS CellParsedFormula: seven token bytes encode the expression 1+2. + sheet['B4'] = { t: 'n', v: 3, bf: [7, 0, 0x1e, 1, 0, 0x1e, 2, 0, 0x03] } as CellObject & { bf: number[] } + sheet['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }] + sheet['!cols'] = [{ wch: 20 }, { wch: 16 }, { wch: 12, hidden: true }] + sheet['!rows'] = [{ hpt: 30 }, {}, {}, {}, { hidden: true }] + utils.book_append_sheet(workbook, sheet, '预算') + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['Second sheet']]), '明细') + utils.book_append_sheet(workbook, utils.aoa_to_sheet([['Hidden']]), '隐藏') + workbook.Workbook = { WBProps: { date1904 }, Sheets: [{ Hidden: 0, name: '预算' }, { Hidden: 0, name: '明细' }, { Hidden: 1, name: '隐藏' }] } + return workbook +} + +/** + * Serialize the legacy workbook to a real XLS container. + * @param workbook - Fixture workbook, optionally modified by the scenario. + * @returns Complete BIFF8 compound-file bytes. + */ +export function xlsFixture(workbook = legacyWorkbook()): Uint8Array { + return new Uint8Array(write(workbook, { type: 'array', bookType: 'biff8' }) as ArrayBuffer) +} diff --git a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts index 950f7bb930..2f11b42361 100644 --- a/packages/client/ui-sidebar-documentpreview/tsdown.config.ts +++ b/packages/client/ui-sidebar-documentpreview/tsdown.config.ts @@ -17,7 +17,11 @@ function excelLicenseBanner(): string { const fortune = readFileSync(join(import.meta.dirname, 'licenses/FortuneSheet.txt'), 'utf8') const excel = readFileSync(join(dirname(require.resolve('exceljs/package.json')), 'LICENSE'), 'utf8') const xml = readFileSync(join(dirname(dirname(require.resolve('fast-xml-parser'))), 'LICENSE'), 'utf8') - return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') + const csv = readFileSync(join(dirname(require.resolve('papaparse/package.json')), 'LICENSE'), 'utf8') + const xlsRoot = dirname(require.resolve('xlsx')) + const xls = readdirSync(xlsRoot).filter(name => /^(LICENSE|NOTICE)(\.|$)/u.test(name)).sort() + .map(name => readFileSync(join(xlsRoot, name), 'utf8')).join('\n') + return ['//! Bundled spreadsheet license notices', ...`${fortune}\n${excel}\n${xml}\n${csv}\n${xls}`.trimEnd().split('\n').map(line => `// ${line}`)].join('\n') } /** License files for PDF.js and the data embedded beside its runtime. */ @@ -68,10 +72,19 @@ const excelWorker: NonNullable = [{ resolveId(source) { return source === './worker.ts?raw' ? '\0dsh-excel-worker-source' : null }, async load(id) { if (id !== '\0dsh-excel-worker-source') return null + const parent = this const worker = await Rolldown.rolldown({ input: join(import.meta.dirname, 'src/client/excel/worker.ts'), platform: 'browser', resolve: { mainFields: ['browser', 'module', 'main'], aliasFields: [['browser']] }, transform: { define: { 'process.env.NODE_ENV': JSON.stringify('production') } }, + plugins: [{ + name: 'dsh-excel-worker-dependencies', + async resolveId(source, importer) { + // The outer build's license analysis must also see imports embedded in Worker text. + if (importer?.startsWith(join(import.meta.dirname, 'src')) && !source.startsWith('.')) await parent.resolve(source, importer) + return null + }, + }], }) try { const result = await worker.generate({ format: 'iife', minify: true }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f8f5e478a..914620af95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4550,6 +4550,9 @@ importers: '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/papaparse': + specifier: ^5.3.16 + version: 5.5.2 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4571,6 +4574,9 @@ importers: fflate: specifier: ^0.8.3 version: 0.8.3 + papaparse: + specifier: 5.5.3 + version: 5.5.3 pdfjs-dist: specifier: 6.3.289 version: 6.3.289 @@ -4580,6 +4586,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + xlsx: + specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz + version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz packages/client/ui-sidebar-files: devDependencies: @@ -16007,6 +16016,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/picomatch@3.0.2': resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} @@ -18977,6 +18989,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -20323,6 +20338,12 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: + resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz} + version: 0.20.3 + engines: {node: '>=0.8'} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -23062,6 +23083,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 22.20.0 + '@types/picomatch@3.0.2': {} '@types/picomatch@4.0.3': {} @@ -26501,6 +26526,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.5.3: {} + parse-ms@4.0.0: {} parse5@8.0.1: @@ -27985,6 +28012,8 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {} + xml-name-validator@5.0.0: {} xml-naming@0.1.0: {} diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index af06b3f429..0da7b0e270 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -78,8 +78,15 @@ - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent -- Legacy guidance: Save this legacy Excel file as .xlsx to preview it. -- Invalid XLSX: This Excel file could not be opened. It may be damaged or password protected. +- XLS: merged title copied; worksheet selection retained +- Invalid XLS/XLSX: This spreadsheet could not be opened. Check its format, contents, or password protection. + +## Delimited spreadsheets + +- CSV and TSV default to Spreadsheet; Plain text remains selectable +- Copied ID: 00123; copied literal formula: =SUM(1,2) +- Plain-text round trip retains complete source lines +- Reload replaces parsed cells: 00123 -> 00999 ## Unknown suffix From f51092015f1ad2b4f1e0b4c7e0bf0d4ac80992d8 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 12:27:01 +0800 Subject: [PATCH 4/9] refactor(preview): align spreadsheets with shared file preview structure --- .../architecture/2026-09-11-node-office-kit.i18n.yaml | 4 ++-- .../implemented/architecture/2026-09-11-node-office-kit.md | 2 +- .../implemented/architecture/2026-09-11-node-office-kit.zh.md | 2 +- packages/client/ui-sidebar-documentpreview/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-documentpreview/README.md | 2 +- packages/client/ui-sidebar-documentpreview/README.zh.md | 2 +- .../tests/excel-registration.client.spec.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 9f2569bdfb..8e3e128348 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 8a974903f91c75d111a798bd9797120fef547f9f -2026-09-11-node-office-kit.zh.md: f4dd249c8e5d098308bf8f4cce7fcea1202a9efb +2026-09-11-node-office-kit.md: 36897178014daa839399adb5fdf72a4ca53cec8b +2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 8a974903f9..3689717801 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -10,7 +10,7 @@ Binary Office and OOXML files require document layout before they can be preview ## Decision -The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares separate provider and controller entries and the shared Document Preview entry with stable IDs. Conversion and authorized transport remain independently configurable; Office UI shares the Document Preview Loader lifetime. +The [office-to-pdf capability](../../../../packages/document/README.md) delegates conversion to the independently released `@deepseek-ai/libreoffice-kit` Node API. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) owns source maintenance, compatibility versions, and npm distribution. DSH owns Session file authorization, conversion concurrency, private scratch files, output limits, and Remote transport. The [Web bundle](../../../../packages/bundle/web-app/README.md) declares the conversion service and shared Document Preview entry with stable IDs. The service owns authorized conversion Remote methods, whose descriptor is mounted by `api/remotes`; Office UI shares the Document Preview Loader lifetime. The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index f4dd249c8e..1255012bf6 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明独立的 provider、controller 入口和共享文档预览入口。转换与授权传输仍可独立配置;Office UI 共享文档预览的 Loader 生命周期。 +[文档渲染能力](../../../../packages/document/README.zh.md)将转换委托给独立发布的 `@deepseek-ai/libreoffice-kit` Node API。[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)负责源码维护、兼容版本和 npm 分发。DSH 负责 Session 文件授权、转换并发、私有临时文件、输出限制和 Remote 传输。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)使用稳定 ID 声明转换服务与共享文档预览入口。服务负责带授权的转换 Remote 方法,其描述符由 `api/remotes` 挂载;Office UI 共享文档预览的 Loader 生命周期。 [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 01384a6bee..61434cb5b5 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: d4392dc889b1e1cb052412900fe719174aabb508 -README.zh.md: b05257bcf3f816b6d1040a6d5489a545d6d25d5d +README.md: 6f27ddccd3ecde10310ae2c2efca2ec2e3bd5a58 +README.zh.md: c3a95db1683e64d72963d6b3854527e0076683a8 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d4392dc889..6f27ddccd3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -52,7 +52,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. -- **Complete bytes** — PDF, HTML, and common images call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Complete bytes** — PDF, HTML, common images, and spreadsheets call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF and spreadsheet renderers copy retained bytes before Worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. - **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index b05257bcf3..c3a95db168 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -52,7 +52,7 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 -- **完整字节** —— PDF、HTML 和常见图片调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 和表格渲染器在传给 Worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 - **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index 4503dbaa97..f00d3341b6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import { expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { binaryDocumentPath, DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { Config } from '../src/config.ts' import { apply } from '../src/client/excel/index.ts' import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' From 9f6434d24b6456a54ecc04a06b5c9064cde8e01e Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 16:24:00 +0800 Subject: [PATCH 5/9] refactor(web): move Office loading into its injected face --- ...9-08-document-preview-operations.i18n.yaml | 4 +- .../2026-09-08-document-preview-operations.md | 2 +- ...26-09-08-document-preview-operations.zh.md | 2 +- docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 2 +- docs/subsystems/sidebar-right.zh.md | 2 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/office/OfficeBody.tsx | 33 +++------- .../src/client/office/face.ts | 64 +++++++++++++++++++ .../src/client/office/index.ts | 10 ++- .../src/client/office/locales.ts | 4 +- .../tests/office-registration.client.spec.ts | 15 +++-- .../tests/renderer-loading.client.spec.tsx | 63 +++++++++++++++--- .../src/client/slot-catalog.ts | 2 +- scripts/no-unknown-casts.baseline.json | 5 -- 17 files changed, 161 insertions(+), 63 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/face.ts diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index ecb554c14d..6ccdf5d446 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 0b90c17ba94d0c7cd30afb0a6e6a033f8f12d0df -2026-09-08-document-preview-operations.zh.md: ec9c208c97dfe75afdb0915c655d9c1c7b4d42ff +2026-09-08-document-preview-operations.md: 220db6db0474f244f902cdeb89b297856243672b +2026-09-08-document-preview-operations.zh.md: 7b838711e5118b8fcc072e4efb753abfc9756b56 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 0b90c17ba9..220db6db04 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -24,7 +24,7 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat ## Alternatives considered -**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and reports only its displayed source version. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot. +**Load converted content through callbacks in preview metadata.** A callback makes the shared file store hold both original file bytes and format-specific conversion results. Renderer-owned loading keeps conversion caches, failures, and font metadata with Office while preserving shared file identity and toolbar controls. Definitions declare `loading: 'renderer'`; the body receives a format-independent loading revision and delegates reads and store updates to its private injected face. The face reports the displayed source version or a failed load to the shared owner. Reload or implementation replacement advances the revision, stale reports are ignored, and the body cancels pending work on replacement or unmount. Office retains settled contents for the tab lifetime and composes its own PDF child slot. **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index ec9c208c97..7b838711e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -24,7 +24,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界 ## 考虑过的替代方案 -**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。 +**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,并将读取与 store 更新交给其私有注入 face。face 向共享 owner 报告已展示的源版本或加载失败。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index b4d97350d4..07ae54182f 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: bade9a958a62368b84ddfae8b5dbe869dfe11e4a -sidebar-right.zh.md: 93a6f1f25171b19fef5876e353e4b8be46aef78d +sidebar-right.md: 7ec6d931ef92a23cf5cbd647d97e03fb55c32f84 +sidebar-right.zh.md: 413ea2145de19738e939f24778da0765c7aa5424 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index bade9a958a..7ec6d931ef 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -116,7 +116,7 @@ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/docum Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). -A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. +A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body invokes its injected face, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its face writes converted bytes, font metadata, and failures to its own store, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. ## Resource model diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 93a6f1f251..413ea2145d 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -116,7 +116,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 -自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 +自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文调用自己的注入 face,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);其 face 将转换字节、字体元数据和失败写入自己的 store,正文渲染嵌套 PDF 视图。`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 ## 资源模型 diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 61434cb5b5..9c012fb5ea 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 6f27ddccd3ecde10310ae2c2efca2ec2e3bd5a58 -README.zh.md: c3a95db1683e64d72963d6b3854527e0076683a8 +README.md: 8c845b6236c637f340ce7d2be0fdc25f7950dcdc +README.zh.md: 4f1a5f55f35495ba51b55f67a7864396169a2304 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 6f27ddccd3..8c845b6236 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -34,7 +34,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. -`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. +`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected face owns content loading, errors, store updates, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. ## Addresses @@ -102,7 +102,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar
Office implementation — click to expand -Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. +Office registration, loading, caching, and font notices live in `src/client/office/`. The injected Office face writes converted PDF bytes, font metadata, and failures through the declared store actions. The Office body triggers loading, binds cancellation to its lifecycle, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index c3a95db168..4f1a5f55f3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -34,7 +34,7 @@ kind: "package-reference" 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 -`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 +`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入 face 负责内容加载、错误、store 更新和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 ## 地址 @@ -102,7 +102,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
Office 实现——点击展开 -Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 +Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注入的 Office face 通过已声明的 store action 写入转换后的 PDF 字节、字体元数据和失败。Office 正文触发加载,将取消绑定到自身生命周期,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 `documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx index b921f9269f..5915ab3655 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx @@ -1,7 +1,6 @@ -/** Office owns source loading and conversion failures around the shared PDF view. */ +/** Office presents retained conversion results and font notices around the shared PDF view. */ import { useEffect, type ReactNode } from 'react' import type { PropsLocale, PropsRenderSlots, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' -import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { Button, FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' @@ -9,7 +8,7 @@ import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar- import type { DocumentBodyOwner, DocumentPreviewProps } from '../document/contract.ts' import { hostFileOf } from '../rpc.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' -import type { ReadOfficeDocument } from './cache.ts' +import type { OfficeLoadInjected } from './face.ts' import type { OfficeStore } from './store.ts' import common from '../TextPreview.module.css' import css from './OfficeBody.module.css' @@ -28,10 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Office loading callbacks supplied by the registration's services. */ -export interface OfficeBodyInjected { - readonly read: ReadOfficeDocument - /** @param failure - declared file-read failure or conversion exception message. @returns localized display text. */ - readonly describeFailure: (failure: RemoteFailure | { readonly message: string }) => string +export interface OfficeBodyInjected extends OfficeLoadInjected { /** @param tab - owning tab. @param signal - tab lifetime, including hidden bodies. */ readonly retainTab: (tab: TabId, signal: AbortSignal) => void } @@ -47,35 +43,22 @@ export type OfficeBodyProps = DocumentPreviewProps & PropsStore & O */ export function OfficeBody(props: OfficeBodyProps): ReactNode { const { tab } = props.useTabInfo() - const { actions, read, retainTab, describeFailure, resourceAddress, t } = props + const { load, retainTab, resourceAddress, t } = props const request = props.content.kind === 'renderer' ? props.content : undefined const revision = request?.revision const held = props.useStore(state => state.byTab[tab.id]) const view = held?.revision === revision ? held : undefined const settled = view?.file !== undefined || view?.failure !== undefined useEffect(() => { retainTab(tab.id, tab.signal) }, [retainTab, tab.id, tab.signal]) + // Metadata may replace callbacks without advancing the content revision. useEffect(() => { - if (revision === undefined || settled || tab.signal.aborted) return + if (request === undefined || settled || tab.signal.aborted) return const controller = new AbortController() const signal = AbortSignal.any([controller.signal, tab.signal]) - actions.loading(tab.id, revision) - void read(hostFileOf(resourceAddress), signal).then((result) => { - if (signal.aborted) return - if (result.ok) actions.complete(tab.id, revision, result.value) - else actions.failed(tab.id, revision, { code: result.error.code, message: describeFailure(result.error) }) - }, (error: unknown) => { - if (!signal.aborted) actions.failed(tab.id, revision, { - code: 'gateway/internal', message: describeFailure({ message: error instanceof Error ? error.message : String(error) }), - }) - }) + load(tab.id, request.revision, hostFileOf(resourceAddress), signal, request.loaded, request.failed) return () => { controller.abort() } - }, [revision, resourceAddress, tab.id, tab.signal, read, actions, describeFailure, settled]) + }, [revision, resourceAddress, tab.id, tab.signal, load, settled]) const file = view?.file - useEffect(() => { - if (request === undefined) return - if (file !== undefined) request.loaded(file.version) - else if (view?.failure !== undefined) request.failed() - }, [file, view?.failure, request]) if (request === undefined) return null if (view?.failure !== undefined) { const { name } = pathPartsOf(resourceAddress) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts new file mode 100644 index 0000000000..48d953ba67 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/face.ts @@ -0,0 +1,64 @@ +/** Office conversion reads and settlements bound to the renderer's declared store. */ +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionFile } from '../rpc.ts' +import type { ReadOfficeDocument } from './cache.ts' +import type { OfficeStore } from './store.ts' + +/** Conversion loading callback supplied to the Office body through Slot injection. */ +export interface OfficeLoadInjected { + /** + * Read one conversion revision; cancellation suppresses its store writes and version report. + * @param tabId - owning tab. + * @param revision - requested content revision. + * @param file - addressed Session and source path. + * @param signal - request lifetime, cancelled on replacement, unmount, or tab closure. + * @param loaded - report the displayed source version to the document owner. + * @param failed - report a failed load to the document owner. + */ + readonly load: ( + tabId: TabId, + revision: number, + file: SessionFile, + signal: AbortSignal, + loaded: (version: string) => void, + failed: () => void, + ) => void +} + +/** + * Bind Office reads to store actions without exposing Remote work to the component. + * @param read - authorized conversion reader, including Client cache reuse. + * @param describeFailure - localized conversion or source-access failure text. + * @returns Slot inject factory; the file address supplies the read's Session authority. + */ +export function officeFace( + read: ReadOfficeDocument, + describeFailure: (failure: RemoteFailure | { readonly message: string }) => string, +): (sessionId: SessionId, actions: BoundActions) => OfficeLoadInjected { + return (_sessionId, actions) => ({ + load: (tabId, revision, file, signal, loaded, failed) => { + if (signal.aborted) return + actions.loading(tabId, revision) + void read(file, signal).then((result) => { + if (signal.aborted) return + if (result.ok) { + actions.complete(tabId, revision, result.value) + loaded(result.value.version) + } else { + actions.failed(tabId, revision, { code: result.error.code, message: describeFailure(result.error) }) + failed() + } + }, (error: unknown) => { + if (!signal.aborted) { + actions.failed(tabId, revision, { + code: 'gateway/internal', message: describeFailure({ message: error instanceof Error ? error.message : String(error) }), + }) + failed() + } + }) + }, + }) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 098e5dd06c..34dcd60c83 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -14,6 +14,7 @@ import { pdfBodyRegistration } from '../pdf/index.ts' import { LazyPdfBody } from '../pdf/LazyPdfBody.tsx' import { OfficeBody, type OfficeBodyInjected } from './OfficeBody.tsx' import { OfficeFontAction } from './OfficeFontAction.tsx' +import { officeFace } from './face.ts' import { createOfficeStore } from './store.ts' import type { Config } from '../../config.ts' @@ -49,14 +50,17 @@ export function apply(ctx: Context, config: Config['office']): void { }, OfficeFontAction))) const retainTab = retainDocumentTabs(ctx) const documentT = ctx.locale.bind('sidebarDocumentPreview') + const face = officeFace( + (file, signal) => read(file, signal), + failure => 'code' in failure ? failureLine(documentT, failure) : documentT('error.unavailable', { message: failure.message }), + ) ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ name: 'sidebar.right.tab.document', key: id, locale: 'sidebarOffice', store, children: { 'sidebar.right.tab.document.office.pdf': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } }, } }, - inject: (_sessionId, actions): OfficeBodyInjected => ({ - read: (file, signal) => read(file, signal), - describeFailure: failure => 'code' in failure ? failureLine(documentT, failure) : documentT('error.unavailable', { message: failure.message }), + inject: (sessionId, actions): OfficeBodyInjected => ({ + ...face(sessionId, actions), retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) }, }), }, OfficeBody))) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts index 5e83803eae..653afc3062 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -1,6 +1,6 @@ /** Office preview copy and Host render configuration guidance. */ export const zh = { - title: 'Office 文档', + title: 'Office PDF', loading: '正在读取…', retry: '重试', viewMissingFonts: '缺失 {count} 种字体,点击查看', @@ -22,7 +22,7 @@ export type OfficePreviewKey = keyof typeof zh /** English translations checked against the Chinese key set. */ export const en = { - title: 'Office document', + title: 'Office PDF', loading: 'Reading…', retry: 'Retry', viewMissingFonts: 'Missing fonts: {count}. Click to view.', diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index ffa492509f..bed2275f85 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-office-to-pdf/remote' import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { apply } from '../src/client/office/index.ts' +import { officeFace } from '../src/client/office/face.ts' import { Config } from '../src/config.ts' import { OfficeBody, type OfficeBodyInjected } from '../src/client/office/OfficeBody.tsx' import type { OfficeStore } from '../src/client/office/store.ts' @@ -15,6 +16,11 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { en, zh } from '../src/client/office/locales.ts' import { en as documentEn } from '../src/client/locales.ts' +vi.mock('../src/client/office/face.ts', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, officeFace: vi.fn(actual.officeFace) } +}) + const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' } const generation = ('renderer' as OfficeToPdfGeneration) const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' } @@ -40,8 +46,8 @@ it('retains Office view state across remounts and releases it on tab close or pl h.instance.actions.loading(second, 1) h.injected.retainTab(second, retained.signal) const message = documentEn['error.unavailable'].replace('{message}', 'conversion stopped') - expect(h.injected.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message) - expect(h.injected.describeFailure({ message: 'conversion stopped' })).toBe(message) + expect(h.describeFailure(new RemoteError('gateway/internal', 'conversion stopped', {}))).toBe(message) + expect(h.describeFailure({ message: 'conversion stopped' })).toBe(message) await h.close() expect(h.instance.getSnapshot().byTab[second]).toBeUndefined() } finally { closed.abort(); retained.abort(); await h.close() } @@ -74,9 +80,10 @@ async function harness(config: Partial = {}, missing?: 'remote const entry = recorded.find(entry => entry.component === OfficeBody)!.options const instance = entry.store.create() const injected = entry.inject(file.sessionId, instance.actions) - return { ctx, registry, instance, injected, recorded, locale, removeLocale, render, rendererGeneration, + const [read, describeFailure] = vi.mocked(officeFace).mock.calls.at(-1)! + return { ctx, registry, instance, injected, describeFailure, recorded, locale, removeLocale, render, rendererGeneration, stat, readBytes, register, removeNotice, - read: (signal = new AbortController().signal, path = file.path) => injected.read({ ...file, path }, signal), + read: (signal = new AbortController().signal, path = file.path) => read({ ...file, path }, signal), close: () => fiber.dispose(), } } diff --git a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx index 472a8192f2..8566142c5c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx @@ -11,10 +11,11 @@ import type { DocumentContent } from '../src/client/document/contract.ts' import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx' import { OfficeFontAction, type OfficeFontActionProps } from '../src/client/office/OfficeFontAction.tsx' import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx' +import { officeFace } from '../src/client/office/face.ts' import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts' import type { ReadOfficeDocument } from '../src/client/office/cache.ts' import { en } from '../src/client/office/locales.ts' -import { harness, ABSOLUTE_PATH, TAB_ID, settle } from './fixtures.client.ts' +import { harness, ABSOLUTE_PATH, ADDRESS, TAB_ID, SESSION, settle } from './fixtures.client.ts' beforeEach(() => { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) @@ -100,12 +101,12 @@ function setup() { function useOffice(selector: (state: OfficeState) => T): T { return selector(useSyncExternalStore(subscribe, snapshot)) } - const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message + const injected = officeFace(read, error => error.message)(SESSION, office.actions) let request: Extract | undefined const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => { if (key === 'sidebar.right.tab.document.action') { return , useTabInfo: options?.hookContext, useStore: useOffice, - actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} /> + actions: office.actions, t: makeTranslate(en) } as OfficeFontActionProps} /> } if (key !== 'sidebar.right.tab.document') return null const owner = input as OwnerOf<'sidebar.right.tab.document'> @@ -113,16 +114,16 @@ function setup() { request = owner.content // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario. const props = { ...h.props(), ...owner, useTabInfo: options?.hookContext, useStore: useOffice, - actions: office.actions, read, retainTab, describeFailure, + actions: office.actions, ...injected, retainTab, t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (

{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}

), - } as unknown as OfficeBodyProps + } as OfficeBodyProps return } - function View({ renderer = true }: { renderer?: boolean }) { + function View({ renderer = true, implementation = definition.id }: { renderer?: boolean; implementation?: string }) { return selector([renderer ? definition : { ...definition, id: 'raw', loading: 'bytes-complete' }])} /> + useDocumentPreviews={selector => selector([renderer ? { ...definition, id: implementation } : { ...definition, id: 'raw', loading: 'bytes-complete' }])} /> } onTestFinished(async () => { h.controller.abort() @@ -240,8 +241,10 @@ it.each(['declared', 'exception', 'foreign'] as const)('shows %s conversion fail it('starts no conversion when a body receives ordinary shared content', () => { const h = setup() const props = { ...h.h.props(), content: { kind: 'bytes', data: new Uint8Array() }, - useStore: () => undefined, read: h.read, retainTab: vi.fn(), describeFailure: vi.fn(), - } as unknown as OfficeBodyProps + resourceAddress: ADDRESS, wrap: false, scrollportRef: vi.fn(), + addResource: vi.fn(), setResources: vi.fn(), + useStore: () => undefined, actions: h.office.actions, load: vi.fn(), retainTab: vi.fn(), t: makeTranslate(en), + } as OfficeBodyProps const view = render() expect(view.container.childElementCount).toBe(0) expect(h.read).not.toHaveBeenCalled() @@ -269,3 +272,45 @@ it('shows the current revision’s missing fonts in the toolbar and clears stale expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() expect(screen.queryByRole('dialog')).toBeNull() }) + +it('isolates a replacement implementation with the same renderer-owned loading mode', async () => { + const h = setup() + const view = render() + const previous = h.request() + view.rerender() + expect(h.pending[0]!.signal.aborted).toBe(true) + expect(h.pending).toHaveLength(2) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') }) + expect(screen.getByText('PDF v2')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.contentRendererId).toBe('alternative') + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') +}) + +it('starts no conversion or store write for an already closed request', () => { + const read = vi.fn() + const store = createOfficeStore().create() + const face = officeFace(read, error => error.message)(SESSION, store.actions) + const controller = new AbortController() + controller.abort() + const loaded = vi.fn() + const failed = vi.fn() + face.load(TAB_ID, 1, { sessionId: SESSION, path: ABSOLUTE_PATH }, controller.signal, loaded, failed) + expect(read).not.toHaveBeenCalled() + expect(loaded).not.toHaveBeenCalled() + expect(failed).not.toHaveBeenCalled() + expect(store.getSnapshot().byTab[TAB_ID]).toBeUndefined() +}) + +it('keeps a pending conversion when metadata changes while automatic refresh is paused', async () => { + const h = setup() + const view = render() + act(() => { h.h.instance.actions.toggledAutoRefresh(TAB_ID) }) + h.h.setVersion('v2') + view.rerender() + expect(h.read).toHaveBeenCalledTimes(1) + expect(h.pending[0]!.signal.aborted).toBe(false) + await act(async () => { h.pending[0]!.deferred.resolve(result('v1')) }) + expect(screen.getByText('PDF v1')).toBeTruthy() + expect(screen.getByText('changed')).toBeTruthy() +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 2954e2b935..7660b40523 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3115,7 +3115,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.office.pdf\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.office.pdf\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:20', + source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:19', }, { key: 'sidebar.right.tab.document.unpreviewable', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 3a90b2606a..0cdde48383 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -744,11 +744,6 @@ "193e777770c611bf1005e2d5ce574b13e1469926c169812107cf091c73772c0b": 1, "ed59d2814914c377dda69ab030e0b51de776a34205ab85e6364fda6fbe903de5": 1 }, - "packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx": { - "3729313792d93c55cad1437e7ca1c5f9d5d6d4e02ab0000b0a3b01246504f898": 1, - "98dcb2e285933d18a1bc95899f5a46d545e3dba0873640e1920e711a254082dc": 1, - "a3a3404b2792d2353dc869625840b3166064d6f34100f366940ac686376783f8": 1 - }, "packages/client/ui-sidebar-documentpreview/tests/store.client.spec.ts": { "9173285d1c600b0eeb00bc3f852d2e3c8c46ea83cd36488a422d5fd05194935a": 1 }, From 15415600d1bd0c36a6ace17674747d9d19acb8d1 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 17:03:53 +0800 Subject: [PATCH 6/9] fix(preview): render spreadsheet values as literal text --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 6 ++- .../2026-09-16-browser-excel-preview.zh.md | 6 ++- THIRD_PARTY_NOTICES.md | 1 + apps/web/tests/document-preview.e2e.ts | 22 +++++++- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../tests/excel-fixture.ts | 15 ++++++ patches/@fortune-sheet__core@1.0.4.patch | 26 ++++++++++ patches/@fortune-sheet__react@1.0.4.patch | 50 ++++++++++++++++--- pnpm-lock.yaml | 13 ++--- pnpm-workspace.yaml | 1 + .../web/document-preview/document.expected.md | 1 + 14 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 patches/@fortune-sheet__core@1.0.4.patch diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 5e63afa914..2c6432adcf 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 6a33fbccdace9172fcee0863519b5c05d0595c7b -2026-09-16-browser-excel-preview.zh.md: 190102ed86925b28dea434d3c2f3ded4de11ddf6 +2026-09-16-browser-excel-preview.md: c31c563664d9c888a85d8b7fcb7f3abb50422f44 +2026-09-16-browser-excel-preview.zh.md: 09e24a28a76d00e60357b1c1acacdf82943fe567 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 6a33fbccda..c31c563664 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -16,7 +16,11 @@ Preview is read-only and retains formula text alongside saved results. It does n A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. -The pinned FortuneSheet React dependency carries a [small patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry; the CommonJS entry receives the same fixes. Recheck this patch when upgrading FortuneSheet. +The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry. + +The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with ` writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))), writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'literal-html.xlsx'), await excelHtmlFixture()), writeFile(join(cwd, 'budget.xls'), xlsFixture()), writeFile(join(cwd, 'table.csv'), '00123,"中文,字段","=SUM(1,2)"\n2024-03-01,,TRUE'), writeFile(join(cwd, 'table.tsv'), '00123\t"中文\t字段"\t=SUM(1,2)\n2024-03-01\t\tTRUE'), @@ -784,6 +785,24 @@ else process.exit(1); await excel.getByText('明细', { exact: true }).click() await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') await successShot(page, 'excel-legacy') + await openFile('literal-html.xlsx') + const formulaInput = excel.locator('.fortune-fx-input') + for (const [sheet, formula, copied] of [ + ['Formula', `="${excelHtmlText}"`, 'saved result'], + ['Text', excelHtmlText, excelHtmlText], + ['Cached text', '="cached"', excelHtmlText], + ] as const) { + await excel.getByText(sheet, { exact: true }).click() + await expect.poll(() => formulaInput.textContent()).toBe(formula) + expect(await formulaInput.locator('img').count()).toBe(0) + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(`${copied}\n`) + const clipboardTable = excel.locator('#fortune-copy-content table') + expect(await clipboardTable.locator('td').textContent()).toBe(copied) + expect(await clipboardTable.locator('img').count()).toBe(0) + expect(await page.evaluate(() => document.documentElement.dataset.spreadsheetHtml)).toBeUndefined() + } const invalidExcel = 'This spreadsheet could not be opened. Check its format, contents, or password protection.' for (const extension of ['xls', 'xlsx']) { await openFile(`unavailable.${extension}`) @@ -795,6 +814,7 @@ else process.exit(1); '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', '- Formula bar is read-only; PDF body and editing toolbar absent', + '- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML', '- XLS: merged title copied; worksheet selection retained', `- Invalid XLS/XLSX: ${invalidExcel}`, ].join('\n')) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 9c012fb5ea..767eda39c1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 8c845b6236c637f340ce7d2be0fdc25f7950dcdc -README.zh.md: 4f1a5f55f35495ba51b55f67a7864396169a2304 +README.md: 7e9650c3e65763dfe5536077e19adffd7cc70e02 +README.zh.md: d391c95f6b942596c5665022448189d6a70aa2b1 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 8c845b6236..7e9650c3e6 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -78,7 +78,9 @@ Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits c | `excel.maxCells` | `250000` | Maximum combined rectangular worksheet area, including empty cells | | `excel.timeoutMs` | `15000` | Maximum parser Worker lifetime in milliseconds | -The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. +The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ExcelJS decodes entity spellings in cached XLSX string formula results again; a saved literal `<` is displayed as `<`. + +The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency. ## Office preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 4f1a5f55f3..d391c95f6b 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -78,7 +78,9 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别 | `excel.maxCells` | `250000` | 所有工作表矩形区域的最大合计单元格数,包含空单元格 | | `excel.timeoutMs` | `15000` | 解析 Worker 的最长存活时间,单位毫秒 | -惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。 +惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。ExcelJS 会再次解码 XLSX 字符串公式缓存结果中的实体写法;保存的字面文本 `<` 会显示为 `<`。 + +只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。 ## Office 预览 diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index 41ecf38986..ef9eefc42b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -47,3 +47,18 @@ export async function excelFixture(): Promise> { workbook.addWorksheet('隐藏页', { state: 'hidden' }).getCell('A1').value = 'Hidden data' return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Cell text whose markup and ampersand must remain literal in the preview and clipboard. */ +export const excelHtmlText = '& literal' + +/** + * Build a workbook carrying HTML-looking formulas, text, and saved formula results. + * @returns Complete XLSX file bytes. + */ +export async function excelHtmlFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Formula').getCell('A1').value = { formula: `"${excelHtmlText}"`, result: 'saved result' } + workbook.addWorksheet('Text').getCell('A1').value = excelHtmlText + workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText } + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/patches/@fortune-sheet__core@1.0.4.patch b/patches/@fortune-sheet__core@1.0.4.patch new file mode 100644 index 0000000000..64457ac131 --- /dev/null +++ b/patches/@fortune-sheet__core@1.0.4.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.esm.js b/dist/index.esm.js +index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3af3bfadc 100644 +--- a/dist/index.esm.js ++++ b/dist/index.esm.js +@@ -62866,7 +62866,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { + if (_.isNil(c_value)) { + c_value = ""; + } +- column += escapeHTMLTag(c_value); ++ column += _.escape(c_value); + } else { + var _style = ""; + if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { +diff --git a/dist/index.js b/dist/index.js +index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02546b0b5b 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -62876,7 +62876,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { + if (___default['default'].isNil(c_value)) { + c_value = ""; + } +- column += escapeHTMLTag(c_value); ++ column += ___default['default'].escape(c_value); + } else { + var _style = ""; + if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { diff --git a/patches/@fortune-sheet__react@1.0.4.patch b/patches/@fortune-sheet__react@1.0.4.patch index dd76b04e78..f9d10c03ed 100644 --- a/patches/@fortune-sheet__react@1.0.4.patch +++ b/patches/@fortune-sheet__react@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb87459dbe7 100644 +index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..c76ab678d776641131f582a194b5008a73a19bf2 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -921,7 +921,7 @@ var InputBox = function InputBox() { @@ -11,7 +11,26 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 setContext(function (ctx) { var flowdata = getFlowdata(ctx); if (!_.isNil(flowdata) && ctx.forceFormulaRef) { -@@ -6908,7 +6908,8 @@ var SheetItem = function SheetItem(_ref) { +@@ -6733,12 +6733,16 @@ var FxEditor = function FxEditor() { + if (isInlineStringCell(cell)) { + value = getInlineStringNoStyle(r, c, d); + } else if (cell.f) { +- value = getCellValue(r, c, d, "f"); ++ value = context.allowEdit === false ? cell.f : getCellValue(r, c, d, "f"); + } else { + value = valueShowEs(r, c, d); + } + } +- refs.fxInput.current.innerHTML = escapeHTMLTag(escapeScriptTag(value)); ++ if (context.allowEdit === false) { ++ refs.fxInput.current.textContent = value; ++ } else { ++ refs.fxInput.current.innerHTML = escapeHTMLTag(escapeScriptTag(value)); ++ } + } else { + refs.fxInput.current.innerHTML = ""; + } +@@ -6908,7 +6912,8 @@ var SheetItem = function SheetItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -21,7 +40,7 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 } draftCtx.luckysheet_selection_range = []; }); -@@ -10693,7 +10694,8 @@ var SheetListItem = function SheetListItem(_ref) { +@@ -10693,7 +10698,8 @@ var SheetListItem = function SheetListItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -32,7 +51,7 @@ index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..173172b84976b1e25bcca1fb0faa2bb8 draftCtx.luckysheet_selection_range = []; }); diff --git a/dist/index.js b/dist/index.js -index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c002653a92b 100644 +index e4045518773578e3faaa3a58985a5585f9683088..e559e7a600365993a2994746c84e56f64bbf0033 100644 --- a/dist/index.js +++ b/dist/index.js @@ -932,7 +932,7 @@ var InputBox = function InputBox() { @@ -44,7 +63,26 @@ index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c00 setContext(function (ctx) { var flowdata = core.getFlowdata(ctx); if (!___default['default'].isNil(flowdata) && ctx.forceFormulaRef) { -@@ -6919,7 +6919,8 @@ var SheetItem = function SheetItem(_ref) { +@@ -6744,12 +6744,16 @@ var FxEditor = function FxEditor() { + if (core.isInlineStringCell(cell)) { + value = core.getInlineStringNoStyle(r, c, d); + } else if (cell.f) { +- value = core.getCellValue(r, c, d, "f"); ++ value = context.allowEdit === false ? cell.f : core.getCellValue(r, c, d, "f"); + } else { + value = core.valueShowEs(r, c, d); + } + } +- refs.fxInput.current.innerHTML = core.escapeHTMLTag(core.escapeScriptTag(value)); ++ if (context.allowEdit === false) { ++ refs.fxInput.current.textContent = value; ++ } else { ++ refs.fxInput.current.innerHTML = core.escapeHTMLTag(core.escapeScriptTag(value)); ++ } + } else { + refs.fxInput.current.innerHTML = ""; + } +@@ -6919,7 +6923,8 @@ var SheetItem = function SheetItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; @@ -54,7 +92,7 @@ index e4045518773578e3faaa3a58985a5585f9683088..5b89eaa8e5f78e3ddc204f4b88b25c00 } draftCtx.luckysheet_selection_range = []; }); -@@ -10704,7 +10705,8 @@ var SheetListItem = function SheetListItem(_ref) { +@@ -10704,7 +10709,8 @@ var SheetListItem = function SheetListItem(_ref) { draftCtx.scrollLeft = 0; draftCtx.scrollTop = 0; draftCtx.luckysheet_select_status = false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914620af95..77aecfe1da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,8 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/react@1.0.4': 7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6 + '@fortune-sheet/core@1.0.4': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0 + '@fortune-sheet/react@1.0.4': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -4543,10 +4544,10 @@ importers: version: link:../../util/workspace-path '@fortune-sheet/core': specifier: 1.0.4 - version: 1.0.4 + version: 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) '@fortune-sheet/react': specifier: 1.0.4 - version: 1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -21551,7 +21552,7 @@ snapshots: bessel: 1.0.2 jstat: 1.9.6 - '@fortune-sheet/core@1.0.4': + '@fortune-sheet/core@1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)': dependencies: '@fortune-sheet/formula-parser': 0.2.13 dayjs: 1.11.21 @@ -21565,9 +21566,9 @@ snapshots: '@formulajs/formulajs': 2.9.3 tiny-emitter: 2.1.0 - '@fortune-sheet/react@1.0.4(patch_hash=7863d250d98c4d56197e88baae7f06423dae0581995e1755f98506da14a29ca6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@fortune-sheet/react@1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@fortune-sheet/core': 1.0.4 + '@fortune-sheet/core': 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) '@types/regenerator-runtime': 0.13.8 immer: 9.0.21 lodash: 4.18.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fd9e8fd510..5480d37563 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -97,6 +97,7 @@ minimumReleaseAgeExclude: patchedDependencies: '@earendil-works/pi-ai@0.85.1': patches/@earendil-works__pi-ai@0.85.1.patch '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch + '@fortune-sheet/core@1.0.4': patches/@fortune-sheet__core@1.0.4.patch '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 0da7b0e270..0ae42ec777 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -78,6 +78,7 @@ - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent +- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML - XLS: merged title copied; worksheet selection retained - Invalid XLS/XLSX: This spreadsheet could not be opened. Check its format, contents, or password protection. From 767cb38567cfb01ef63752e26376d7f79e9214fb Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 13:08:10 +0800 Subject: [PATCH 7/9] fix(locales): update titles for Office document in Chinese and English --- .../ui-sidebar-documentpreview/src/client/office/locales.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts index 653afc3062..5e83803eae 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts @@ -1,6 +1,6 @@ /** Office preview copy and Host render configuration guidance. */ export const zh = { - title: 'Office PDF', + title: 'Office 文档', loading: '正在读取…', retry: '重试', viewMissingFonts: '缺失 {count} 种字体,点击查看', @@ -22,7 +22,7 @@ export type OfficePreviewKey = keyof typeof zh /** English translations checked against the Chinese key set. */ export const en = { - title: 'Office PDF', + title: 'Office document', loading: 'Reading…', retry: 'Retry', viewMissingFonts: 'Missing fonts: {count}. Click to view.', From ad9f77db04a1edeb4e7eb91a95c4e08a3f30a2e3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 14:28:15 +0800 Subject: [PATCH 8/9] fix(preview): compact spreadsheet formula warning --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +-- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 32 ++++++++++++++++--- .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/excel/ExcelBody.module.css | 30 +++++++++++++---- .../src/client/excel/excel.tsx | 13 +++++--- .../src/client/excel/locales.ts | 12 ++----- .../tests/excel-body.client.spec.tsx | 20 ++++++------ .../tests/excel-convert.client.spec.ts | 15 +++++++-- .../tests/excel-fixture.ts | 28 ++++++++++++++++ .../web/document-preview/document.expected.md | 2 ++ 14 files changed, 122 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 2c6432adcf..f279a70f08 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: c31c563664d9c888a85d8b7fcb7f3abb50422f44 -2026-09-16-browser-excel-preview.zh.md: 09e24a28a76d00e60357b1c1acacdf82943fe567 +2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435 +2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index c31c563664..5631906931 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -12,7 +12,7 @@ PDF conversion loses spreadsheet navigation and the relationship between a formu The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX, XLS, CSV, and TSV with FortuneSheet and package-local adapters for ExcelJS, SheetJS CE, and PapaParse. The adapter accepts bytes, format, and limits and returns worksheet data; it has no React state setters, timers, or component references. Dependencies own file parsing, while the adapters share display formatting and initial selection without introducing another workbook model. CSV/TSV retain literal strings and a plain-text option; implicit type inference could otherwise discard leading zeros or interpret source text as formulas. The shared document reader calls `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)` and receives native bytes through the binary Remote, retaining Session authorization, file versions, reload, and transient bytes without base64 decoding. Workspace Files owns bounded file reads; format parsing stays in the Client. The registration marks only XLSX/XLS as `binaryExtensions`, so the shared document container offers Plain text for CSV/TSV. Each renderer registers its body under its own id. -Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. +Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank. Workbooks containing formulas show a compact warning beside the formula icon instead of consuming worksheet height with a notice row. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 09e24a28a7..4107b98624 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -12,7 +12,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 [文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS、SheetJS CE 和 PapaParse 适配层打开 XLSX、XLS、CSV 和 TSV。适配层接收字节、格式和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。依赖库负责文件解析,适配层复用显示格式化和初始选区,不引入另一种工作簿模型。CSV/TSV 保留字面字符串和纯文本选项;隐式类型推断可能丢失前导零或把源文本解释为公式。共享文档读取器调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`,并通过二进制 Remote 直接接收原生字节,保留 Session 授权、文件版本、重新载入和临时字节,无需 base64 解码。Workspace Files 负责有界文件读取,格式解析留在 Client。注册仅将 XLSX/XLS 标为 `binaryExtensions`,共享文档容器因此为 CSV/TSV 提供纯文本选项。每个渲染器以自己的 id 注册正文。 -预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空,并显示工作簿提示。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 +预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空。包含公式的工作簿会在公式图标旁显示紧凑提示,不再用整行提示占用工作表高度。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index ec09035d09..ad27f80c82 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,7 +9,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' -import { excelFixture, excelHtmlFixture, excelHtmlText } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' +import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' @@ -246,6 +246,7 @@ else process.exit(1); ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))), writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()), writeFile(join(cwd, 'literal-html.xlsx'), await excelHtmlFixture()), writeFile(join(cwd, 'budget.xls'), xlsFixture()), writeFile(join(cwd, 'table.csv'), '00123,"中文,字段","=SUM(1,2)"\n2024-03-01,,TRUE'), @@ -764,17 +765,37 @@ else process.exit(1); await successShot(page, 'excel-budget') expect(await preview.locator('[data-pdf-preview]').count()).toBe(0) expect(await excel.locator('.fortune-toolbar').count()).toBe(0) + const sheetOverlay = excel.locator('.fortune-sheet-overlay') + const formulaInput = excel.locator('.fortune-fx-input') + await sheetOverlay.click({ position: { x: 500, y: 110 } }) + await expect.poll(() => formulaInput.innerText()).toBe('=C3/B3') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('80.0%\n') await excel.getByText('公式与格式', { exact: true }).click() await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') - await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) - await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') - expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') + await sheetOverlay.click({ position: { x: 140, y: 30 } }) + await expect.poll(() => formulaInput.innerText()).toBe('46281') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('2026-09-16\n') + await sheetOverlay.click({ position: { x: 70, y: 30 } }) + await expect.poll(() => formulaInput.innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') + expect(await formulaInput.getAttribute('contenteditable')).toBe('false') await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') await page.keyboard.type('999') await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') await successShot(page, 'excel-cached-formula') + await openFile('meeting.xlsx') + await excel.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' }) + expect(await excel.getByText('Read-only preview', { exact: false }).count()).toBe(0) + expect(await excel.getByText('Some formulas have no saved result', { exact: false }).count()).toBe(0) + const formulaWarning = excel.locator('[data-excel-formula-warning]') + await formulaWarning.hover() + await page.getByText('This workbook contains formulas. Displayed results may be missing or inaccurate.', { exact: true }).waitFor() + await excel.getByText('统计看板', { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') + await successShot(page, 'excel-meeting') await openFile('budget.xls') await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' }) await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') @@ -786,7 +807,6 @@ else process.exit(1); await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') await successShot(page, 'excel-legacy') await openFile('literal-html.xlsx') - const formulaInput = excel.locator('.fortune-fx-input') for (const [sheet, formula, copied] of [ ['Formula', `="${excelHtmlText}"`, 'saved result'], ['Text', excelHtmlText, excelHtmlText], @@ -812,6 +832,8 @@ else process.exit(1); '## Browser Excel preview', '', '- Opens without the Office conversion service', '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', + '- Formula workbooks use a compact warning beside fx; notice rows absent', + '- Formatted percent copied: 80.0%; date copied: 2026-09-16', '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', '- Formula bar is read-only; PDF body and editing toolbar absent', '- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML', diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 767eda39c1..1fae2bc487 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 7e9650c3e65763dfe5536077e19adffd7cc70e02 -README.zh.md: d391c95f6b942596c5665022448189d6a70aa2b1 +README.md: 67136b8e5ff81dee26905be7734c981f18fc3b0a +README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 7e9650c3e6..67136b8e5f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results receive a notice. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service. CSV and TSV default to the spreadsheet viewer and also offer Plain text. Commas and tabs delimit their fields respectively; quoted separators, escaped quotes, multiline fields, empty fields, and unequal row lengths are supported. The first row remains data. Values remain literal strings, including leading zeros, dates, booleans, and formula-looking text. Text files accept UTF-8 or BOM-marked UTF-16; invalid encoding receives conversion guidance. Malformed quoted fields fail the table preview rather than silently dropping data. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index d391c95f6b..9686863b9d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。 CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index 25d00bc7eb..efd00a9c2a 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -9,19 +9,35 @@ } .workbook { + position: relative; flex: 1; min-height: 0; min-width: 0; color-scheme: light; } -.notice { - flex: none; - margin: 0; - padding: 6px 12px; - font-size: 12px; - color: var(--dsw-alias-label-secondary); - border-bottom: 0.5px solid var(--dsw-alias-border-l3); +.formulaWarning { + position: absolute; + top: 4px; + left: 142px; + z-index: 3; + display: flex; + width: 20px; + height: 20px; + padding: 3px; + color: var(--dsw-alias-state-warn-label); + background: transparent; + border: 0; + border-radius: 4px; +} + +.formulaWarning:hover, .formulaWarning:focus-visible { + background: var(--dsw-alias-state-warn-tertiary); + outline: none; +} + +.withFormulaWarning :global(.fortune-fx-input) { + padding-left: 24px; } .status { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index 2aaa7ad200..b2e94fdea7 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -1,7 +1,7 @@ /** Read-only spreadsheet surface backed by browser-parsed workbook data. */ import { useEffect, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' -import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconLoadingOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' import type { ExcelFormat } from './format.ts' import type { ExcelPreview } from './model.ts' @@ -19,7 +19,6 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` */ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined - const delimited = format === 'csv' || format === 'tsv' const [state, setState] = useState() const [attempt, setAttempt] = useState(0) useEffect(() => { @@ -40,11 +39,15 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): {t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}
+ const hasFormulas = state.value.sheets.some(sheet => sheet.celldata?.some(cell => cell.v?.f !== undefined)) return
-

{t(delimited ? 'textSummary' : 'summary')}

- {state.value.missingResults > 0 &&

{t('missingResults')}

} -
+
+ {hasFormulas && + + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index cdcde81ed3..42c2b1f066 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -4,11 +4,7 @@ export const zh = { invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', - summary: '只读预览 · 显示已保存的公式结果', - textSummary: '只读预览 · 按原始文本显示单元格', - xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。', - limitations: '图表、图片、数据透视表和条件格式暂不显示。', - missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', + formulaWarning: '此工作簿包含公式,显示结果可能缺失或不准确。', retry: '重试', } satisfies Record @@ -21,11 +17,7 @@ export const en = { invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', - summary: 'Read-only preview · Saved formula results', - textSummary: 'Read-only preview · Literal cell text', - xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.', - limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', - missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', + formulaWarning: 'This workbook contains formulas. Displayed results may be missing or inaccurate.', retry: 'Retry', } satisfies Record diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx index fa3bf9aaf2..ab9e905cf1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -16,15 +16,16 @@ import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } +const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1 } afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows loading then a workbook with editing and recalculation disabled', async () => { - mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) + mocked.parse.mockResolvedValue(formulaValue) const view = render() expect(screen.getByRole('status', { name: en.loading })).toBeDefined() - await screen.findByText(en.summary) - expect(screen.getByText(en.missingResults)).toBeDefined() - expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.getByRole('button', { name: en.formulaWarning })).toBeDefined() + expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: formulaValue.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.unmount() expect(signal.aborted).toBe(true) @@ -36,10 +37,10 @@ it('ignores a retired file result and cancels parsing when the bytes change', as const view = render() const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.rerender() - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) expect(signal.aborted).toBe(true) - finish({ ...value, missingResults: 1 }) - await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) + finish(value) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) }) it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { @@ -48,7 +49,7 @@ it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows loc const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid' await screen.findByText(en[key]) fireEvent.click(screen.getByRole('button', { name: en.retry })) - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) expect(mocked.parse).toHaveBeenCalledTimes(2) }) @@ -61,7 +62,8 @@ it('rejects non-byte content without allocating a parser', () => { it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => { mocked.parse.mockResolvedValue(value) render() - await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.queryByRole('button', { name: en.formulaWarning })).toBeNull() expect(mocked.parse.mock.calls[0]![1]).toBe(format) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 31a1b35c58..edb37f7e9f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -4,12 +4,12 @@ import { strToU8, unzipSync, zipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { convertExcel } from '../src/client/excel/convert.ts' import { Config } from '../src/config.ts' -import { excelFixture } from './excel-fixture.ts' +import { excelFixture, meetingMinutesFixture } from './excel-fixture.ts' const limits = Config({}).excel describe('Excel conversion', () => { - it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { + it('keeps values, dates, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { const input = await excelFixture() const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits) expect(input.byteLength).toBeGreaterThan(0) @@ -42,6 +42,17 @@ describe('Excel conversion', () => { expect(missingResults).toBe(1) }) + it('opens workbooks with missing results and Chinese worksheet references', async () => { + const { sheets, missingResults } = await convertExcel(await meetingMinutesFixture(), 'xlsx', limits) + expect(sheets.map(sheet => sheet.name)).toEqual(['会议信息', '会议议程', '决议事项', '行动计划', '待确认问题', '风险与依赖', '统计看板', '填写说明']) + const dashboard = sheets[6]! + const cell = (r: number, c: number) => dashboard.celldata!.find(value => value.r === r && value.c === c)!.v + expect(cell(4, 1)).toMatchObject({ f: '=COUNTIF(行动计划!$G$4:$G$23,$A5)', m: '' }) + expect(cell(4, 2)).toMatchObject({ f: '=IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))', m: '' }) + expect(cell(4, 3)).toMatchObject({ f: '=IFERROR($B5/$B$10,0)', m: '' }) + expect(missingResults).toBe(27) + }) + it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) const input = await excelFixture() diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index ef9eefc42b..dcfc93abce 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -62,3 +62,31 @@ export async function excelHtmlFixture(): Promise> { workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText } return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Build a meeting-minutes workbook whose formulas have no cached results. */ +export async function meetingMinutesFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + const info = workbook.addWorksheet('会议信息') + info.getCell('A1').value = '会议纪要' + info.getCell('B11').value = { formula: 'IF(OR(B9="",B10=""),"",ROUND((B10-B9)*1440,0))' } + const agenda = workbook.addWorksheet('会议议程') + agenda.getCell('D16').value = { formula: 'SUM(D4:D15)' } + agenda.getCell('E16').value = { formula: 'SUM(E4:E15)' } + workbook.addWorksheet('决议事项') + const actions = workbook.addWorksheet('行动计划') + for (let row = 4; row <= 23; row += 1) { + actions.getCell(`I${row}`).value = { formula: `IF(AND($E${row}<>"",$E${row}"已完成",$G${row}<>"已取消"),TODAY()-$E${row},"")` } + } + workbook.addWorksheet('待确认问题') + workbook.addWorksheet('风险与依赖') + const dashboard = workbook.addWorksheet('统计看板') + dashboard.getCell('A1').value = '会议纪要 · 统计看板' + dashboard.getCell('A5').value = '未开始' + dashboard.getCell('B5').value = { formula: 'COUNTIF(行动计划!$G$4:$G$23,$A5)' } + dashboard.getCell('C5').value = { formula: 'IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))' } + dashboard.getCell('D5').value = { formula: 'IFERROR($B5/$B$10,0)' } + dashboard.getCell('D5').numFmt = '0%' + dashboard.getCell('B10').value = { formula: 'SUM(B5:B9)' } + workbook.addWorksheet('填写说明') + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 0ae42ec777..633a072fa7 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -76,6 +76,8 @@ - Opens without the Office conversion service - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted +- Formula workbooks use a compact warning beside fx; notice rows absent +- Formatted percent copied: 80.0%; date copied: 2026-09-16 - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent - HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML From 61a0acc4da3d37cf8b09635522ffb9bd4df9502b Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 15:35:57 +0800 Subject: [PATCH 9/9] fix(preview): satisfy strict spreadsheet lint --- .../src/client/excel/xls.ts | 3 ++- .../tests/excel-convert.client.spec.ts | 20 +++++++++---------- .../tests/excel-formats.client.spec.ts | 9 ++++----- .../tests/excel-registration.client.spec.ts | 5 ++++- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts index 437dc53b0c..f265266eef 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/xls.ts @@ -7,7 +7,8 @@ import { formatCell, initialSelection, type ExcelLimits, type ExcelPreview } fro set_cptable(codepages) // SheetJS publishes SSF as `any`; this is the one formatter capability used by the adapter. -const isDateFormat: (format: string) => boolean = SSF.is_date +const spreadsheetFormatter = SSF as { is_date(format: string): boolean } +const isDateFormat = (format: string) => spreadsheetFormatter.is_date(format) /** * Decode a binary XLS workbook without accepting renamed text or HTML files. diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index edb37f7e9f..aa99637d8d 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -26,17 +26,17 @@ describe('Excel conversion', () => { expect(first.frozen).toEqual({ type: 'rangeBoth', range: { row_focus: 1, column_focus: 0 } }) expect(first.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 3], row_focus: 0, column_focus: 0 }]) const second = sheets[1]! + const secondCell = (r: number, c: number) => second.celldata!.find(value => value.r === r && value.c === c)!.v! expect(second.luckysheet_select_save).toEqual([{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }]) - expect(second.celldata).toEqual(expect.arrayContaining([ - expect.objectContaining({ r: 0, c: 0, v: expect.objectContaining({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) }), - expect.objectContaining({ r: 1, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', m: '' }) }), - expect.objectContaining({ r: 0, c: 1, v: expect.objectContaining({ m: '2026-09-16' }) }), - expect.objectContaining({ r: 0, c: 2, v: expect.objectContaining({ v: '富文本 示例', ct: { t: 'inlineStr', s: expect.any(Array) } }) }), - expect.objectContaining({ r: 0, c: 3, v: expect.objectContaining({ v: 'Link text' }) }), - expect.objectContaining({ r: 2, c: 0, v: expect.objectContaining({ f: '=SUM(1,2)', v: 3 }) }), - expect.objectContaining({ r: 3, c: 0, v: expect.objectContaining({ v: true }) }), - expect.objectContaining({ r: 4, c: 0, v: expect.objectContaining({ v: '#DIV/0!' }) }), - ])) + expect(secondCell(0, 0)).toMatchObject({ f: '=_xlfn.XLOOKUP(1,{1},{42})', v: 42, m: '42' }) + expect(secondCell(1, 0)).toMatchObject({ f: '=SUM(1,2)', m: '' }) + expect(secondCell(0, 1)).toMatchObject({ m: '2026-09-16' }) + expect(secondCell(0, 2)).toMatchObject({ v: '富文本 示例', ct: { t: 'inlineStr' } }) + expect(secondCell(0, 2).ct?.s).toBeInstanceOf(Array) + expect(secondCell(0, 3)).toMatchObject({ v: 'Link text' }) + expect(secondCell(2, 0)).toMatchObject({ f: '=SUM(1,2)', v: 3 }) + expect(secondCell(3, 0)).toMatchObject({ v: true }) + expect(secondCell(4, 0)).toMatchObject({ v: '#DIV/0!' }) expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } }) expect(sheets[2]).toMatchObject({ hide: 1, status: 0 }) expect(missingResults).toBe(1) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts index 6ca70fd926..b84c2b9ca2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-formats.client.spec.ts @@ -54,12 +54,11 @@ it('maps optional row metadata, empty formula caches, errors, and Date values', sheet['B5'] = { t: 'd', v: new Date('2024-03-01T00:00:00Z'), z: 'yyyy-mm-dd' } sheet['C5'] = { t: 'e', v: 7, w: '#DIV/0!' } const { sheets, missingResults } = mapXlsWorkbook(workbook, limits) + const cell = (r: number, c: number) => sheets[0]!.celldata!.find(value => value.r === r && value.c === c)!.v! expect(sheets[0]!.config).toMatchObject({ rowlen: { 0: 40, 1: 18 }, rowhidden: { 2: 0 }, columnlen: { 0: 130 }, colhidden: { 1: 0 } }) - expect(sheets[0]!.celldata).toEqual(expect.arrayContaining([ - { r: 3, c: 1, v: expect.objectContaining({ f: '=1+2', m: '' }) }, - { r: 4, c: 1, v: expect.objectContaining({ m: '2024-03-01' }) }, - { r: 4, c: 2, v: expect.objectContaining({ v: '#DIV/0!' }) }, - ])) + expect(cell(3, 1)).toMatchObject({ f: '=1+2', m: '' }) + expect(cell(4, 1)).toMatchObject({ m: '2024-03-01' }) + expect(cell(4, 2)).toMatchObject({ v: '#DIV/0!' }) expect(missingResults).toBe(1) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts index f00d3341b6..a2d96258c1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-registration.client.spec.ts @@ -29,8 +29,11 @@ it('registers complete binary reads and removes the slot and locale on disposal' expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path)) } expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en }) - expect(register).toHaveBeenCalledWith(expect.objectContaining({ locale: 'sidebarExcel', inject: expect.any(Function) }), LazyExcelBody) + expect(register).toHaveBeenCalledOnce() + expect(register.mock.calls[0]![1]).toBe(LazyExcelBody) const options = register.mock.calls[0]![0] as { inject: () => unknown } + expect(options).toMatchObject({ locale: 'sidebarExcel' }) + expect(options.inject).toBeTypeOf('function') expect(options.inject()).toEqual({ limits }) } finally { await fiber.dispose() } expect(registry.getSnapshot()).toEqual([])