fix(sidebar): open spreadsheets in default app

This commit is contained in:
Yudong committed 2026-09-20 17:39:28 +08:00
1 parent 8d6245bcc8
commit c1f37fef9d
10 files changed
+51 -28

No files matched your search

@@ -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: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9
2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a
2026-09-11-node-office-kit.md: b8e94bf6b392edf7931346400fda9ca5d3416188
2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d
@@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate
The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills.
The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. 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. 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.
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.
@@ -14,7 +14,7 @@ Status: implemented
[平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。
服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。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 预览注册 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 存储或持久缓存。
converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
+28 -11
View File
@@ -260,7 +260,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
const restoredAdd = await addTab.count()
expect(restoredFilesClose).toBe(1)
expect(restoredAdd).toBe(1)
const preview = column.locator('[data-document-preview]')
const preview = column.locator('[data-textpreview-url]')
const openFile = openPreviewFile.bind(undefined, column, filesTab, preview)
const viewer = preview.locator('[data-document-viewer-menu]')
const body = preview.locator('[data-textpreview-body]')
@@ -696,7 +696,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
const officeMenus: number[] = []
const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.'
for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) {
for (const extension of ['doc', 'docx', 'ppt', 'pptx']) {
await openFile(`unavailable.${extension}`)
expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 })
@@ -707,12 +707,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await successShot(page, 'office-unavailable')
sections.push([
'## Office unavailable', '',
`- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
`- DOC, DOCX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
`- Guidance: ${configurationGuide}`,
'- Binary text shown: false',
'- Plain-text option and viewer picker: hidden',
].join('\n'))
const spreadsheetStates: string[] = []
for (const extension of ['xls', 'xlsx']) {
await openFile(`unavailable.${extension}`)
const spreadsheet = column.locator('[data-textpreview-state="unsupported"]')
await spreadsheet.waitFor({ timeout: 15_000 })
expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1)
await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 })
spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`)
}
sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n'))
await openFile('notes.unknown')
const plainLines = preview.locator('[data-textpreview-line]')
await expect.poll(() => plainLines.count()).toBe(2)
@@ -810,7 +821,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
await column.locator('[data-files-reload]').click()
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
const preview = column.locator('[data-document-preview]')
const preview = column.locator('[data-textpreview-url]')
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click()
expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
@@ -885,30 +896,36 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
`- Document top inset: ${topInset}px`,
].join('\n'), MODE)
await successShot(page, 'office-docx')
for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) {
for (const extension of ['doc', 'ppt', 'pptx']) {
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
await successShot(page, `office-${extension}`)
}
expect(convert).toHaveBeenCalledTimes(6)
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()
await expect.poll(() => convert.mock.calls.length).toBe(7)
await expect.poll(() => convert.mock.calls.length).toBe(5)
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
await openPreviewFile(column, filesTab, preview, 'renamed.docx')
await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
await successShot(page, 'office-invalid')
expect(convert).toHaveBeenCalledTimes(8)
for (const extension of ['doc', 'xls', 'ppt']) {
expect(convert).toHaveBeenCalledTimes(6)
for (const extension of ['doc', 'ppt']) {
await openPreviewFile(column, filesTab, preview, `renamed.${extension}`)
await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
}
expect(convert).toHaveBeenCalledTimes(11)
expect(convert).toHaveBeenCalledTimes(8)
expect(tripwire.pageErrors).toEqual([])
} finally { convert.mockRestore() }
})
@@ -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: e7360434961ce480d2e97cb2ef41685b8cd8e14f
README.zh.md: 7a60e3a94b931ed50e37214a9c2c63ed5ce6cbbb
README.md: 2797d6fd6be8f429043c8de1413f90e64bda3876
README.zh.md: a04571ed6d76ca812aa0a70ce6f3c403ad80947d
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. 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, 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.
## Table of Contents
@@ -65,7 +65,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin
<a id="office-preview"></a>
## Office preview
Open `.doc`, `.docx`, `.xls`, `.xlsx`, `.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; invalid 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. `.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.
The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML.
@@ -108,7 +108,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. Binary `.doc`, `.xls`, 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. 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).
- **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,7 +9,7 @@ kind: "package-reference"
## 概述
在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。
在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Word 与 PowerPoint 文档在本地转换为 PDF;Excel 工作簿使用不支持预览的空态。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。
## 目录
@@ -65,7 +65,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
<a id="office-preview"></a>
## Office 预览
将 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换;无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。
将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。`.xls` 与 `.xlsx` 不注册 Sidebar 渲染器:它们不请求转换,直接显示不支持预览的提示;Host 报告存在桌面时,还提供用系统默认应用打开的控件。[Host 提供方](../../document/office-to-pdf/README.zh.md)仍为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。
[Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。
@@ -108,7 +108,7 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi
<a id="known-limitations-and-deferred-work"></a>
- **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。
- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc`、`.xls` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。
- **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。Excel 工作簿不提供预览,在桌面 Host 上改为提供用系统默认应用打开的控件。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。
- **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
@@ -31,7 +31,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
export function apply(ctx: Context, config: Config['office']): void {
const id = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office'
const extensions = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']
const extensions = ['doc', 'docx', 'ppt', 'pptx']
ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en }))
const t = ctx.locale.bind('sidebarOffice')
const unavailable: ReadOfficeDocument = (_file, signal) => {
@@ -81,17 +81,18 @@ async function harness(config: Partial<Config['office']> = {}, missing?: 'remote
}
}
it.each(['remote', 'render', 'files'] as const)('keeps Office registration and guidance when %s is absent', async (missing) => {
it.each(['remote', 'render', 'files'] as const)('keeps Word and PowerPoint registration and guidance when %s is absent', async (missing) => {
const h = await harness(undefined, missing)
try {
expect(h.locale.register).toHaveBeenCalledWith('sidebarOffice', { zh, en })
for (const path of ['a.DOC', 'b.DOCX', 'c.XLS', 'd.xlsx', 'e.PPT', 'f.pptx']) {
expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
for (const path of ['a.DOC', 'b.DOCX', 'c.PPT', 'd.pptx']) {
expect(h.registry.candidates(path)[0]!.binaryExtensions).toEqual(['doc', 'docx', 'ppt', 'pptx'])
expect(h.registry.candidates(path)[0]!.title()).toBe(en.title)
expect(h.registry.candidates(path)[0]!.loading).toBe('renderer')
expect(h.registry.candidates(path)[0]).not.toHaveProperty('read')
await expect(h.read(undefined, path)).rejects.toThrow(en.unavailable)
}
for (const path of ['sheet.XLS', 'sheet.xlsx']) expect(h.registry.candidates(path)).toEqual([])
expect(h.render).not.toHaveBeenCalled()
} finally { await h.close() }
expect(h.registry.getSnapshot()).toEqual([])
@@ -67,11 +67,16 @@
## Office unavailable
- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0 | 0 | 0
- DOC, DOCX, PPT, PPTX viewer menus: 0 | 0 | 0 | 0
- Guidance: Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.
- Binary text shown: false
- Plain-text option and viewer picker: hidden
## Spreadsheet preview
- XLS: unsupported / Open in default app
- XLSX: unsupported / Open in default app
## Unknown suffix
- Viewer menu hidden: true