Merge pull request #4346 from deepseek-harness/web/excel-frontend-preview
feat(preview): open spreadsheets in the browser
This commit is contained in:
commit
22da693201
63 files changed
+2574
-103
No files matched your search
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 回调负责读取。
|
||||
|
||||
|
||||
@@ -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: 36897178014daa839399adb5fdf72a4ca53cec8b
|
||||
2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a
|
||||
@@ -10,11 +10,11 @@ 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.
|
||||
|
||||
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 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.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ 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。
|
||||
|
||||
服务与 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;[浏览器表格预览](../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 会刷新元数据。
|
||||
|
||||
|
||||
@@ -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: 5631906931122e45d4381a570c095203655b6435
|
||||
2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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, 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. 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.
|
||||
|
||||
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) 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 `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
|
||||
|
||||
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying 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. 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/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.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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、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 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
|
||||
|
||||
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
|
||||
|
||||
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
|
||||
|
||||
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**采用 FortuneExcel 面向 React 的转换辅助函数。** 其组件状态设置器和渲染后尺寸调整把文件转换耦合到一种 UI 生命周期。本地适配层无需挂载工作簿即可测试,也不需要第二种中间工作簿模型。
|
||||
|
||||
**自行编写 OOXML 解析器或创建新的公共包。** ExcelJS 已经负责容器和文件语义。单个预览使用方不足以支持另一套公共 API、Cordis 服务或通用工作簿抽象;出现第二个独立使用方时再考虑提取。
|
||||
|
||||
**保留 PDF 作为 Excel 兜底,或在导入时计算所有公式。** PDF 无法保留表格交互与公式查看,而重新计算可能改变已保存结果。[Office 引擎决策](../architecture/2026-09-11-node-office-kit.zh.md)仍适用于 Word/PowerPoint 预览与独立转换使用方;转换能力仍支持表格。
|
||||
|
||||
## 影响
|
||||
|
||||
渲染器用部分 Excel 保真度换取浏览器内导航和可复用的转换代码。包 README 负责说明支持的格式、资源默认值和不支持的功能。真实 XLSX/XLS fixture 和分隔文本用例覆盖转换,Worker 测试覆盖取消和清理,发布 profile 的浏览器场景在禁用 Office 转换后打开工作簿,并在只读设置下复制已保存的 XLOOKUP 结果。该场景还验证 XLS 工作表切换,以及 CSV/TSV 字面值复制、纯文本切换和重新载入。由于公共 npm 仓库版本过旧,SheetJS CE 固定使用官方发布的 tarball;其 Apache-2.0 许可证沿用现有宽松许可证及打包声明规则。
|
||||
@@ -38,6 +38,8 @@ 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 |
|
||||
| [`@lexical/history`](https://github.com/facebook/lexical) | MIT |
|
||||
@@ -79,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 |
|
||||
@@ -103,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 +126,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 |
|
||||
@@ -129,6 +135,8 @@ pnpm applies local patches to the following packages at install time, so shipped
|
||||
|
||||
- `@earendil-works/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
|
||||
@@ -183,6 +191,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 |
|
||||
|
||||
@@ -9,6 +9,8 @@ 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, 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'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
@@ -243,6 +245,12 @@ 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()),
|
||||
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'),
|
||||
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])),
|
||||
])
|
||||
@@ -751,16 +759,121 @@ else process.exit(1);
|
||||
'- Plain-text option and viewer picker: hidden',
|
||||
].join('\n'))
|
||||
|
||||
const spreadsheetStates: string[] = []
|
||||
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)
|
||||
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 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')
|
||||
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')
|
||||
await openFile('literal-html.xlsx')
|
||||
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}`)
|
||||
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`)
|
||||
await preview.getByText(invalidExcel, { exact: true }).waitFor()
|
||||
}
|
||||
sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n'))
|
||||
sections.push([
|
||||
'## 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',
|
||||
'- 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')
|
||||
const plainLines = preview.locator('[data-textpreview-line]')
|
||||
@@ -958,12 +1071,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()
|
||||
|
||||
@@ -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: 65c8de43fcb36dcab56c04350ef11d2364905796
|
||||
config-catalog.zh.md: 19f62adeece3983ac67895b124c370b2aa2eb7dc
|
||||
config-catalog.md: d3d1b4fe47bf6dc745dc9a00ac6ef317ab0d3ca8
|
||||
config-catalog.zh.md: 3f204af30d5e973c03cd24c6c2873bb7756d334e
|
||||
@@ -502,6 +502,15 @@ export interface Config {
|
||||
/** Maximum readers including source and renderer metadata lookups. */
|
||||
maxReaders: number
|
||||
}
|
||||
/** Browser spreadsheet parser and dense cell allocation limits. */
|
||||
excel: {
|
||||
/** Maximum source file bytes. */
|
||||
maxBytes: number
|
||||
/** Maximum combined rectangular cell area across worksheets. */
|
||||
maxCells: number
|
||||
/** Maximum parser Worker lifetime in milliseconds. */
|
||||
timeoutMs: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -506,6 +506,15 @@ export interface Config {
|
||||
/** Maximum readers including source and renderer metadata lookups. */
|
||||
maxReaders: number
|
||||
}
|
||||
/** Browser spreadsheet parser and dense cell allocation limits. */
|
||||
excel: {
|
||||
/** Maximum source file bytes. */
|
||||
maxBytes: number
|
||||
/** Maximum combined rectangular cell area across worksheets. */
|
||||
maxCells: number
|
||||
/** Maximum parser Worker lifetime in milliseconds. */
|
||||
timeoutMs: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)定义加载生命周期。
|
||||
|
||||
## 资源模型
|
||||
|
||||
|
||||
@@ -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: 67136b8e5ff81dee26905be7734c981f18fc3b0a
|
||||
README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726
|
||||
@@ -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 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
|
||||
|
||||
- [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)
|
||||
@@ -33,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.
|
||||
|
||||
<a id="addresses"></a>
|
||||
## Addresses
|
||||
@@ -51,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<ArrayBuffer>` 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<ArrayBuffer>` 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 `<base href>` 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.
|
||||
@@ -62,10 +63,29 @@ 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.
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## 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 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.
|
||||
|
||||
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 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 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.
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## 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.
|
||||
|
||||
@@ -84,7 +104,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar
|
||||
<details>
|
||||
<summary>Office implementation — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
@@ -108,7 +128,7 @@ No direct effect; what the user reads here never enters a model request.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
- **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.
|
||||
|
||||
@@ -9,13 +9,14 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。
|
||||
在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML、常见图片和表格接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;表格在浏览器内打开。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。
|
||||
|
||||
## 目录
|
||||
|
||||
- [注册了什么](#what-it-registers)
|
||||
- [地址](#addresses)
|
||||
- [怎么读](#how-it-reads)
|
||||
- [Excel 预览](#excel-preview)
|
||||
- [Office 预览](#office-preview)
|
||||
- [导航](#navigation)
|
||||
- [模型体验](#model-experience)
|
||||
@@ -33,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。
|
||||
|
||||
<a id="addresses"></a>
|
||||
## 地址
|
||||
@@ -51,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<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
|
||||
- **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array<ArrayBuffer>`,供 `{ 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 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
|
||||
@@ -62,10 +63,29 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
|
||||
|
||||
首次读取、追加页及 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 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
|
||||
|
||||
CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。
|
||||
|
||||
在同一个 `ui-sidebar-documentpreview` 条目上配置 `excel`。这些限制补充 Host 的完整文件读取限制,但不限制浏览器进程内存或解压分配量。
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `excel.maxBytes` | `16777216`(16 MiB) | 最大源文件字节数 |
|
||||
| `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 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。ExcelJS 会再次解码 XLSX 字符串公式缓存结果中的实体写法;保存的字面文本 `<` 会显示为 `<`。
|
||||
|
||||
只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
|
||||
|
||||
<a id="office-preview"></a>
|
||||
## 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 后重新加载浏览器页面。
|
||||
|
||||
@@ -84,7 +104,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
|
||||
<details>
|
||||
<summary>Office 实现——点击展开</summary>
|
||||
|
||||
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 前复制。
|
||||
|
||||
@@ -108,7 +128,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `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 不获得运行时文件读取桥接。
|
||||
|
||||
@@ -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.
|
||||
@@ -65,18 +65,27 @@
|
||||
"@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:^",
|
||||
"@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/papaparse": "^5.3.16",
|
||||
"@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",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
white-space: normal;
|
||||
font-family: var(--dsw-font, sans-serif);
|
||||
}
|
||||
|
||||
.workbook {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.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 {
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 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 { 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'
|
||||
|
||||
/** 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 }))
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const format = excelFormat(hostFileOf(props.resourceAddress).path)
|
||||
return <Suspense fallback={<LoadingIndicator className={css.status} label={props.t('loading')} />}>
|
||||
<LoadedExcelBody {...props} format={format} />
|
||||
</Suspense>
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 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 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 Display sheets and missing formula-cache count.
|
||||
*/
|
||||
export async function convertExcel(bytes: Uint8Array<ArrayBuffer>, format: ExcelFormat, limits: ExcelLimits): Promise<ExcelPreview> {
|
||||
if (bytes.byteLength > limits.maxBytes) throw new ExcelPreviewError('tooLarge')
|
||||
if (format === 'xlsx') return convertXlsx(bytes, limits)
|
||||
if (format === 'xls') return convertXls(bytes, limits)
|
||||
return convertDelimited(bytes, format, limits)
|
||||
}
|
||||
@@ -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<ArrayBuffer>, 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<Sheet['celldata']> = []
|
||||
let row = 0
|
||||
let column = 1
|
||||
let failure: ExcelPreviewError | undefined
|
||||
Papa.parse<string[]>(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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 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, 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'
|
||||
import type { LoadedExcelBodyProps } from './LazyExcelBody.tsx'
|
||||
import { parseExcel } from './parse.ts'
|
||||
import css from './ExcelBody.module.css'
|
||||
|
||||
type State = { data: Uint8Array<ArrayBuffer>; format: ExcelFormat } & ({ 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, format, limits, t }: LoadedExcelBodyProps): ReactNode {
|
||||
const data = content.kind === 'bytes' ? content.data : undefined
|
||||
const [state, setState] = useState<State>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
useEffect(() => {
|
||||
if (data === undefined) return
|
||||
const controller = new AbortController()
|
||||
setState(undefined)
|
||||
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, format, limits, attempt])
|
||||
if (data === undefined) return <p className={css.status} role="alert">{t('invalid')}</p>
|
||||
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
|
||||
<span className={css.loadingIcon} aria-hidden="true"><IconLoadingOutlineRegular /></span>
|
||||
</span>
|
||||
if ('error' in state) return <div className={css.status} role="alert">
|
||||
<span>{t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}</span>
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
|
||||
</div>
|
||||
const hasFormulas = state.value.sheets.some(sheet => sheet.celldata?.some(cell => cell.v?.f !== undefined))
|
||||
return <section className={css.body} data-excel-preview aria-label={t('title')}>
|
||||
<style>{scopedStyles}</style>
|
||||
<div className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
|
||||
{hasFormulas && <Tooltip portal label={t('formulaWarning')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.formulaWarning} aria-label={t('formulaWarning')} data-excel-formula-warning>
|
||||
<IconWarningTriangleOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>}
|
||||
<Workbook key={`${attempt}:${t('language')}`} data={state.value.sheets} lang={t('language')}
|
||||
allowEdit={false} showToolbar={false} showFormulaBar showSheetTabs forceCalculation={false}
|
||||
cellContextMenu={['copy']} headerContextMenu={[]} sheetTabContextMenu={[]} filterContextMenu={[]} />
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Filename-to-parser selection kept outside the heavy spreadsheet chunk. */
|
||||
import { ExcelPreviewError } from './error.ts'
|
||||
|
||||
/** File formats admitted by the spreadsheet preview. */
|
||||
export type ExcelFormat = 'xlsx' | 'xls' | 'csv' | 'tsv'
|
||||
|
||||
/**
|
||||
* Resolve a registered filename to its parser.
|
||||
* @param path - Decoded file path.
|
||||
* @returns The supported suffix; rejects unsupported filenames.
|
||||
*/
|
||||
export function excelFormat(path: string): ExcelFormat {
|
||||
const suffix = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
||||
if (suffix === 'xlsx' || suffix === 'xls' || suffix === 'csv' || suffix === 'tsv') return suffix
|
||||
throw new ExcelPreviewError('invalid')
|
||||
}
|
||||
@@ -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', '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({
|
||||
name: 'sidebar.right.tab.document', key: id, locale: 'sidebarExcel', inject: () => ({ limits }),
|
||||
}, LazyExcelBody)))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Locale-owned Excel preview controls and parser feedback. */
|
||||
export const zh = {
|
||||
title: '表格', language: 'zh', loading: '正在打开表格…',
|
||||
invalid: '无法打开此表格。请检查文件格式、内容或密码保护。',
|
||||
tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。',
|
||||
encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。',
|
||||
formulaWarning: '此工作簿包含公式,显示结果可能缺失或不准确。',
|
||||
retry: '重试',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Excel preview dictionary keys. */
|
||||
export type ExcelPreviewKey = keyof typeof zh
|
||||
|
||||
/** English Excel preview copy. */
|
||||
export const en = {
|
||||
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.',
|
||||
encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.',
|
||||
formulaWarning: 'This workbook contains formulas. Displayed results may be missing or inaccurate.',
|
||||
retry: 'Retry',
|
||||
} satisfies Record<ExcelPreviewKey, string>
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Excel preview status and third-party locale selection. */
|
||||
sidebarExcel: ExcelPreviewKey
|
||||
}
|
||||
}
|
||||
@@ -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<Sheet['luckysheet_select_save']> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Browser lifetime for the isolated spreadsheet parser. */
|
||||
import workerSource from './worker.ts?raw'
|
||||
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<ArrayBuffer>, format: ExcelFormat, limits: ExcelLimits, signal: AbortSignal,
|
||||
): Promise<ExcelPreview> {
|
||||
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<unknown>) => {
|
||||
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', 'encoding'].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, format, 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))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** One workbook per disposable parser Worker; formulas and external links are never executed. */
|
||||
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<ArrayBuffer>; 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' }) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/** 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 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.
|
||||
* @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<ArrayBuffer>, 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<typeof read>
|
||||
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<Pick<SheetConfig, 'merge' | 'rowlen' | 'columnlen' | 'rowhidden' | 'colhidden'>> = {
|
||||
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<string, { r: number; c: number; v: Cell }>()
|
||||
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 }
|
||||
}
|
||||
@@ -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<BorderStyle, number> = {
|
||||
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<ArrayBuffer>, limits: ExcelLimits): Promise<ExcelPreview> {
|
||||
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<Pick<SheetConfig, 'merge' | 'rowlen' | 'columnlen' | 'rowhidden' | 'colhidden' | 'borderInfo'>> = {
|
||||
merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [],
|
||||
}
|
||||
const celldata: NonNullable<Sheet['celldata']> = []
|
||||
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<ExcelJS.Borders> | undefined
|
||||
if (border !== undefined) {
|
||||
const value: Record<string, unknown> = { 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<ExcelCell, 'font' | 'fill' | 'alignment'> & Partial<Pick<ExcelCell, 'font' | 'fill' | 'alignment'>>,
|
||||
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<Font> | 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<Color> | 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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<OfficeStore> & 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)
|
||||
|
||||
@@ -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<OfficeStore>) => 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()
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -14,14 +14,28 @@ export interface Config {
|
||||
/** Maximum readers including source and renderer metadata lookups. */
|
||||
maxReaders: number
|
||||
}
|
||||
/** Browser spreadsheet parser and dense cell allocation limits. */
|
||||
excel: {
|
||||
/** Maximum source file 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['office']> }, Config> = z.object({
|
||||
export const Config: z<{ office?: Partial<Config['office']>; excel?: Partial<Config['excel']> }, 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),
|
||||
}),
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @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, 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 }))
|
||||
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 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(formulaValue)
|
||||
const view = render(<ExcelBody {...loadedProps} />)
|
||||
expect(screen.getByRole('status', { name: en.loading })).toBeDefined()
|
||||
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)
|
||||
})
|
||||
|
||||
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(<ExcelBody {...loadedProps} />)
|
||||
const signal = mocked.parse.mock.calls[0]![3] as AbortSignal
|
||||
view.rerender(<ExcelBody {...loadedProps} content={{ kind: 'bytes', data: new Uint8Array([2]) }} />)
|
||||
await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) })
|
||||
expect(signal.aborted).toBe(true)
|
||||
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) => {
|
||||
mocked.parse.mockRejectedValueOnce(new Error(error)).mockResolvedValueOnce(value)
|
||||
render(<ExcelBody {...loadedProps} />)
|
||||
const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid'
|
||||
await screen.findByText(en[key])
|
||||
fireEvent.click(screen.getByRole('button', { name: en.retry }))
|
||||
await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() })
|
||||
expect(mocked.parse).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects non-byte content without allocating a parser', () => {
|
||||
render(<ExcelBody {...loadedProps} content={{ kind: 'text', text: '', pages: [], eof: true }} />)
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.invalid)
|
||||
expect(mocked.parse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => {
|
||||
mocked.parse.mockResolvedValue(value)
|
||||
render(<LazyExcelBody {...props} resourceAddress={`dsh-resource://file/session/s1/book.${format}`} />)
|
||||
await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() })
|
||||
expect(screen.queryByRole('button', { name: en.formulaWarning })).toBeNull()
|
||||
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(<ExcelBody {...loadedProps} />)
|
||||
await screen.findByText(en.invalid)
|
||||
view.unmount()
|
||||
let reject!: (error: Error) => void
|
||||
mocked.parse.mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail }))
|
||||
const pending = render(<ExcelBody {...loadedProps} />)
|
||||
const signal = mocked.parse.mock.calls[1]![3] as AbortSignal
|
||||
pending.unmount()
|
||||
reject(new Error('closed'))
|
||||
await waitFor(() => { expect(signal.aborted).toBe(true) })
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/** 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, meetingMinutesFixture } from './excel-fixture.ts'
|
||||
|
||||
const limits = Config({}).excel
|
||||
|
||||
describe('Excel conversion', () => {
|
||||
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)
|
||||
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 } })
|
||||
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(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)
|
||||
})
|
||||
|
||||
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()
|
||||
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()), '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()), '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()), 'xlsx', 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()), 'xlsx', 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<ExcelJS.Color> }
|
||||
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<ExcelJS.Color> }
|
||||
sheet.getCell('F1').value = 42
|
||||
sheet.getCell('F1').alignment = { vertical: 'middle' }
|
||||
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' })
|
||||
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()), '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)
|
||||
})
|
||||
|
||||
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)), 'xlsx', limits)).sheets).toHaveLength(3)
|
||||
files['xl/theme/theme1.xml'] = strToU8('<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:clrScheme name="Custom"><a:lt1><a:sysClr val="window" lastClr="FEFEFE"/></a:lt1></a:clrScheme></a:themeElements></a:theme>')
|
||||
expect((await convertExcel(new Uint8Array(zipSync(files)), 'xlsx', limits)).sheets).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/** 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<Uint8Array<ArrayBuffer>> {
|
||||
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())
|
||||
}
|
||||
|
||||
/** Cell text whose markup and ampersand must remain literal in the preview and clipboard. */
|
||||
export const excelHtmlText = '<span><img src=data:, onerror=document.documentElement.dataset.spreadsheetHtml=1></span>& literal'
|
||||
|
||||
/**
|
||||
* Build a workbook carrying HTML-looking formulas, text, and saved formula results.
|
||||
* @returns Complete XLSX file bytes.
|
||||
*/
|
||||
export async function excelHtmlFixture(): Promise<Uint8Array<ArrayBuffer>> {
|
||||
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())
|
||||
}
|
||||
|
||||
/** Build a meeting-minutes workbook whose formulas have no cached results. */
|
||||
export async function meetingMinutesFixture(): Promise<Uint8Array<ArrayBuffer>> {
|
||||
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}<TODAY(),$G${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())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/** 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)
|
||||
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(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)
|
||||
})
|
||||
|
||||
it('rejects damaged, renamed, hidden-only, and oversized legacy workbooks', async () => {
|
||||
for (const bytes of [encode('a,b\n1,2'), encode('<html>not a workbook</html>'), 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 })
|
||||
})
|
||||
@@ -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<ArrayBuffer> }, 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, 'xlsx', 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(), '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(), 'xlsx', 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(), 'xlsx', 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(), '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(), 'xlsx', 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(), 'xlsx', 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(), 'xlsx', limits, new AbortController().signal)).rejects.toThrow('invalid')
|
||||
expect(instance.terminate).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 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 { 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'
|
||||
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', '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('Spreadsheet')
|
||||
expect(binaryDocumentPath(registry.getSnapshot(), path)).toBe(/\.(xlsx|xls)$/iu.test(path))
|
||||
}
|
||||
expect(registerLocale).toHaveBeenCalledWith('sidebarExcel', { zh, en })
|
||||
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([])
|
||||
expect(removeLocale).toHaveBeenCalledOnce()
|
||||
expect(removeBody).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -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<ArrayBuffer>, maxCells = 250_000) => {
|
||||
handle({ data: { bytes, format: 'xlsx', 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<typeof import('../src/client/excel/convert.ts')>()
|
||||
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(), format: 'xlsx', limits: Config({}).excel } } as MessageEvent)
|
||||
await vi.waitFor(() => { expect(post).toHaveBeenCalledWith({ ok: false, code: 'invalid' }) })
|
||||
})
|
||||
+11
-4
@@ -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<typeof import('../src/client/office/face.ts')>()
|
||||
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<Config['office']> = {}, 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '..')
|
||||
@@ -41,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 {
|
||||
@@ -56,13 +57,37 @@ 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')
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -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<T>(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<DocumentContent, { kind: 'renderer' }> | undefined
|
||||
const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => {
|
||||
if (key === 'sidebar.right.tab.document.action') {
|
||||
return <OfficeFontAction {...{ ...input as OwnerOf<'sidebar.right.tab.document.action'>, 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 }) => (
|
||||
<p data-test-pdf>{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}</p>
|
||||
),
|
||||
} as unknown as OfficeBodyProps
|
||||
} as OfficeBodyProps
|
||||
return <OfficeBody {...props} />
|
||||
}
|
||||
function View({ renderer = true }: { renderer?: boolean }) {
|
||||
function View({ renderer = true, implementation = definition.id }: { renderer?: boolean; implementation?: string }) {
|
||||
return <TextPreview {...h.props()} renderSlot={slots}
|
||||
useDocumentPreviews={selector => 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(<OfficeBody {...props} />)
|
||||
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(<h.View />)
|
||||
const previous = h.request()
|
||||
view.rerender(<h.View implementation="alternative" />)
|
||||
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<ReadOfficeDocument>()
|
||||
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(<h.View />)
|
||||
act(() => { h.h.instance.actions.toggledAutoRefresh(TAB_ID) })
|
||||
h.h.setVersion('v2')
|
||||
view.rerender(<h.View />)
|
||||
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()
|
||||
})
|
||||
@@ -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<ArrayBuffer> {
|
||||
return new Uint8Array(write(workbook, { type: 'array', bookType: 'biff8' }) as ArrayBuffer)
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
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')
|
||||
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. */
|
||||
function pdfLicenseFiles(root: string): string[] {
|
||||
return ['LICENSE', ...['cmaps', 'standard_fonts', 'wasm'].flatMap(directory =>
|
||||
@@ -53,10 +66,44 @@ const pdfWorker: NonNullable<UserConfig['plugins']> = [{
|
||||
},
|
||||
}]
|
||||
|
||||
/** Embed a self-contained browser parser without giving it a loader-module dependency. */
|
||||
const excelWorker: NonNullable<UserConfig['plugins']> = [{
|
||||
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 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 })
|
||||
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<typeof bundle>[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,
|
||||
)
|
||||
@@ -2959,6 +2959,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',
|
||||
@@ -3120,7 +3121,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: \'<one key the owner dispatches>\' },\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',
|
||||
|
||||
@@ -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)]) {
|
||||
@@ -0,0 +1,104 @@
|
||||
diff --git a/dist/index.esm.js b/dist/index.esm.js
|
||||
index c4486a2b38bec4eff10cdfda4d3748d40bfb71ba..c76ab678d776641131f582a194b5008a73a19bf2 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) {
|
||||
@@ -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;
|
||||
- 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 +10698,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..e559e7a600365993a2994746c84e56f64bbf0033 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) {
|
||||
@@ -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;
|
||||
- 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 +10709,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 = [];
|
||||
});
|
||||
Generated
+486
File diff suppressed because it is too large.
Load diff
@@ -97,5 +97,7 @@ minimumReleaseAgeExclude:
|
||||
patchedDependencies:
|
||||
'@earendil-works/[email protected]': patches/@[email protected]
|
||||
'@electron/[email protected]': patches/@[email protected]
|
||||
'@fortune-sheet/[email protected]': patches/@[email protected]
|
||||
'@fortune-sheet/[email protected]': patches/@[email protected]
|
||||
'@yao-pkg/[email protected]': patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -72,10 +72,24 @@
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user