Selaa lähdekoodia

Merge pull request #4041 from deepseek-harness/office-sidebar-preview

feat(preview): consume shared Office conversion in sidebar
Yudong Han 1 viikko sitten
vanhempi
sitoutus
0e5ec6defa
100 muutettua tiedostoa jossa 2985 lisäystä ja 227 poistoa
  1. 6 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml
  2. 52 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.md
  3. 52 0
      .agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md
  4. 6 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml
  5. 36 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md
  6. 36 0
      .agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md
  7. 9 0
      .agents/notes/archived/manifest.json
  8. 6 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml
  9. 32 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md
  10. 32 0
      .agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml
  12. 5 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
  13. 5 1
      .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md
  14. 6 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml
  15. 65 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
  16. 65 0
      .agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md
  17. 2 2
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml
  18. 3 1
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
  19. 3 1
      .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md
  20. 256 7
      apps/web/tests/document-preview.e2e.ts
  21. 7 0
      apps/web/tests/expected/office-font-notice.md
  22. 6 0
      apps/web/tests/fixtures/office/README.i18n.yaml
  23. 7 0
      apps/web/tests/fixtures/office/README.md
  24. 7 0
      apps/web/tests/fixtures/office/README.zh.md
  25. BIN
      apps/web/tests/fixtures/office/preview.doc
  26. BIN
      apps/web/tests/fixtures/office/preview.ppt
  27. BIN
      apps/web/tests/fixtures/office/preview.xls
  28. 52 0
      apps/web/tests/office-fixture.ts
  29. 3 1
      apps/web/tests/preview-boot.e2e.ts
  30. 22 17
      apps/web/tests/seeded-history.e2e.ts
  31. 1 0
      apps/web/tsconfig.json
  32. 2 2
      docs/capability-seams.i18n.yaml
  33. 3 1
      docs/capability-seams.md
  34. 3 1
      docs/capability-seams.zh.md
  35. 2 2
      docs/config-catalog.i18n.yaml
  36. 24 2
      docs/config-catalog.md
  37. 24 2
      docs/config-catalog.zh.md
  38. 2 2
      docs/event-producer-consumer.i18n.yaml
  39. 1 1
      docs/event-producer-consumer.md
  40. 1 1
      docs/event-producer-consumer.zh.md
  41. 2 2
      docs/subsystems/office-to-pdf.i18n.yaml
  42. 26 0
      docs/subsystems/office-to-pdf.md
  43. 26 0
      docs/subsystems/office-to-pdf.zh.md
  44. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  45. 4 2
      docs/subsystems/sidebar-right.md
  46. 4 2
      docs/subsystems/sidebar-right.zh.md
  47. 2 2
      packages/api/remotes/README.i18n.yaml
  48. 1 1
      packages/api/remotes/README.md
  49. 1 1
      packages/api/remotes/README.zh.md
  50. 2 1
      packages/api/remotes/package.json
  51. 3 1
      packages/api/remotes/src/client/index.ts
  52. 3 0
      packages/api/remotes/tsconfig.client.json
  53. 2 2
      packages/bundle/web-app/README.i18n.yaml
  54. 1 1
      packages/bundle/web-app/README.md
  55. 1 1
      packages/bundle/web-app/README.zh.md
  56. 1 1
      packages/bundle/web-app/cordis.patch.yml
  57. 9 1
      packages/bundle/web-app/package.json
  58. 1 1
      packages/bundle/web-app/tests/document-conversion.e2e.ts
  59. 128 0
      packages/bundle/web-app/tests/document-preview.spec.ts
  60. 1 1
      packages/client/ui-chat/tsconfig.json
  61. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  62. 38 6
      packages/client/ui-sidebar-documentpreview/README.md
  63. 38 6
      packages/client/ui-sidebar-documentpreview/README.zh.md
  64. 11 4
      packages/client/ui-sidebar-documentpreview/package.json
  65. 6 0
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css
  66. 24 10
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  67. 23 11
      packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts
  68. 2 2
      packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts
  69. 24 0
      packages/client/ui-sidebar-documentpreview/src/client/document/tab-lifetime.ts
  70. 46 19
      packages/client/ui-sidebar-documentpreview/src/client/face.ts
  71. 13 4
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  72. 139 0
      packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css
  73. 89 0
      packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx
  74. 12 0
      packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css
  75. 94 0
      packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx
  76. 193 0
      packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts
  77. 127 0
      packages/client/ui-sidebar-documentpreview/src/client/office/index.ts
  78. 44 0
      packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts
  79. 53 0
      packages/client/ui-sidebar-documentpreview/src/client/office/store.ts
  80. 94 1
      packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css
  81. 23 3
      packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts
  82. 24 19
      packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts
  83. 15 5
      packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx
  84. 38 0
      packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts
  85. 6 3
      packages/client/ui-sidebar-documentpreview/src/client/rpc.ts
  86. 22 2
      packages/client/ui-sidebar-documentpreview/src/client/store.ts
  87. 27 0
      packages/client/ui-sidebar-documentpreview/src/config.ts
  88. 16 3
      packages/client/ui-sidebar-documentpreview/src/index.ts
  89. 11 7
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  90. 29 0
      packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts
  91. 31 0
      packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts
  92. 1 1
      packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx
  93. 77 20
      packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx
  94. 33 25
      packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts
  95. 9 3
      packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts
  96. 4 0
      packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml
  97. 1 1
      packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts
  98. 1 1
      packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts
  99. 395 0
      packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts
  100. 84 0
      packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx

+ 6 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-local-office-preview.md
+2026-09-10-local-office-preview.md: ea32e37f0b2dcba866b78947c785dcee8e475b29
+2026-09-10-local-office-preview.zh.md: a1ac8337a1cf22f5842485c0790c6554635dc125

+ 52 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.md

@@ -0,0 +1,52 @@
+# Agent Note: Local Office preview through WASM PDF bytes
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-10-local-office-preview.zh.md)
+
+## Problem
+
+Office Open XML files are ZIP archives, so text fallback cannot provide a useful preview. Document conversion must stay local without taking focus or adding document content to a model conversation. Engine success alone cannot prove that the input has the claimed format or that a PDF was produced.
+
+## Decision
+
+A [document-render Service Definition](../../../../packages/document/document-render/README.md), a LibreOffice WASM provider, and Client/API consumers form the conversion capability. [Boot composition](../../../../packages/document/document-render-auto/README.md) mounts the provider and Remote controller only when `wasm.artifactDirectory` is configured. Invalid explicit configuration fails initialization. The Office Client registration always exists so an unconfigured Host receives guidance. Availability follows the generated Remote namespace without executable discovery or a duplicate Client flag.
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) uses pinned official LibreOffice source, Emscripten, and a LibreOfficeKit adapter. Each Node Worker owns one engine instance, its pthreads, and its memory filesystem. Terminating the Worker cancels synchronous engine calls; every conversion waits for its Worker to stop before returning. OOXML inspection verifies archive membership, content type, and resource bounds before startup.
+
+A VCL callback requests a font family before substitution or missing Unicode characters during glyph fallback. Host code indexes installed font metadata and copies selected files into engine memory, including complete font collections. Exact installed families precede configured alternatives; original-first fontconfig aliases preserve that ordering inside LibreOffice. Optional initial families use the same resolver. The engine has no Host filesystem mount, and rendering neither installs nor downloads fonts. The [build recipe](../../../../native/libreoffice-wasm/README.md) records source and toolchain revisions, patches, and asset hashes. Engine bundles remain immutable during provider use; a different build uses a new directory.
+
+The [independent engine release](../process/2026-09-11-independent-libreoffice-package.md) owns precompiled npm packaging and keeps engine compilation outside ordinary DSH builds.
+
+The provider returns caller-owned PDF bytes and independent `succeeded`, `timedOut`, and `cancelled` facts. PDF bytes are copied out of engine memory before teardown. The API consumer authorizes the source through [Workspace Files](2026-09-09-workspace-file-read-authority.md), requires successful uninterrupted conversion, and encodes the PDF directly. Source path and freshness version survive the PDF transport. Workspace Files limits source reads; the provider's `maxOutputBytes` alone limits generated PDFs. No temporary PDF or file lease is needed.
+
+The Office Client watches the selected Conversation's existing Deliverables projection and warms recent Office files without opening tabs. Its bounded memory cache checks authorized source metadata on every read and shares pending conversions between background and foreground callers. Only the last departing reader cancels a shared conversion; failures are not cached and connection reset discards cached bytes. [Document Preview](2026-09-08-document-preview-operations.md) owns format selection, loading, cancellation, and the PDF.js Worker.
+
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
+
+Missing-family observations accompany the PDF through the worker and Remote response. The Office plugin renders a keyed notice above the shared PDF scrollport, so dismissing it removes its layout height. The font resolver intersects explicit source and theme references with actual document requests, excluding engine defaults and font-table inventories; installed aliases and glyph fallback do not by themselves mean a family is absent.
+
+## Alternatives considered
+
+**Keep an installed native LibreOffice provider.** A second provider adds executable discovery, version queries, subprocess supervision, scratch files, and platform-specific confinement. This design uses WASM for all supported Office previews. A native provider needs a demonstrated rendering or deployment requirement that WASM cannot satisfy.
+
+**Return a temporary PDF path.** The Worker already supplies independent PDF bytes. Writing them to disk, authorizing another file read, and maintaining leases adds file ownership without a current consumer. Source authorization and output limits apply directly to the memory result.
+
+**Automate installed Microsoft Office.** Word for Mac requires GUI automation rather than headless conversion, introducing focus changes and per-file operating-system authorization. Native Office automation remains outside this preview capability.
+
+**Use an online converter or download the engine automatically.** Remote conversion uploads document content. Automatic installation adds runtime distribution and update responsibilities; explicit artifact configuration leaves deployment with the operator.
+
+**Convert PDF pages to PNG.** PDF.js already owns PDF presentation. Rasterizing adds another image pipeline and loses the existing PDF controls and selectable text.
+
+**Add a model-facing rendering tool or durable preview events.** Client preview does not supply model input. A model-facing tool requires logged facts and both SDK projections, so it is a separate consumer.
+
+**Scan every font-table entry for warnings.** Font tables include unused styles and templates. Reporting them would make the notice unrelated to rendered content; collection instead starts after engine initialization and ends after PDF save.
+
+## Consequences
+
+The single-provider design assumes WASM covers the required Office rendering behavior; it is not evidence of complete fidelity equivalence with native LibreOffice. Fidelity depends on the engine build and installed fonts. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) cover DOCX, XLSX, PPTX, selectable Chinese text, and embedded system fonts; ABI fixtures alone cannot establish rendering fidelity or assembled browser presentation.
+
+WASM instances have substantial memory and startup costs, so the provider bounds concurrency. The [font reuse and image resolution decision](2026-09-11-wasm-preview-font-and-image-budgets.md) owns shared Host metadata, Worker request memoization, and raster export limits; only selected fonts enter engine memory. Large collections require bounded memory growth, and CFF export requires checked stack space. A fatal abort prevents further calls into C++. A device without a font covering a character cannot render it correctly without additional fonts. Owning the WASM build adds compiler compatibility, patch maintenance, and redistribution obligations.
+
+Prewarming consumes conversion resources to reduce foreground waits; entry and byte limits bound retained Client results. Source and PDF bytes remain outside Session storage and persistent caches. The [provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover independent output facts, input/output rejection, queued cancellation, and active Worker termination. Existing preview registry, file read authority, and Session ownership decisions remain active.

+ 52 - 0
.agents/notes/archived/architecture/2026-09-10-local-office-preview.zh.md

@@ -0,0 +1,52 @@
+# Agent Note: 通过 WASM PDF 字节进行本地 Office 预览
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-10-local-office-preview.md) | 中文
+
+## 问题
+
+Office Open XML 文件是 ZIP 归档,因此文本回退无法提供有用的预览。文档转换必须留在本地,不抢占焦点,也不将文档内容放入模型对话。仅凭引擎成功状态无法证明输入具有声明的格式或生成了 PDF。
+
+## 决定
+
+[document-render Service Definition](../../../../packages/document/document-render/README.zh.md)、LibreOffice WASM 提供方及 Client/API 消费方构成转换能力。[启动组合](../../../../packages/document/document-render-auto/README.zh.md)仅在配置 `wasm.artifactDirectory` 时挂载提供方和 Remote 控制器。无效的显式配置会使初始化失败。Office Client 注册始终存在,为未配置的 Host 提供指引。可用性由生成的 Remote 命名空间决定,不发现可执行文件,也不维护重复的 Client 标记。
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)使用固定的官方 LibreOffice 源码、Emscripten 和 LibreOfficeKit 适配器。每个 Node Worker 拥有一个引擎实例、其 pthread 和内存文件系统。终止 Worker 可取消同步引擎调用;每次转换均在返回前等待 Worker 停止。OOXML 检查在启动前验证归档成员、内容类型和资源限制。
+
+VCL 回调在替换前请求字体族,或在字形回退时请求缺失的 Unicode 字符。Host 代码索引已安装字体元数据,将选中的文件复制到引擎内存,包括完整字体集合。精确匹配的已安装字体优先于配置的替代项;原字体优先的 fontconfig 别名在 LibreOffice 内保留此顺序。可选的初始字体族使用同一解析器。引擎不挂载 Host 文件系统,渲染不安装或下载字体。[构建配方](../../../../native/libreoffice-wasm/README.zh.md)记录源码与工具链版本、补丁及产物哈希。提供方使用期间,引擎包保持不可变;不同构建使用新目录。
+
+[独立引擎发布](../process/2026-09-11-independent-libreoffice-package.zh.md)负责预编译 npm 打包,并将引擎编译与普通 DSH 构建隔离。
+
+提供方返回调用方拥有的 PDF 字节,以及独立的 `succeeded`、`timedOut` 和 `cancelled` 事实。PDF 字节在清理前从引擎内存复制出来。API 消费方通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件,要求转换成功且未被中断,并直接编码 PDF。源路径和新鲜度版本保留在 PDF 传输中。Workspace Files 限制源文件读取;生成 PDF 仅受提供方的 `maxOutputBytes` 限制。无需临时 PDF 或文件租约。
+
+Office Client 观察所选 Conversation 现有的 Deliverables 投影,在不打开 tab 的情况下预转换最近的 Office 文件。有上限的内存缓存每次读取都检查已授权的源文件元数据,并在后台和前台调用方之间共享进行中的转换。仅最后一个退出的读取方取消共享转换;失败不缓存,连接重置丢弃缓存字节。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责格式选择、加载、取消和 PDF.js Worker。
+
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
+
+缺失字体族观察值随 PDF 经过 Worker 和 Remote 响应。Office 插件在共享 PDF 滚动区域上方呈现 keyed 提示,关闭时移除其布局高度。字体解析器取源文档及主题显式引用与实际文档请求的交集,排除引擎默认字体和字体表清单;已安装别名和字形回退本身不代表字体族缺失。
+
+## 考虑过的替代方案
+
+**保留已安装的原生 LibreOffice 提供方。** 第二个提供方增加可执行文件发现、版本查询、子进程监督、临时文件和平台特定隔离。本设计将 WASM 用于全部受支持的 Office 预览。引入原生提供方需要证明存在 WASM 无法满足的渲染或部署需求。
+
+**返回临时 PDF 路径。** Worker 已提供独立的 PDF 字节。将其写入磁盘、授权另一处文件读取并维护租约,会增加文件所有权,而没有当前消费方需要这些操作。源文件授权和输出限制直接应用于内存结果。
+
+**自动操作已安装的 Microsoft Office。** Word for Mac 需要 GUI 自动化而非 headless 转换,会引入焦点变化和逐文件操作系统授权。原生 Office 自动化不属于本预览能力。
+
+**使用在线转换器或自动下载引擎。** 远程转换会上传文档内容。自动安装增加运行时分发和更新责任;显式产物配置将部署留给运维者。
+
+**将 PDF 页面转换为 PNG。** PDF.js 已负责 PDF 展示。栅格化增加另一条图像流水线,并失去现有 PDF 控件和可选择文本。
+
+**添加面向模型的渲染工具或持久化预览事件。** Client 预览不提供模型输入。面向模型的工具需要记录事实并更新两种 SDK 投影,因此属于独立消费方。
+
+**扫描全部字体表条目生成提示。** 字体表包含未使用的样式和模板。报告这些条目会使提示与实际渲染内容无关,因此收集从引擎初始化完成后开始,在 PDF 保存后结束。
+
+## 影响
+
+单一提供方设计假设 WASM 覆盖所需的 Office 渲染行为;这不是与原生 LibreOffice 完全等价的保真度证据。保真度取决于引擎构建和已安装字体。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)覆盖 DOCX、XLSX、PPTX、可选择中文文本和嵌入系统字体;仅有 ABI fixture 无法证明渲染保真度或完整浏览器展示。
+
+WASM 实例有较高的内存与启动成本,因此提供方限制并发。[字体复用与图像分辨率决策](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)负责共享 Host 元数据、Worker 请求记忆化和栅格导出限制;仅选中字体进入引擎内存。大型集合需要有界内存增长,CFF 导出需要受检查的栈空间。致命 abort 后不再调用 C++。设备没有覆盖某字符的字体时,无法在不添加字体的前提下正确渲染该字符。自行构建 WASM 会增加编译器兼容、补丁维护和再分发义务。
+
+预转换消耗转换资源以减少前台等待;条目数和字节限制约束 Client 保留的结果。源文件和 PDF 字节不进入 Session 存储或持久缓存。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖独立输出事实、输入/输出拒绝、排队取消和活跃 Worker 终止。现有预览注册表、文件读权限和 Session 所有权决策保持有效。

+ 6 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md
+2026-09-11-wasm-preview-font-and-image-budgets.md: ab33b0bb6e28e5749ac38f36c08bc7539a368515
+2026-09-11-wasm-preview-font-and-image-budgets.zh.md: e6ff458e8b68ce08ff5d81ce6767a08a82974a74

+ 36 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.md

@@ -0,0 +1,36 @@
+# Agent Note: Font reuse and image resolution in WASM previews
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-wasm-preview-font-and-image-budgets.zh.md)
+
+## Problem
+
+Office conversion repeats font metadata parsing across documents and font matching within one document. Image-heavy documents also spend substantial PDF export time resampling images above preview resolution. These costs need separate controls because font reuse does not reduce image decoding or resampling.
+
+## Decision
+
+The [WASM provider](../../../../packages/document/document-render-libreoffice-wasm/README.md) builds a font metadata snapshot once during initialization. Each conversion Worker receives a structured clone. The Host retains names, face attributes, paths, sizes, and modification times; glyph coverage, request caches, and imported bytes belong to the Worker. Reloading the provider refreshes the snapshot. Workers validate indexed files when reading them, and an already imported font remains usable within that conversion.
+
+Each Worker memoizes complete requests: family, style, weight, italic, width, pitch, language, and ordered code points. The cache stores MEMFS paths and missing-family observations. Hits replay those observations because an initialization request can recur after document font collection starts. Worker termination releases both the cache and engine memory.
+
+Runtime PDF filter options reduce raster images to a configurable `maxImageResolution`, defaulting to 192 DPI. The [shared PDF canvas](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts) renders at 96 CSS DPI times the device pixel ratio; the default covers ratio 2. Text and vector graphics remain scalable. Explicit bookmark export preserves LibreOfficeKit's default when JSON filter options replace its implicit filter data.
+
+The [local Office preview decision](2026-09-10-local-office-preview.md) continues to own conversion lifetime, authorization, missing-font presentation, and PDF transport.
+
+## Alternatives considered
+
+**Index fonts inside every conversion Worker.** This keeps discovery off the Host event loop and sees newly installed fonts immediately, but repeats full-file metadata parsing across previews. A provider snapshot removes that repeated work at the cost of synchronous initialization and explicit reload after font changes.
+
+**Cache only the family name.** Style, language, pitch, and missing characters can select different files. A complete request key retains those distinctions and still captures repeated layout requests.
+
+**Keep 300 DPI for every preview.** Higher raster resolution retains detail for high pixel ratios and magnification but increases image export work. The configurable 192-DPI default follows the common display target without rasterizing text or rebuilding the engine.
+
+## Consequences
+
+Cold provider startup includes indexing and remains separate from conversion deadlines. Shared metadata retains no original font buffers, while each Worker still reads selected fonts and lazily decodes coverage. Installing or replacing fonts requires provider reload; a file changed since indexing can fail a later conversion.
+
+Reducing image resolution trades raster detail at high magnification for less export work. It is not an engine memory cap: LibreOffice can still decode full-resolution source images, load large font collections, or exhaust its build-time WASM memory maximum. No persistent font cache, filesystem watcher, or cross-document engine instance is introduced.
+
+[Font tests](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts) cover complete request keys, repeated missing-family observations, snapshot isolation, and failed imports. [Provider tests](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts) cover snapshot reuse and reload. The [real-engine tests](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts) inspect exported image dimensions with default and overridden DPI settings alongside selectable text and page count; ABI forwarding alone cannot establish filter behavior.

+ 36 - 0
.agents/notes/archived/architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md

@@ -0,0 +1,36 @@
+# Agent Note: WASM 预览的字体复用与图像分辨率
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-wasm-preview-font-and-image-budgets.md) | 中文
+
+## 问题
+
+Office 转换在不同文档间重复解析字体元数据,并在同一文档内重复匹配字体。图片较多的文档还会在 PDF 导出时花费大量时间,重采样高于预览显示分辨率的图像。这些成本需要分别控制,因为字体复用不会减少图像解码或重采样。
+
+## 决策
+
+[WASM 提供方](../../../../packages/document/document-render-libreoffice-wasm/README.zh.md)在初始化时建立一次字体元数据快照。每个转换 Worker 接收结构化克隆。Host 保留名称、字体面属性、路径、大小和修改时间;字形覆盖、请求缓存及导入字节属于 Worker。重新加载提供方会刷新快照。Worker 读取已索引文件时校验文件,已经导入的字体在该次转换内仍可使用。
+
+每个 Worker 记忆化完整请求:字体族、样式、字重、斜体、字宽、字距类型、语言及有序码点。缓存保存 MEMFS 路径和缺失字体族观察值。命中时重放这些观察值,因为初始化请求可能在文档字体收集开始后再次出现。Worker 终止时释放缓存及引擎内存。
+
+运行时 PDF 过滤器选项将栅格图像降低到可配置的 `maxImageResolution`,默认 192 DPI。[共享 PDF 画布](../../../../packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts)按 96 CSS DPI 乘以设备像素比渲染;默认值覆盖像素比 2。文本和矢量图形仍可缩放。JSON 过滤器选项替换隐含过滤器数据时,显式导出书签保留 LibreOfficeKit 的默认行为。
+
+[本地 Office 预览决策](2026-09-10-local-office-preview.zh.md)继续负责转换生命周期、授权、缺失字体展示和 PDF 传输。
+
+## 考虑过的替代方案
+
+**在每个转换 Worker 内索引字体。** 这会将发现工作移出 Host 事件循环,并立即识别新安装的字体,但会在不同预览间重复读取完整字体文件并解析元数据。提供方快照消除重复工作,代价是同步初始化,以及字体变化后显式重新加载。
+
+**只缓存字体族名称。** 样式、语言、字距类型和缺失字符可能选择不同文件。完整请求键保留这些区别,同时仍能命中重复排版请求。
+
+**所有预览保持 300 DPI。** 更高栅格分辨率保留高像素比及放大时的细节,但增加图像导出工作。可配置的 192 DPI 默认值符合常见显示目标,无需栅格化文本或重新构建引擎。
+
+## 影响
+
+提供方冷启动包含索引成本,不计入转换时限。共享元数据不保留原始字体缓冲区,各 Worker 仍会读取选中字体并按需解码字形覆盖。安装或替换字体后需要重新加载提供方;索引后发生变化的文件可能使后续转换失败。
+
+降低图像分辨率以高倍缩放下的栅格细节换取更少的导出工作。它不是引擎内存上限:LibreOffice 仍可能解码全分辨率原图、加载大型字体集合,或耗尽构建时设定的 WASM 内存上限。此实现不引入持久化字体缓存、文件系统监听器或跨文档引擎实例。
+
+[字体测试](../../../../packages/document/document-render-libreoffice-wasm/tests/fonts.spec.ts)覆盖完整请求键、重复缺失字体族观察值、快照隔离和失败导入。[提供方测试](../../../../packages/document/document-render-libreoffice-wasm/tests/provider.spec.ts)覆盖快照复用和重新加载。[真实引擎测试](../../../../packages/document/document-render-libreoffice-wasm/tests/libreoffice-wasm.e2e.ts)检查默认及覆盖 DPI 设置时的导出图像尺寸、可选择文本和页数;仅验证 ABI 参数转发无法证明过滤器行为。

+ 9 - 0
.agents/notes/archived/manifest.json

@@ -364,6 +364,12 @@
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+    "architecture/2026-09-10-local-office-preview.i18n.yaml": "sha256:a43a2370c7434293ef98d1901a29b7b4b03f400a108c119f3ec5f2f7ca8ca3f8",
+    "architecture/2026-09-10-local-office-preview.md": "sha256:754cfb4dde9f1ee70e495095f12fc9e3c43af4a959250bb3a59ae44fd32fd763",
+    "architecture/2026-09-10-local-office-preview.zh.md": "sha256:d8fb9a65f437b585f2dfa2b4aa091634eeabf571e98e881cf7344b7574704e53",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
+    "architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
@@ -1609,6 +1615,9 @@
     "process/2026-09-03-workspace-version-coherence-gate.i18n.yaml": "sha256:ac442dee172d396395b8516d6d38c4fcedbc855d735c86c378aec5c1098ecd8d",
     "process/2026-09-03-workspace-version-coherence-gate.md": "sha256:37b63a78506a8741eb04826dc1eea7e3a64c19fd4494c2d7754054a5602d308b",
     "process/2026-09-03-workspace-version-coherence-gate.zh.md": "sha256:cfa2c8fe6cf1d4665121a987861b70a91b5b41eb99e7ed192c85760a916fddf6",
+    "process/2026-09-11-independent-libreoffice-package.i18n.yaml": "sha256:e8d2fe75e05ac53438513a7c51dab6510bcecf644019391ee6650e73961c0f8d",
+    "process/2026-09-11-independent-libreoffice-package.md": "sha256:1292b409b7b4cbc4420868de5e3b7417b3c855889023ccfd9a61d824bf3019d8",
+    "process/2026-09-11-independent-libreoffice-package.zh.md": "sha256:384783da1614c7e2ea84ea513013af6b20e9d4545a03cf7fa068415078126932",
     "simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml": "sha256:43fe5daadc1491f94a3e34595182cad1591f76b64e1f13b48811b80fd7e53b94",
     "simplification/2026-06-19-drop-mutable-session-summary.md": "sha256:01647a5a14aa4e195328d4d39c6d80eb0723739e5a543115314716b280a93560",
     "simplification/2026-06-19-drop-mutable-session-summary.zh.md": "sha256:22389e0c29158b1f7a5a43ed6073f8795bb87cf51c04cf83be055359cd65c9f5",

+ 6 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-independent-libreoffice-package.md
+2026-09-11-independent-libreoffice-package.md: 05fd8eb86ffb75b564008eaffda5c98669afee27
+2026-09-11-independent-libreoffice-package.zh.md: b2abf61f8a5cf9b247fa2b94697eb90a252d4cff

+ 32 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.md

@@ -0,0 +1,32 @@
+# Agent Note: Independent precompiled LibreOffice package
+
+Status: implemented
+Archived: 2026-09-11
+
+English | [中文](2026-09-11-independent-libreoffice-package.zh.md)
+
+## Problem
+
+Compiling LibreOffice inside ordinary DSH builds would require every contributor and CI job to acquire its toolchain and repeat a large native build. Desktop also needs immutable engine resources that can travel with its offline installation material.
+
+## Decision
+
+The [engine package](../../../../native/libreoffice-wasm/package.json) has its own version and an asset-only manifest export. Its source directory stays outside the main pnpm workspace. Installation has no lifecycle hook, and packaging verifies an existing successful build without compiling. The [release workflow](../../../../.github/workflows/libreoffice-wasm-release.yml) compiles only on explicit dispatch; publication requires a matching engine version tag and the protected npm environment.
+
+Each tarball contains the engine manifest and assets, corresponding source pins, patches, full source diff, and the built distribution's license and notices. Packaging rejects mismatched build receipts, modified assets, unlisted engine files, missing notices, and bundled fonts. A failure never triggers compilation automatically.
+
+The [local preview decision](../architecture/2026-09-10-local-office-preview.md) continues to own conversion and explicit artifact configuration. This change establishes release machinery; it does not add an unpublished engine dependency to DSH. The existing [Desktop package set](../../../../apps/desktop/scripts/prepare-package-set.ts) remains the intended carrier for a future fixed-version dependency and its offline seed.
+
+## Alternatives considered
+
+**Compile during ordinary builds or package installation.** This puts engine toolchain availability and compilation cost on every consumer, even when the engine version has not changed.
+
+**Extract a general Node conversion library.** Font handling, Worker ownership, and the conversion API can remain with the provider. Moving them is unnecessary to distribute precompiled assets or isolate compilation from DSH CI.
+
+**Link the engine source as a workspace dependency.** Workspace linking would replace a published precompiled package with a source directory that lacks its engine files.
+
+## Consequences
+
+The engine can be compiled and versioned independently of DSH. The package exposes files rather than a conversion API; the provider retains runtime ownership. Ordinary builds remain free of LibreOffice compilation, as checked by the [packaging tests](../../../../scripts/libreoffice-package.spec.ts).
+
+Initial publication and fixed-version consumer integration remain incomplete. A source tree that differs from its recorded recipe cannot produce a release tarball. Real-engine conversion, installation from the packed package, and Desktop offline seed qualification must accompany the first consumed engine release.

+ 32 - 0
.agents/notes/archived/process/2026-09-11-independent-libreoffice-package.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: 独立的预编译 LibreOffice 包
+
+Status: implemented
+Archived: 2026-09-11
+
+[English](2026-09-11-independent-libreoffice-package.md) | 中文
+
+## 问题
+
+在普通 DSH 构建中编译 LibreOffice,会要求每个贡献者和 CI job 获取工具链并重复执行大型本机构建。Desktop 也需要能随离线安装材料分发的不可变引擎资源。
+
+## 决策
+
+[引擎包](../../../../native/libreoffice-wasm/package.json)采用独立版本,只导出产物清单。源码目录不属于主 pnpm workspace。安装没有生命周期 hook,打包只校验已有的成功构建,不执行编译。[发布工作流](../../../../.github/workflows/libreoffice-wasm-release.yml)仅在明确调度时编译;发布要求匹配的引擎版本 tag 和受保护的 npm 环境。
+
+每个 tarball 包含引擎清单和产物、对应的源码版本、补丁、完整源码差异,以及构建发行物的许可证和声明。打包拒绝不匹配的构建记录、被修改的产物、未列入清单的引擎文件、缺失的声明和内置字体。失败不会自动触发编译。
+
+[本地预览决策](../architecture/2026-09-10-local-office-preview.zh.md)继续负责转换与显式产物配置。此改动建立发布机制,不向 DSH 添加尚未发布的引擎依赖。现有 [Desktop 包集合](../../../../apps/desktop/scripts/prepare-package-set.ts)仍是未来固定版本依赖及其离线 seed 的预期载体。
+
+## 考虑过的替代方案
+
+**在普通构建或包安装期间编译。** 即使引擎版本没有变化,每个消费方也要承担工具链可用性要求和编译开销。
+
+**抽出通用 Node 转换库。** 字体处理、Worker 所有权和转换 API 可以继续由提供方负责。分发预编译产物及将编译与 DSH CI 隔离不需要迁移这些逻辑。
+
+**将引擎源码链接为 workspace 依赖。** 工作区链接会把已发布的预编译包替换为缺少引擎文件的源码目录。
+
+## 影响
+
+引擎可以独立于 DSH 编译和发布版本。包暴露文件而非转换 API;提供方保留运行时所有权。[打包测试](../../../../scripts/libreoffice-package.spec.ts)检查普通构建不编译 LibreOffice。
+
+首次发布和固定版本消费方接入尚未完成。与记录的配方不一致的源码树不能产生发布 tarball。首个被消费的引擎发行版必须同时完成真实引擎转换、打包安装,以及 Desktop 离线 seed 验证。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md
-2026-09-08-document-preview-operations.md: 43cc8d935763512a53379466bb796b5cac469793
-2026-09-08-document-preview-operations.zh.md: 44eccba894b3748a1d8640561a9eb760de481919
+2026-09-08-document-preview-operations.md: 980e2e443db13d6a956d554172bc765a5c11b435
+2026-09-08-document-preview-operations.zh.md: 364ef9ced6f8d98313f9bc287588c98daa408837

+ 5 - 1
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md

@@ -16,12 +16,16 @@ Document Preview separates resource observation from content reads. The [resourc
 
 Readable files use `dsh-resource://file/session/<sessionId>/<path>`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity.
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives only when at least two exist and remembers a manual choice per tab; plain text is the fallback except for suffixes a registration declares binary or the owner's unviewable list names ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session.
 
 Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array<ArrayBuffer>` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `<img>` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL.
 
+PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages.
+
 ## 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.
+
 **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.
 
 **A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview.

+ 5 - 1
.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md

@@ -16,12 +16,16 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0
 
 可读取的文件使用 `dsh-resource://file/session/<sessionId>/<path>`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。
 
-[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏仅在候选不少于两个时列出匹配候选,按 tab 记住手动选择;纯文本是兜底,但注册声明为二进制的后缀和 owner 的 unviewable 清单所列后缀除外([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
+[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。
 
 Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array<ArrayBuffer>` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `<img>` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。
 
+PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。
+
 ## 考虑过的替代方案
 
+**通过预览元数据中的回调加载转换内容。** 这种回调会让共享文件 store 同时持有源文件字节和格式专属的转换结果。由渲染器自行加载可将转换缓存、错误和字体元数据留在 Office,同时保留共享文件身份和工具栏控件。定义声明 `loading: 'renderer'`;正文接收与格式无关的加载 revision,只报告已展示的源版本。重新加载或替换实现会增加 revision,过期报告会被忽略,正文在替换或卸载时取消待处理工作。Office 在 tab 生命周期内保留已完成的内容,并组合自己的 PDF 子 slot。
+
 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。
 
 **核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md
+2026-09-11-node-office-kit.md: d77d6c5392532f39a9993af60faad6474bc8f36c
+2026-09-11-node-office-kit.zh.md: a0cbd22e4f06bd4f2878adf27852f9d2378b8b33

+ 65 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md

@@ -0,0 +1,65 @@
+# Agent Note: Node Office conversion with independently packaged engines
+
+Status: implemented
+
+English | [中文](2026-09-11-node-office-kit.zh.md)
+
+## Problem
+
+Binary Office and OOXML files require document layout before they can be previewed. Conversion must stay on the device without opening an Office application or adding source bytes to a model conversation. Native engines need platform-specific distribution, while browser conversion duplicates font transport, worker ownership, and resource limits across the Host and Client.
+
+## 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 [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.
+
+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.
+
+The kit owns default serif, sans-serif, and monospace preference groups, including Chinese text families. Missing Chinese glyphs in Western text try the corresponding common text families before searching the remaining catalog, which avoids choosing a handwriting face solely because its file sorts first. The provider's optional `fontFallbacks` replaces these ordered groups without duplicating their defaults. Preferences preserve exact installed fonts and retain other covering fonts as a last resort; they are not a font whitelist. The native adapter writes missing-family choices into its private VCL profile. Installed metric-compatible fonts can resolve before that table, and platform glyph fallback remains available. Native platform selection and whole collection imports require inspecting the fonts actually used in exported PDFs.
+
+DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine.
+
+The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the Office scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values.
+
+Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory.
+
+The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion.
+
+Desktop installs the kit through its existing target-Node pnpm dependency installation and retains the complete dependency tree. Worker paths and executable permissions remain ordinary package files. The [Desktop build guide](../../../../apps/desktop/README.md) owns target selection and packaging; each signed application requires qualification on its target platform.
+
+The [Python executable distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md) keeps the kit, target engine, and their dependency closure beside the executable. Its installed-wheel smoke relocates the payload, requires exactly the target backend, and converts DOCX once through that engine. Platform packaging and publication constraints belong to the [platform engine decision](2026-09-15-platform-office-engines.md).
+
+The notices gate permits only the exact API and engine package names at MPL-2.0 and continues to reject unrelated MPL or changed non-permissive terms. Each recipient must retain access to the kit’s corresponding LibreOffice source pin, patches, build instructions, and license notices; the engine packages retain their third-party notices. [MPL source availability](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) applies when distributing covered executables outside the organization.
+
+The [browser-only preview](../../../../packages/experimental/webworker-runtime/README.md) replaces the kit entry with an unavailable converter and excludes its engine dependency tree from the VFS image. Keeping the Host provider loadable preserves the ordinary Office error presentation without shipping Node Workers, native helpers, or WASM conversion resources to the browser.
+
+## Alternatives considered
+
+**Keep conversion in browser Workers.** This requires shipping the engine to the Client, browser font RPC, and shared-memory response headers. Node already owns authorized disk access and can serve the resulting PDF to every Client.
+
+**Use the installed soffice CLI or automate Microsoft Office.** Executable discovery and ambient versions weaken reproducibility; Office GUI automation additionally changes focus and requires application permissions. The distributed helper owns a fixed engine without requiring either application installation.
+
+**Load a native addon in the Host process.** A parser crash or synchronous stall would affect the Host. The separate native helper provides an independently terminable lifetime; its disk exchange also works with the WASM adapter.
+
+**Fall back after any native error.** Retrying a damaged package or failed document with another engine hides release defects, duplicates work, and makes output depend on failure timing. Platform selection requires the kit’s declared native target engine, or WASM when no native target is declared; it does not retry native failures with WASM.
+
+**Return a temporary PDF path, or rasterize pages to PNG.** The Client needs PDF bytes for its existing viewer and selectable text. Exposing temporary paths would add authorization and lease ownership; PNG would create another rendering pipeline and remove PDF controls.
+
+**Index every font for every render, or cache only family names.** Repeated indexing dominates small-document work, while family-only keys lose style and glyph distinctions. Shared metadata and complete per-request keys remove repeated parsing without retaining font buffers or introducing a filesystem watcher.
+
+**Compile at install time, download engines or fonts at runtime, or use an online converter.** These add toolchain or network requirements and may move private content off device. Prebuilt tarballs keep installation and conversion independent of those operations.
+
+**Keep an independent Office UI package.** Its reader, cache, and font notice have the same preview lifecycle and PDF presentation consumers. A separate plugin adds a package, boot wiring, and a Loader switch without an independently evolving UI responsibility. Host conversion and Remote authorization retain their separate plugins.
+
+**Remove the shared Client conversion cache.** Per-tab state cannot share pending conversion or retained PDFs across readers. A bounded cache avoids that repeated work while preserving per-reader cancellation and authorized version checks.
+
+**Add a model-facing rendering tool or durable preview events.** Preview provides no model input. Such a tool would require logged facts and both SDK projections and remains a separate consumer.
+
+## Consequences
+
+Native and WASM fidelity still depends on the build, source formatting, installed fonts, and platform font discovery. A missing glyph cannot be recovered without a covering font. Image resolution limits do not cap image decoding or total process memory. WASM retains bounded memory growth and checked stack space for large collections and CFF fonts; a fatal runtime abort prevents subsequent C++ cleanup calls. Macros and document-link updates are disabled by supported LOKit options and the pinned source patch; this does not constitute an OS sandbox.
+
+The [provider tests](../../../../packages/document/office-to-pdf/tests/provider.spec.ts), [Loader composition](../../../../packages/bundle/web-app/tests/document-preview.spec.ts), and [browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) own DSH lifecycle, authorization, and presentation evidence. Engine qualification additionally requires real DOC/DOCX/XLS/XLSX/PPT/PPTX conversion, external PDF text/font/page/image inspection, relocation and corrupted-package rejection, and same-input native/WASM performance samples. Mock helpers and microbenchmarks do not establish these outcomes. Each target's real builder and Desktop package need independent qualification; a successful local architecture does not prove the full matrix.

+ 65 - 0
.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md

@@ -0,0 +1,65 @@
+# Agent Note: 独立打包引擎的 Node Office 转换
+
+Status: implemented
+
+[English](2026-09-11-node-office-kit.md) | 中文
+
+## 问题
+
+二进制 Office 和 OOXML 文件需要经过文档排版才能预览。转换必须留在设备上,不能打开 Office 应用,也不能将源字节加入模型对话。原生引擎需要按平台分发,而浏览器转换会在 Host 和 Client 两侧重复字体传输、worker 生命周期与资源限额管理。
+
+## 决策
+
+[文档渲染能力](../../../../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 生命周期。
+
+[平台引擎决策](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 存储或持久缓存。
+
+converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。
+
+kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中文正文字体。西文文本缺少中文字形时,先尝试同类的常用正文字体,再搜索其余字体目录,避免仅因文件排序靠前而选用手写体。provider 的可选 `fontFallbacks` 替换这些有序组,不重复维护默认值。优先规则保留已安装原字体的精确匹配,并以其他覆盖字体作为最后兜底;它们不是字体白名单。原生适配器将缺失字体的选择写入私有 VCL profile。已安装的度量兼容字体可能在查询该表前被选中,平台的字形回退仍然可用。原生平台的选择及完整字体集合的导入要求检查导出 PDF 实际使用的字体。
+
+DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。
+
+[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在 Office 滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。
+
+普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。
+
+[kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。
+
+Desktop 通过现有的目标 Node pnpm 依赖安装流程安装 kit,并保留完整依赖树。Worker 路径和可执行权限仍由普通包文件承载。[Desktop 构建指南](../../../../apps/desktop/README.zh.md) 负责目标选择与打包;每个签名应用仍需在目标平台验收。
+
+[Python 可执行分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)将 kit、目标引擎及其依赖闭包保留在可执行文件旁。安装后的 wheel 冒烟测试会迁移载荷,要求仅存在目标后端,并通过该引擎转换一次 DOCX。各平台的打包与发布限制由[平台引擎决策](2026-09-15-platform-office-engines.zh.md)说明。
+
+声明检查仅放行精确的 API 与引擎包名及 MPL-2.0 条款,继续拒绝无关 MPL 包或变更后的非宽松条款。每位接收者都必须保有访问 kit 对应 LibreOffice 源码版本、补丁、构建说明与许可证声明的权限;引擎包保留各自的第三方声明。向组织外部分发受覆盖的可执行文件时,须满足 [MPL 源码可用性要求](https://www.mozilla.org/en-US/MPL/2.0/FAQ/)。
+
+[纯浏览器预览](../../../../packages/experimental/webworker-runtime/README.zh.md)用返回不可用错误的转换器替代 kit 入口,并从 VFS 镜像排除其引擎依赖树。保留 Host provider 的可加载性,可以沿用正常的 Office 错误展示,而无需向浏览器分发 Node Worker、原生辅助程序或 WASM 转换资源。
+
+## 考虑过的替代方案
+
+**保留浏览器 Worker 转换。** 这需要向 Client 分发引擎、提供浏览器字体 RPC 和配置共享内存响应头。Node 已经拥有授权磁盘访问能力,可以向所有 Client 提供转换后的 PDF。
+
+**使用已安装的 soffice CLI 或自动化 Microsoft Office。** 可执行文件发现和环境中的版本削弱可复现性;Office GUI 自动化还会改变焦点并需要应用权限。随包辅助进程拥有固定引擎,不要求安装这两类应用。
+
+**在 Host 进程中加载原生 addon。** 解析器崩溃或同步阻塞会影响 Host。独立的原生辅助进程具有可单独终止的生命周期,其磁盘交换方式也适用于 WASM 适配层。
+
+**任何原生错误都触发回退。** 使用另一引擎重试损坏的包或失败文档会隐藏发布缺陷、重复计算,并使输出取决于失败时机。平台选择要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM;原生失败后不会使用 WASM 重试。
+
+**返回临时 PDF 路径,或将页面栅格化为 PNG。** Client 需要 PDF 字节来使用现有查看器和可选择文本。暴露临时路径会增加授权与租约管理;PNG 则会建立另一条渲染管线并丢失 PDF 控件。
+
+**每次渲染重新索引所有字体,或只按字体族缓存。** 重复索引主导小文档成本,而仅以字体族为键会丢失样式和字形差异。共享元数据与完整请求键减少重复解析,无需保留字体缓冲区或引入文件系统监听器。
+
+**安装时编译、运行时下载引擎或字体,或使用在线转换器。** 这些方案增加工具链或网络要求,并可能将私有内容移出设备。预构建 tarball 使安装与转换不依赖这些操作。
+
+**保留独立的 Office UI 包。** 其读取器、缓存和字体提示共享预览生命周期与 PDF 展示消费者。独立插件增加包、启动接线和 Loader 开关,却没有独立演进的 UI 职责。Host 转换与 Remote 授权仍保留独立插件。
+
+**移除共享的 Client 转换缓存。** tab 内状态无法在读取方之间共享进行中的转换或已保留的 PDF。有界缓存减少这些重复工作,同时保留各读取方的取消和已授权版本检查。
+
+**添加面向模型的渲染工具或持久预览事件。** 预览不会提供模型输入。这样的工具需要日志事实及两套 SDK 投影,仍属于独立消费者。
+
+## 后果
+
+原生与 WASM 的保真度仍取决于构建、源文件格式、已安装字体及平台字体发现。没有覆盖字体就无法恢复缺失字形。图片分辨率限额不限制图片解码或总进程内存。WASM 为大型字体集合和 CFF 字体保留有界内存增长与受检查的栈空间;致命运行时中止会阻止后续 C++ 清理调用。宏与文档链接更新由实际支持的 LOKit 选项和固定源码补丁禁用;这不构成操作系统沙箱。
+
+[提供方测试](../../../../packages/document/office-to-pdf/tests/provider.spec.ts)、[Loader 组合](../../../../packages/bundle/web-app/tests/document-preview.spec.ts)和[浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts)负责 DSH 生命周期、授权与展示证据。引擎验收还需要真实 DOC/DOCX/XLS/XLSX/PPT/PPTX 转换、外部 PDF 文本、字体、页数与图片检查、迁移安装和损坏包拒绝,以及同输入的原生/WASM 性能样本。模拟辅助进程和微基准不能证明这些结果。各目标的真实构建机与 Desktop 安装包需要独立验收;一个本地架构成功不能证明整个矩阵。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md
-2026-09-15-bounded-office-conversion.md: 75e61ce0c49dafac68d4cf3a653d13cbee096e4c
-2026-09-15-bounded-office-conversion.zh.md: a2accf02ec058fa90b83e36bc761a2b7637eaef9
+2026-09-15-bounded-office-conversion.md: 78a2fc46d97500575262d2263bf5549037872264
+2026-09-15-bounded-office-conversion.zh.md: 88cb15c8cbf2200a9365b9a42cb5802c1bfb65d8

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md

@@ -10,7 +10,7 @@ Office preview and explicit document inspection can request the same conversion.
 
 ## Decision
 
-The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
+The `office-to-pdf` service returns complete PDF bytes. Its Remote file entry authorizes Session files through `workspaceFiles`, while its in-process conversion accepts authorized deferred reads without requiring that service. The `api/remotes` assembly owns Client namespace mounting. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI.
 
 The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse.
 
@@ -34,4 +34,6 @@ Foreground preview and explicit QA requests precede background work. Disabling b
 
 The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish.
 
+Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output.
+
 Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md

@@ -10,7 +10,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
 
 ## 决策
 
-`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
+`office-to-pdf` 服务返回完整 PDF 字节。其 Remote 文件入口通过 `workspaceFiles` 授权 Session 文件,进程内转换则接受已授权的延迟读取,不要求该服务。`api/remotes` 装配负责 Client 命名空间挂载。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。
 
 [宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。
 
@@ -34,4 +34,6 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成
 
 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。
 
+Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。
+
 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。

+ 256 - 7
apps/web/tests/document-preview.e2e.ts

@@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
-import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
+import { realOfficeBytes } from './office-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'
 
@@ -29,6 +30,24 @@ async function successShot(page: Page, name: string): Promise<void> {
   await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true })
 }
 
+/** Exercise native browser selection and copy, including the text overlay's canvas alignment. */
+async function copyPdfText(page: Page, preview: Locator, expected: string): Promise<void> {
+  const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first()
+  await text.waitFor({ state: 'visible' })
+  await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text')
+  await text.click({ clickCount: 3 })
+  await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected)
+  await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
+  await page.keyboard.press('ControlOrMeta+C')
+  await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected)
+  await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
+    const canvas = node.querySelector('canvas')!.getBoundingClientRect()
+    const layer = node.querySelector('.textLayer')!.getBoundingClientRect()
+    return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height),
+      Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top))
+  })).toBeLessThan(1)
+}
+
 /** Trigger the document owner's native scroll handler after a real first page overflows. */
 async function scrollForNextPage(body: Locator): Promise<void> {
   await body.evaluate((node) => {
@@ -51,6 +70,13 @@ async function canvasColor(canvas: Locator): Promise<string> {
   })
 }
 
+/** Select a workspace file through the Files tab and wait for its preview identity. */
+async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise<void> {
+  await filesTab.click()
+  await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
+  await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
+}
+
 describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -130,6 +156,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
         '</svg>',
       ].join('')),
       writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
+      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()),
+      ...['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])),
     ])
 
@@ -171,12 +201,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     expect(restoredFilesClose).toBe(1)
     expect(restoredAdd).toBe(1)
     const preview = column.locator('[data-document-preview]')
-    const openFile = async (name: string): Promise<void> => {
-      await filesTab.click()
-      await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
-      await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
-    }
-    // Binary suffixes (bitmaps, PDF) drop the plain-text fallback; a single remaining viewer renders no control.
+    const openFile = openPreviewFile.bind(undefined, column, filesTab, preview)
     const viewer = preview.locator('[data-document-viewer-menu]')
     const body = preview.locator('[data-textpreview-body]')
     const sections = ['# Document preview']
@@ -307,6 +332,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const restoredColor = await canvasColor(secondPage)
     expect(restoredColor).toBe('blue')
     expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId)
+    await copyPdfText(page, preview, 'Selectable PDF text')
     await successShot(page, 'pdf')
     sections.push([
       '## PDF', '',
@@ -316,8 +342,81 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
       `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
       `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
+      '- Selected and copied text: Selectable PDF text',
     ].join('\n'))
 
+    await openFile('user-unit.pdf')
+    await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+    await copyPdfText(page, preview, 'Selectable PDF text')
+    sections.push('## PDF page units\n\n- UserUnit 2: selected and copied text aligns with the canvas')
+
+    const viewportSize = page.viewportSize()!
+    try {
+      for (const rotation of [90, 180, 270]) {
+        await openFile(`rotated-${rotation}.pdf`)
+        for (const width of [viewportSize.width, 1280]) {
+          await page.setViewportSize({ ...viewportSize, width })
+          await copyPdfText(page, preview, 'Selectable PDF text')
+          // The fixture's only black pixels are text; canvas ink is independent of the overlay geometry.
+          await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
+            const canvas = node.querySelector('canvas')!
+            const canvasBox = canvas.getBoundingClientRect()
+            const textBox = node.querySelector('.textLayer span')!.getBoundingClientRect()
+            const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
+            let ink = 0
+            let aligned = 0
+            for (let i = 0; i < pixels.length; i += 4) {
+              if (pixels[i + 3]! < 128 || Math.max(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!) > 80) continue
+              ink++
+              const x = canvasBox.left + ((i / 4) % canvas.width + 0.5) * canvasBox.width / canvas.width
+              const y = canvasBox.top + (Math.floor(i / 4 / canvas.width) + 0.5) * canvasBox.height / canvas.height
+              if (x >= textBox.left - 1 && x <= textBox.right + 1 && y >= textBox.top - 1 && y <= textBox.bottom + 1) aligned++
+            }
+            return ink === 0 ? 0 : aligned / ink
+          })).toBeGreaterThan(0.95)
+        }
+      }
+    } finally { await page.setViewportSize(viewportSize) }
+    sections.push('## PDF page rotation\n\n- 90, 180, 270 degrees: selection and copied text align with canvas ink before and after resizing')
+
+    await openFile('selection.pdf')
+    await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
+    const selectionLayer = preview.locator('.textLayer')
+    const selectionText = selectionLayer.locator('span:not(.markedContent)')
+    const titleText = selectionText.filter({ hasText: 'JOURNAL' })
+    const priorityText = selectionText.filter({ hasText: 'HIGH / MEDIUM / LOW' }).first()
+    // The text resize observer aligns the overlay after the canvas becomes visible.
+    await titleText.waitFor({ state: 'visible' })
+    await priorityText.waitFor({ state: 'visible' })
+    const titleBox = await titleText.boundingBox()
+    const priorityBox = await priorityText.boundingBox()
+    if (titleBox === null || priorityBox === null) throw new Error('selection fixture text has no bounds')
+    const start = { x: titleBox.x + 1, y: titleBox.y + titleBox.height / 2 }
+    const end = { x: priorityBox.x + priorityBox.width / 2, y: priorityBox.y - 3 }
+    const drag = async (from: typeof start, to: typeof end, through?: typeof end): Promise<string> => {
+      await page.mouse.move(from.x, from.y)
+      await page.mouse.down()
+      try {
+        if (through !== undefined) await page.mouse.move(through.x, through.y, { steps: 15 })
+        await page.mouse.move(to.x, to.y, { steps: 15 })
+        return await page.evaluate(() => window.getSelection()?.toString() ?? '')
+      } finally { await page.mouse.up() }
+    }
+    const priority = { x: end.x, y: priorityBox.y + priorityBox.height / 2 }
+    const forward = await drag(start, end, priority)
+    expect(forward).toContain('JOURNAL')
+    expect(forward).toContain('THREE TASKS')
+    expect(forward).not.toContain('REFLECTION')
+    expect(forward).not.toContain('AFTER TABLE')
+    const backward = await drag(priority, start)
+    expect(backward).toContain('THREE TASKS')
+    expect(backward).not.toContain('REFLECTION')
+    expect(backward).not.toContain('AFTER TABLE')
+    expect(await selectionLayer.locator('br').first().evaluate(node => getComputedStyle(node, '::selection').backgroundColor))
+      .toBe('rgba(0, 0, 0, 0)')
+    await successShot(page, 'pdf-drag-selection')
+    sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent')
+
     await openFile('tiny.png')
     const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
     await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
@@ -461,6 +560,25 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
       `- Tail: ${completed.at(-1)}`,
     ].join('\n'))
 
+    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']) {
+      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 })
+      expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
+      expect(await preview.getByText('OFFICE_BINARY_PREVIEW', { exact: false }).count()).toBe(0)
+      officeMenus.push(await viewer.count())
+    }
+    await successShot(page, 'office-unavailable')
+    sections.push([
+      '## Office unavailable', '',
+      `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
+      `- Guidance: ${configurationGuide}`,
+      '- Binary text shown: false',
+      '- Plain-text option and viewer picker: hidden',
+    ].join('\n'))
+
     await openFile('notes.unknown')
     const plainLines = preview.locator('[data-textpreview-line]')
     await expect.poll(() => plainLines.count()).toBe(2)
@@ -487,3 +605,134 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
   })
 })
+
+describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+
+  afterAll(async () => {
+    try { await browser?.close() } finally { await scaffold?.close() }
+  })
+
+  it('rejects renamed text and renders Chinese Office documents through the PDF worker', async () => {
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false,
+      extraOverlayPath: fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)),
+    })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    const tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    onTestFailed(async () => {
+      await saveFailureShot(page, `screenshots/0908-document-preview/office-${process.pid}`)
+    })
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
+    if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
+    await Promise.all([
+      writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'),
+      writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')),
+      writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')),
+      writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')),
+      ...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
+      ...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
+    ])
+    const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert')
+    try {
+      const column = page.locator('[data-rightbar-col]')
+      await page.locator('[data-sidebar-right-expand]').click()
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+      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]')
+      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 })
+      await canvas.waitFor({ state: 'visible', timeout: 60_000 })
+      await expect.poll(() => canvas.evaluate((node) => {
+        const canvas = node as HTMLCanvasElement
+        const context = canvas.getContext('2d')
+        if (context === null) return false
+        const bytes = context.getImageData(0, 0, canvas.width, canvas.height).data
+        for (let index = 0; index < bytes.length; index += 4) {
+          if (bytes[index + 3] === 255 && bytes[index]! < 200 && bytes[index + 1]! < 200 && bytes[index + 2]! < 200) return true
+        }
+        return false
+      }), { timeout: 30_000 }).toBe(true)
+      const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
+      expect(workerNames).toContain('dsh-pdf')
+      expect(workerNames.some(name => /libreoffice|soffice/i.test(name))).toBe(false)
+      await copyPdfText(page, preview, 'Office preview')
+      await copyPdfText(page, preview, '中文文档')
+      expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档')
+      expect(convert).toHaveBeenCalledTimes(1)
+      await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
+      await canvas.waitFor({ state: 'visible' })
+      expect(convert).toHaveBeenCalledTimes(1)
+      const notice = preview.locator('[data-office-font-notice]')
+      const more = notice.getByRole('button', { name: 'Show more', exact: true })
+      await more.waitFor({ state: 'visible' })
+      const before = await canvas.evaluate(node => node.getBoundingClientRect().top)
+      await more.click()
+      const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true })
+      await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' })
+      await successShot(page, 'office-font-details')
+      await page.keyboard.press('Escape')
+      await expect.poll(() => details.count()).toBe(0)
+      expect(await more.evaluate(node => node === document.activeElement)).toBe(true)
+      await more.click()
+      await page.getByRole('button', { name: 'Close font details', exact: true }).click()
+      expect(await more.isVisible()).toBe(true)
+      await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click()
+      await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0)
+      const after = await canvas.evaluate(node => node.getBoundingClientRect().top)
+      expect(before - after).toBeGreaterThan(40)
+      const topInset = await preview.evaluate((node) => {
+        const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect()
+        const canvas = node.querySelector('canvas')!.getBoundingClientRect()
+        return canvas.top - body.top
+      })
+      expect(topInset).toBe(0)
+      await successShot(page, 'office-font-dismissed')
+      await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [
+        '# Office font notice', '',
+        '- Requested absent family is listed: true',
+        '- Escape restores focus to Show more: true',
+        '- Closing details preserves the notice: true',
+        '- Dismissing the notice collapses its occupied height: 0',
+        `- Document top inset after dismissal: ${topInset}px`,
+      ].join('\n'), MODE)
+      await successShot(page, 'office-docx')
+      for (const extension of ['doc', 'xls', 'xlsx', '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('中文文档')
+        await successShot(page, `office-${extension}`)
+      }
+      expect(convert).toHaveBeenCalledTimes(6)
+      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 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']) {
+        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(tripwire.pageErrors).toEqual([])
+    } finally { convert.mockRestore() }
+  })
+})

+ 7 - 0
apps/web/tests/expected/office-font-notice.md

@@ -0,0 +1,7 @@
+# Office font notice
+
+- Requested absent family is listed: true
+- Escape restores focus to Show more: true
+- Closing details preserves the notice: true
+- Dismissing the notice collapses its occupied height: 0
+- Document top inset after dismissal: 0px

+ 6 - 0
apps/web/tests/fixtures/office/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write apps/web/tests/fixtures/office/README.md
+README.md: affa0b57b72414beb4b8e75f19481a92b8745d27
+README.zh.md: ff49b01c51754ae00c613a45ebc8d14f142da9c1

+ 7 - 0
apps/web/tests/fixtures/office/README.md

@@ -0,0 +1,7 @@
+# Binary Office fixtures
+
+English | [中文](README.zh.md)
+
+`preview.doc`, `preview.xls`, and `preview.ppt` contain `Office preview 中文文档`. They are the `one-page.doc`, `one-sheet.xls`, and `one-slide.ppt` fixtures from [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures), exported from Harness-authored OOXML with LibreOffice 26.8.0.3 using the Word, Excel, and PowerPoint 97 filters. The source documents retain the Harness MIT license.
+
+The browser regression reads these committed OLE files and verifies actual conversion and selectable PDF text. They contain no user documents and require no Office application or fixture generator during tests. They cover simple legacy imports, not complex-document fidelity.

+ 7 - 0
apps/web/tests/fixtures/office/README.zh.md

@@ -0,0 +1,7 @@
+# 二进制 Office 测试文件
+
+[English](README.md) | 中文
+
+`preview.doc`、`preview.xls` 和 `preview.ppt` 包含 `Office preview 中文文档`。它们来自 [LibreOffice Kit](https://github.com/deepseek-harness/libreoffice-kit/tree/main/test/fixtures) 的 `one-page.doc`、`one-sheet.xls` 和 `one-slide.ppt`,由 Harness 编写的 OOXML 通过 LibreOffice 26.8.0.3 的 Word、Excel 和 PowerPoint 97 过滤器导出。源文档保留 Harness 的 MIT 许可证。
+
+浏览器回归读取这些检入的 OLE 文件,验证真实转换和可选取的 PDF 文字。文件不含用户文档,测试期间不需要 Office 应用或文件生成器。它们覆盖简单的旧格式导入,不代表复杂文档的保真度。

BIN
apps/web/tests/fixtures/office/preview.doc


BIN
apps/web/tests/fixtures/office/preview.ppt


BIN
apps/web/tests/fixtures/office/preview.xls


+ 52 - 0
apps/web/tests/office-fixture.ts

@@ -0,0 +1,52 @@
+/** Small binary Office and OOXML documents for exercising the installed converter through the Web preview. */
+import { readFileSync } from 'node:fs'
+import { strToU8, zipSync } from 'fflate'
+
+const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
+const OFFICE_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
+const CONTENT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types'
+const TEXT = 'Office preview 中文文档'
+
+/**
+ * Create a valid one-page Office document containing Latin and Chinese text.
+ * @param extension - Office application and format to exercise.
+ * @param font - Latin family requested by generated OOXML documents; binary fixtures retain their stored fonts.
+ * @returns Compressed document bytes accepted by the production converter.
+ */
+export function realOfficeBytes(extension: 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx', font = 'Liberation Sans'): Uint8Array {
+  const files: Record<string, string> = {}
+  let main: string
+  let parts: Record<string, string>
+  switch (extension) {
+    case 'doc': case 'xls': case 'ppt':
+      return readFileSync(new URL(`./fixtures/office/preview.${extension}`, import.meta.url))
+    case 'docx':
+      main = 'word/document.xml'
+      parts = { [main]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml' }
+      files[main] = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="宋体"/></w:rPr><w:t>${TEXT}</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>`
+      break
+    case 'xlsx':
+      main = 'xl/workbook.xml'
+      parts = {
+        [main]: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml',
+        'xl/worksheets/sheet1.xml': 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml',
+      }
+      files[main] = `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="${OFFICE_REL_NS}"><sheets><sheet name="Preview" sheetId="1" r:id="rId1"/></sheets></workbook>`
+      files['xl/_rels/workbook.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/worksheet" Target="worksheets/sheet1.xml"/></Relationships>`
+      files['xl/worksheets/sheet1.xml'] = `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cols><col min="1" max="1" width="45" customWidth="1"/></cols><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>${TEXT}</t></is></c></row></sheetData></worksheet>`
+      break
+    case 'pptx':
+      main = 'ppt/presentation.xml'
+      parts = {
+        [main]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml',
+        'ppt/slides/slide1.xml': 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml',
+      }
+      files[main] = `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="${OFFICE_REL_NS}"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`
+      files['ppt/_rels/presentation.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/slide" Target="slides/slide1.xml"/></Relationships>`
+      files['ppt/slides/slide1.xml'] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Preview"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="7315200" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="2400"><a:latin typeface="${font}"/><a:ea typeface="宋体"/></a:rPr><a:t>${TEXT}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
+      break
+  }
+  files['_rels/.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/officeDocument" Target="${main}"/></Relationships>`
+  files['[Content_Types].xml'] = `<Types xmlns="${CONTENT_NS}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${Object.entries(parts).map(([part, type]) => `<Override PartName="/${part}" ContentType="${type}"/>`).join('')}</Types>`
+  return zipSync(Object.fromEntries(Object.entries(files).map(([path, content]) => [path, strToU8(content)])))
+}

+ 3 - 1
apps/web/tests/preview-boot.e2e.ts

@@ -18,6 +18,7 @@
  */
 import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { readFile } from 'node:fs/promises'
+import { once } from 'node:events'
 import { createServer } from 'node:http'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { tmpdir } from 'node:os'
@@ -211,7 +212,8 @@ async function respond(
  */
 async function serveDist(overrides: ReadonlyMap<string, string>): Promise<Site> {
   const server = createServer((request, response) => { void respond(request, response, overrides) })
-  await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
+  server.listen(0, '127.0.0.1')
+  await once(server, 'listening')
   const address = server.address()
   if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
   return {

+ 22 - 17
apps/web/tests/seeded-history.e2e.ts

@@ -471,24 +471,29 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
-    await fileLink.click()
-    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
     const column = page.locator('[data-rightbar-col]')
-    await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt'])
-    // Path label survives from the recorded args (a.txt).
-    await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
-    const path = column.locator('[data-textpreview-path]')
-    const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
-    await expect.poll(() => path.textContent()).toBe(absolutePath)
-    expect(await path.getAttribute('title')).toBe(absolutePath)
-    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
-    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
-    // Put the column back so the later goldens see the default frame.
-    await column.locator('[data-sidebar-right-toggle]').click()
-    await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
-    await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
-    await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
+    try {
+      await fileLink.click()
+      await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
+      await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt'])
+      // Path label survives from the recorded args (a.txt).
+      await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
+      const path = column.locator('[data-textpreview-path]')
+      const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
+      await expect.poll(() => path.textContent()).toBe(absolutePath)
+      expect(await path.getAttribute('title')).toBe(absolutePath)
+      await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
+      const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
+    } finally {
+      // Later cases share this page and require the sidebar closed even after a failed assertion.
+      if (await frame.getAttribute('data-rightbar-collapsed') !== 'true') {
+        await column.locator('[data-sidebar-right-toggle]').click()
+        await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
+      }
+      await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
+      await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
+    }
   })
 
   it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {

+ 1 - 0
apps/web/tsconfig.json

@@ -45,6 +45,7 @@
     "tests/lifecycle-chrome.e2e.ts",
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
+    "tests/office-fixture.ts",
     "tests/plugin-config.e2e.ts",
     "tests/plugin-manager.e2e.ts",
     "tests/plugin-install-cancel.e2e.ts",

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: b2799a1f0965d687a95af23b67873de1b86a8560
-capability-seams.zh.md: 23fbcdb5621f3129ac699284bd17359941646041
+capability-seams.md: a93d18226eeba66d025fb097d988be8f2ff1f482
+capability-seams.zh.md: 192aff1aa0b5d6306d3165573dd7d478428ea27e

+ 3 - 1
docs/capability-seams.md

@@ -28,6 +28,7 @@ flowchart LR
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
   pkg_office_to_pdf["office-to-pdf"]
   svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
+  pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -434,6 +435,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
   svc_pluginManager --> pkg_plugin_manager
   svc_pluginManager --> pkg_ui_settings_plugin_inventory
   svc_profileContext --> pkg_plugin_manager
@@ -536,7 +538,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |
-| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
+| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |

+ 3 - 1
docs/capability-seams.zh.md

@@ -30,6 +30,7 @@ flowchart LR
   pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
   pkg_office_to_pdf["office-to-pdf"]
   svc_officeToPdf["ctx.officeToPdf<br/>Office to PDF conversion"]
+  pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
   pkg_attachment["attachment"]
   svc_attachments["ctx.attachments<br/>Durable binary attachment storage"]
   pkg_attachment_local["attachment-local"]
@@ -436,6 +437,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
   svc_pluginManager --> pkg_plugin_manager
   svc_pluginManager --> pkg_ui_settings_plugin_inventory
   svc_profileContext --> pkg_plugin_manager
@@ -538,7 +540,7 @@ flowchart LR
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |
-| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
+| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 |
 | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
 | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
 | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: dd30690fbf89051c7242f0dc8104e15f79e8c163
-config-catalog.zh.md: 42054765eb75afdf3a6cb0598d7b81b612243fc7
+config-catalog.md: eeeaff21533f7d39934a5dd1b7bad24c6966456f
+config-catalog.zh.md: db3022cae52790f7e02e35e2fc6a755d0f325fd2

+ 24 - 2
docs/config-catalog.md

@@ -446,6 +446,29 @@ export interface Config {
 
 Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
+<a id="deepseek-aidsh-client-ui-sidebar-documentpreview"></a>
+
+## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
+
+```ts config-catalog
+/** Transient Office conversion reuse within one Client connection. */
+export interface Config {
+  /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */
+  office: {
+    /** Maximum retained completed PDFs. */
+    maxCachedEntries: number
+    /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */
+    maxCachedBytes: number
+    /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */
+    maxPending: number
+    /** Maximum readers including source and renderer metadata lookups. */
+    maxReaders: number
+  }
+}
+```
+
+Source: [`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts)
+
 <a id="deepseek-aidsh-compaction-basic"></a>
 
 ## `@deepseek-ai/dsh-compaction-basic`
@@ -1766,7 +1789,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts)
+Source: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 
@@ -3789,7 +3812,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 24 - 2
docs/config-catalog.zh.md

@@ -448,6 +448,29 @@ export interface Config {
 
 来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
+<a id="deepseek-aidsh-client-ui-sidebar-documentpreview"></a>
+
+## `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`
+
+```ts config-catalog
+/** Transient Office conversion reuse within one Client connection. */
+export interface Config {
+  /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */
+  office: {
+    /** Maximum retained completed PDFs. */
+    maxCachedEntries: number
+    /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */
+    maxCachedBytes: number
+    /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */
+    maxPending: number
+    /** Maximum readers including source and renderer metadata lookups. */
+    maxReaders: number
+  }
+}
+```
+
+来源:[`packages/client/ui-sidebar-documentpreview/src/config.ts:5`](../packages/client/ui-sidebar-documentpreview/src/config.ts)
+
 <a id="deepseek-aidsh-compaction-basic"></a>
 
 ## `@deepseek-ai/dsh-compaction-basic`
@@ -1768,7 +1791,7 @@ export interface Config {
 }
 ```
 
-来源: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts)
+来源: [`packages/document/office-to-pdf/src/index.ts:31`](../packages/document/office-to-pdf/src/index.ts)
 
 <a id="deepseek-aidsh-permission-presets"></a>
 
@@ -3791,7 +3814,6 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: b494af3ece876b5b10b30c3602d6912f25e5088a
-event-producer-consumer.zh.md: 36f97f867de1a897982a0dd7a463a8c20e85484d
+event-producer-consumer.md: e587a790acff79530ebce242678faabed15c304e
+event-producer-consumer.zh.md: b0489c1a8c729c7397abc4e1d6af11fbe393d6f6

+ 1 - 1
docs/event-producer-consumer.md

@@ -74,7 +74,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -76,7 +76,7 @@
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/office-to-pdf.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md
-office-to-pdf.md: 71254a3a8a8b4665d8d42b935cc836cb7c644b10
-office-to-pdf.zh.md: c11889fc9bd4693d90ec60696fce4b57693fd027
+office-to-pdf.md: 65f46ded856e81f9bcd7890c11c82f0a22f769c4
+office-to-pdf.zh.md: df46af01d78241bbc88f6b85a83c47551faf4bfe

+ 26 - 0
docs/subsystems/office-to-pdf.md

@@ -10,6 +10,7 @@ The [document package family](../../packages/document/README.md) converts Office
 |---|---|
 | [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching |
 | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers |
+| [Office preview Client](../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) | Office extension selection, PDF reuse, and missing-font notices |
 
 ## Requests and results
 
@@ -26,6 +27,14 @@ The [document package family](../../packages/document/README.md) converts Office
 
 The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads.
 
+## Preview reads
+
+`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF.
+
+The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events.
+
+The `api/remotes` assembly mounts the conversion service's generated Remote descriptor. The shared Document Preview package registers Office formats with complete-byte loading and its existing PDF.js Worker. Each preview read rechecks renderer generation, source authorization, and version before sharing an in-flight conversion or cached PDF. Connection resets and plugin disposal cancel requests and clear cached bytes. Missing services show localized configuration guidance.
+
 ## Engine selection and limits
 
 The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC.
@@ -55,6 +64,23 @@ A provider lifetime owns all converters, queued calls, and temporary files.
  * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
  */
 convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
+
+/**
+ * Read and convert one Office file using the Session's ordinary filesystem authorization.
+ * @param workspaceFileScope - Session header lookup shared with workspaceFiles.
+ * @param path - absolute or workspace-relative Office path.
+ * @param priority - foreground preview or speculative background work.
+ * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
+ * @returns complete base64 PDF with original source identity and missing font families.
+ */
+@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
+
+/**
+ * Read the current rendering generation before reusing a Client PDF.
+ * @param signal - Remote caller cancellation.
+ * @returns provider lifetime, replaced with rendering, font, or engine configuration.
+ */
+@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
 ```
 
 Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)

+ 26 - 0
docs/subsystems/office-to-pdf.zh.md

@@ -10,6 +10,7 @@
 |---|---|
 | [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 |
 | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 |
+| [Office 预览 Client](../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview) | Office 扩展名选择、PDF 复用和缺失字体提示 |
 
 ## 请求和结果
 
@@ -26,6 +27,14 @@
 
 提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。
 
+## 预览读取
+
+`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。
+
+`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。
+
+`api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。
+
 ## 引擎选择和限制
 
 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。
@@ -55,6 +64,23 @@ A provider lifetime owns all converters, queued calls, and temporary files.
  * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.
  */
 convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise<OfficeToPdfResult>
+
+/**
+ * Read and convert one Office file using the Session's ordinary filesystem authorization.
+ * @param workspaceFileScope - Session header lookup shared with workspaceFiles.
+ * @param path - absolute or workspace-relative Office path.
+ * @param priority - foreground preview or speculative background work.
+ * @param signal - Remote cancellation; disposal also cancels outstanding reads and conversions.
+ * @returns complete base64 PDF with original source identity and missing font families.
+ */
+@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise<RenderedDocumentBytes>
+
+/**
+ * Read the current rendering generation before reusing a Client PDF.
+ * @param signal - Remote caller cancellation.
+ * @returns provider lifetime, replaced with rendering, font, or engine configuration.
+ */
+@Remote('generation') getGeneration(signal: AbortSignal): OfficeToPdfGeneration
 ```
 
 Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts)

+ 2 - 2
docs/subsystems/sidebar-right.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: 3b6e5eb08fc2c0a0bac74369bde4a48b2ee0fdb6
-sidebar-right.zh.md: 680e2ae16f8f8aeeea57b11172be8c8c9e359122
+sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806
+sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47

+ 4 - 2
docs/subsystems/sidebar-right.md

@@ -106,14 +106,16 @@ A body, title and guide replacement receive the framework-injected `useTabInfo()
 
 ## Document renderers
 
-The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
+The `text` tab is the shared Document Preview owner. Its [root registration](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts) declares `sidebar.right.tab.document` and provides `ctx.documentPreviews`. A renderer registers `DocumentPreviewDefinition` metadata in its own effect, then waits through `ctx.slots.inject('sidebar.right.tab.document', ...)` and registers its component with `key: definition.id` and its locale namespace. A renderer registers its own body and can reuse shared presentation through its child slots. Changing the renderer does not change the tab or resource address; the [extension decision](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md) separates preview policy from resource ownership.
 
-The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. `loading` is `text-pages` or `bytes-complete`; `wrap` advertises support for the shared source-wrap control.
+The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control.
 
 [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array<ArrayBuffer>` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes.
 
 Preview records its loaded version and the version observed when a read starts. 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, 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)`. 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, and its body owns font notices above a nested PDF view. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
+
 ## Resource model
 
 The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.

+ 4 - 2
docs/subsystems/sidebar-right.zh.md

@@ -106,14 +106,16 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed 
 
 ## 文档渲染器
 
-`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
+`text` tab 是共享的 Document Preview 所有者。其[根注册](../../packages/client/ui-sidebar-documentpreview/src/client/index.ts)声明 `sidebar.right.tab.document` 并提供 `ctx.documentPreviews`。渲染器在自己的 effect 中注册 `DocumentPreviewDefinition` 元数据,再通过 `ctx.slots.inject('sidebar.right.tab.document', ...)` 等待 slot,以 `key: definition.id` 和自己的 locale 命名空间注册组件。渲染器注册自己的正文,并可通过子 slot 复用共享展示组件。切换渲染器不改变 tab 或资源地址;[扩展决议](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md)将预览策略与资源归属分开。
 
-[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`loading` 为 `text-pages` 或 `bytes-complete`;`wrap` 声明是否支持共享的源码换行控件。
+[注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。
 
 [`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。
 
 Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
 
+自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文在嵌套 PDF 视图上方管理字体提示。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
+
 ## 资源模型
 
 模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。

+ 2 - 2
packages/api/remotes/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/remotes/README.md
-README.md: 756022485beba09751bc39737c3acd97b2194c75
-README.zh.md: a6a6af909c1603148c83b356f495f60629279c79
+README.md: 4e365165f0b1c406e3d5693c772dbcc21f125589
+README.zh.md: 913e08ca7e35e5a6d2756347bf24a840660f85e0

+ 1 - 1
packages/api/remotes/README.md

@@ -27,7 +27,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos
 
 [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy.
 
-The Client assembly mounts Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
+The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation.
 
 This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md).
 

+ 1 - 1
packages/api/remotes/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包只选择并挂载生成的会话 contribution,不复制激活策略。
 
-Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
+Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。
 
 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。
 

+ 2 - 1
packages/api/remotes/package.json

@@ -88,6 +88,7 @@
     "zod": "^4.4.3",
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
     "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
-    "@deepseek-ai/dsh-plugin-manager": "workspace:^"
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-office-to-pdf": "workspace:^"
   }
 }

+ 3 - 1
packages/api/remotes/src/client/index.ts

@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote'
 import commandsRemote from '@deepseek-ai/dsh-commands/remote'
 import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote'
+import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote'
 import goalsRemote from '@deepseek-ai/dsh-goal/remote'
 import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -33,6 +34,7 @@ export type {} from '@deepseek-ai/dsh-agent-presets/remote'
 export type {} from '@deepseek-ai/dsh-commands/remote'
 export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
 export type {} from '@deepseek-ai/dsh-goal/remote'
+export type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
 export type {} from '@deepseek-ai/dsh-llm/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
@@ -166,7 +168,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
       pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
-      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
+      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, officeToPdfRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
     }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -91,6 +91,9 @@
     },
     {
       "path": "../../boot/plugin-manager"
+    },
+    {
+      "path": "../../document/office-to-pdf"
     }
   ]
 }

+ 2 - 2
packages/bundle/web-app/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
-README.md: f733218e857eaf42a8fb2779646b941c5cc1bd4f
-README.zh.md: 5c058067c6f849486229e9e400109565eccc3aea
+README.md: d74489dd294bbf0f8c09df02ffc42984517267cd
+README.zh.md: 9d80190bcf332e1271f1f00bceee34c72416cce4

+ 1 - 1
packages/bundle/web-app/README.md

@@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle.
+The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
 
 ### Patch semantics
 

+ 1 - 1
packages/bundle/web-app/README.zh.md

@@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
 <details>
 <summary>实现细节——点击展开</summary>
 
-此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。
+此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
 
 ### patch 语义
 

+ 1 - 1
packages/bundle/web-app/cordis.patch.yml

@@ -229,7 +229,7 @@
       name: '@deepseek-ai/dsh-office-to-pdf'
 
     # The right Sidebar's document tab: bounded file reads with selectable
-    # Markdown, code, HTML, PDF, and plain-text renderers.
+    # Markdown, code, HTML, PDF, Office, and plain-text renderers.
     - id: ui-sidebar-documentpreview
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 

+ 9 - 1
packages/bundle/web-app/package.json

@@ -133,8 +133,16 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-fs-local": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-shell-env": "workspace:^",
-    "@deepseek-ai/dsh-system-prompt": "workspace:^"
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-typert-registry": "workspace:^",
+    "@deepseek-ai/libreoffice-kit": "0.0.1"
   }
 }

+ 1 - 1
packages/bundle/web-app/tests/document-conversion.e2e.ts

@@ -41,7 +41,7 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal
   await ctx.loader.await()
   const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')!
   await entry.fiber!.await()
-  expect(ctx.get('documentRenderController')).toBeUndefined()
+  expect(ctx.get('workspaceFiles')).toBeUndefined()
   expect(ctx.get('skills')).toBeUndefined()
   const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url))
   let reads = 0

+ 128 - 0
packages/bundle/web-app/tests/document-preview.spec.ts

@@ -0,0 +1,128 @@
+/** The Web bundle's Office rows retain independent configuration and Session file authorization. */
+import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include'
+import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files'
+import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf'
+import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit'
+import { expect, it, onTestFinished, vi } from 'vitest'
+
+const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise<Converter>>() }))
+vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create }))
+
+it('loads the shipped Office rows with separately patched settings and authorized PDF output', async () => {
+  const directory = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-office-')))
+  const ctx = new Context()
+  onTestFinished(async () => {
+    try { await ctx.fiber.dispose() }
+    finally { vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) }
+  })
+  const configPath = join(directory, 'cordis.yml')
+  const expectedRows = {
+    'office-to-pdf': '@deepseek-ai/dsh-office-to-pdf',
+    'ui-sidebar-documentpreview': '@deepseek-ai/dsh-client-ui-sidebar-documentpreview',
+  }
+  const rows = loadOverlayPatches('web-office-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
+    .flatMap(patch => patch.insert ?? []).filter(row => row.id !== undefined && Object.hasOwn(expectedRows, row.id))
+  expect(rows.map(row => [row.id, row.name])).toEqual(Object.entries(expectedRows))
+  const providerConfig = { maxInputBytes: 4096, maxConcurrentConversions: 1, fontFallbacks: [['Missing Serif', 'Available Serif']] }
+  const clientConfig = DocumentPreview.Config({ office: { maxCachedEntries: 3, maxCachedBytes: 8192 } })
+  const configured = applyEntryPatches(rows, [
+    { id: 'office-to-pdf', config: providerConfig },
+    { id: 'ui-sidebar-documentpreview', config: clientConfig },
+  ], (message) => { throw new Error(message) })
+  expect(configured.find(row => row.id === 'ui-sidebar-documentpreview')!.config).toEqual(clientConfig)
+  await writeFile(configPath, JSON.stringify([
+    { name: '@deepseek-ai/dsh-session' },
+    { name: '@deepseek-ai/dsh-session-projection' },
+    { name: '@deepseek-ai/dsh-sandbox-policy', config: { workspaceRoot: directory } },
+    { name: '@deepseek-ai/dsh-fs-local', config: { cwd: directory } },
+    { name: '@deepseek-ai/dsh-typert-registry' },
+    { name: '@deepseek-ai/dsh-api-workspace-files', config: { maxFileBytes: 1 } },
+    ...configured,
+  ]))
+  const pdf = Buffer.from('%PDF-1.7\nLoader preview\n%%EOF\n')
+  const render = vi.fn<Converter['render']>().mockImplementation(async ({ inputPath, outputPath }) => {
+    expect(await readFile(inputPath)).toEqual(Buffer.from('authorized OOXML'))
+    await writeFile(outputPath, pdf)
+    return { backend: 'native', missingFonts: ['Missing Serif'] }
+  })
+  kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} })
+  ctx.baseUrl = pathToFileURL(directory).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  // Loader's native imports must share the test's source-plane Service classes.
+  const modules = new Map<string, unknown>([
+    ['@deepseek-ai/dsh-session', SessionStore],
+    ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
+    ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
+    ['@deepseek-ai/dsh-fs-local', LocalFileSystem],
+    ['@deepseek-ai/dsh-typert-registry', TypertRegistry],
+    ['@deepseek-ai/dsh-api-workspace-files', WorkspaceFiles],
+    ['@deepseek-ai/dsh-office-to-pdf', OfficeToPdf],
+    ['@deepseek-ai/dsh-client-ui-sidebar-documentpreview', DocumentPreview],
+  ])
+  ctx.loader.internal = {
+    version: 'v2',
+    async import(specifier: string) {
+      if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`)
+      return modules.get(specifier)
+    },
+  } as unknown as NonNullable<typeof ctx.loader.internal>
+  await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
+  await ctx.loader.await()
+  const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry]))
+  for (const row of configured) await entries.get(row.id)!.fiber!.await()
+  const injections: IndexInjection[] = []
+  ctx.emit('webserver/index-inject', injections)
+  expect(injections).toEqual([
+    { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig },
+  ])
+
+  const id = SessionId('office-loader')
+  const session = ctx.sessions.create(id, { meta: { cwd: directory } })
+  const lookup = ctx.typert.lookups.get('workspaceFileScope')!
+  const scope = await lookup.resolve(id) as WorkspaceFileScope | undefined
+  if (scope === undefined) throw new Error('Expected the Session workspace scope')
+  expect(await lookup.resolve(SessionId('missing'))).toBeUndefined()
+  const sourcePath = join(directory, 'report.docx')
+  await writeFile(sourcePath, 'authorized OOXML')
+  const signal = new AbortController().signal
+  const version = (await ctx.workspaceFiles.stat(scope, 'report.docx', signal)).version
+  const before = session.seq
+  const result = await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)
+  expect(result).toEqual({ absolutePath: sourcePath, version, offset: 0, eof: true, bytes: pdf.length,
+    data: pdf.toString('base64'), missingFonts: ['Missing Serif'], generation: ctx.officeToPdf.generation })
+  const readAgain = vi.spyOn(ctx.fs, 'readBytes')
+  expect(await ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).toEqual(result)
+  expect(readAgain).not.toHaveBeenCalled()
+  expect(await readFile(sourcePath, 'utf8')).toBe('authorized OOXML')
+  expect(session.seq).toBe(before)
+  expect(ctx.get('agents')).toBeUndefined()
+  const { maxConcurrentConversions: _count, ...kitOptions } = providerConfig
+  expect(kit.create).toHaveBeenCalledWith(expect.objectContaining(kitOptions))
+  await expect(ctx.officeToPdf.render(scope, 'missing.docx', 'foreground', signal))
+    .rejects.toMatchObject({ code: 'workspace-file/not-found' })
+  const refusal = new FsError('read refused', 'FS_SANDBOX_DENIED')
+  vi.spyOn(ctx.fs, 'stat').mockRejectedValueOnce(refusal)
+  await expect(ctx.officeToPdf.render(scope, 'report.docx', 'foreground', signal)).rejects.toBe(refusal)
+  if (process.platform !== 'win32') {
+    await symlink(sourcePath, join(directory, 'link.docx'))
+    await expect(ctx.officeToPdf.render(scope, 'link.docx', 'foreground', signal))
+      .rejects.toMatchObject({ code: 'workspace-file/not-regular-file' })
+  }
+  expect(render).toHaveBeenCalledOnce()
+})

+ 1 - 1
packages/client/ui-chat/tsconfig.json

@@ -99,7 +99,7 @@
       "path": "../ui-workspace"
     },
     {
-      "path": "../ui-sidebar-documentpreview"
+      "path": "../ui-sidebar-documentpreview/tsconfig.client.json"
     },
     {
       "path": "../ui-input-trigger"

+ 2 - 2
packages/client/ui-sidebar-documentpreview/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
-README.md: d4e114aa6e12171d61f8a5284fdbe923916fe0d9
-README.zh.md: 32cb704d29fd0009fdf7a51235617c25029295f0
+README.md: 3b8b2b1d98e45caadb646a43fd04c74d600d0cd3
+README.zh.md: 94de2f75e5955e5bb293d398e65f615b16ece4e4

+ 38 - 6
packages/client/ui-sidebar-documentpreview/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback."
+description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF, Office and HTML renderers, and plain-text fallback."
 kind: "package-reference"
 ---
 
@@ -9,13 +9,14 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`.
+Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. Office documents convert locally to PDF. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`.
 
 ## Table of Contents
 
 - [What it registers](#what-it-registers)
 - [Addresses](#addresses)
 - [How it reads](#how-it-reads)
+- [Office preview](#office-preview)
 - [Navigation](#navigation)
 - [Model Experience](#model-experience)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
@@ -27,10 +28,12 @@ Preview readable files in the right Sidebar and choose among registered renderer
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
-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` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. 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; removing it selects the next candidate. Builtin bodies use these same registrations.
+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`, and the standard `useTabInfo`/`useResource` hooks. 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, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` reports the displayed source version for the shared change notice; 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,9 +54,37 @@ HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-s
 
 PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `<img>` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
 
-Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels.
+Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors.
+
+Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner 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="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.
+
+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.
+
+| Field | Default | Meaning |
+|---|---|---|
+| `office.maxCachedEntries` | `8` | Maximum retained completed PDFs |
+| `office.maxCachedBytes` | `67108864` (64 MiB) | Maximum retained binary PDF bytes, counted by buffer `byteLength` |
+| `office.maxPending` / `office.maxReaders` | `8` / `32` | Unsettled conversion RPCs / readers including metadata lookups |
+
+Opening an Office file requests conversion on demand. Each read checks renderer generation and authorized source metadata before sharing an in-flight conversion or reusing a successful PDF. The cache uses renderer generation, Session, absolute source path, and source version as its identity; least-recently used PDFs leave to keep retention within both limits. Failures and PDFs larger than the byte limit are not retained. A converted PDF is retained only when its returned renderer generation matches the cache identity. Cancelling one reader leaves shared conversion running until the final reader leaves. Connection resets clear cached bytes and cancel pending reads; plugin disposal also waits for outstanding requests. PDFs are never persisted; transport strings, decoding storage, and PDF.js rendering memory remain outside the cache limit.
 
-Initial reads, additional pages, and HTML/PDF/image preparation share an icon-only loading spinner that exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the spinner in the pane, so opening a file shows one spinner 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. 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.
+Background requests leave the final pending-request and reader slots available for foreground work; setting either limit to one rejects background reads. Renderer replacement restarts generation discovery and authorization once. Another replacement during that retry reports the localized busy message.
+
+A yellow notice identifies fonts unavailable during conversion. Show more opens an anchored list; closing the list preserves the notice. Dismissing the notice collapses its space and moves the PDF upward. Dismissal applies to the same source version while the preview remains mounted; switching away from the tab and back shows the notice again. Long notice text fades before the action, and reduced-motion preferences disable the collapse animation.
+
+<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 notice sits above the Office scrollport. 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 shared `documentFileBytes()` helper decodes ordinary file and converted 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.
+
+</details>
 
 <a id="navigation"></a>
 ## Navigation
@@ -73,6 +104,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).
 - **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.

+ 38 - 6
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。"
+description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF、Office 和 HTML 渲染器,并以纯文本兜底。"
 kind: "package-reference"
 ---
 
@@ -9,13 +9,14 @@ kind: "package-reference"
 
 ## 概述
 
-在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。
+在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。
 
 ## 目录
 
 - [注册了什么](#what-it-registers)
 - [地址](#addresses)
 - [怎么读](#how-it-reads)
+- [Office 预览](#office-preview)
 - [导航](#navigation)
 - [模型体验](#model-experience)
 - [已知限制与延期工作](#known-limitations-and-deferred-work)
@@ -27,10 +28,12 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
-- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
+- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
 
-文档实现在 `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` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
+文档实现在 `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` 和标准 `useTabInfo`/`useResource` 钩子。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
+
+`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 为共享变更提示报告已展示的源版本;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
 
 <a id="addresses"></a>
 ## 地址
@@ -51,9 +54,37 @@ HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbo
 
 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
 
-共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。
+共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。
+
+首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
+
+<a id="office-preview"></a>
+## Office 预览
+
+将 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换;无效文件、转换失败和超时会显示本地化消息。缺少 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 后重新加载浏览器页面。
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `office.maxCachedEntries` | `8` | 最多保留的已完成 PDF 数量 |
+| `office.maxCachedBytes` | `67108864`(64 MiB) | 最多保留的二进制 PDF 字节数,按缓冲区 `byteLength` 计算 |
+| `office.maxPending` / `office.maxReaders` | `8` / `32` | 未完成转换 RPC 数量 / 含元数据查询的读取方数量 |
+
+打开 Office 文件时按需请求转换。每次读取都先检查渲染 generation 和已授权的源文件元数据,再共享进行中的转换或复用成功的 PDF。缓存以渲染 generation、Session、源文件绝对路径和源版本作为身份;通过淘汰最久未使用的 PDF,使保留量符合两项限制。失败和超过字节限制的 PDF 不会被保留。转换后 PDF 的返回渲染 generation 与缓存身份一致时,才会保留该 PDF。取消一个读取方后,共享转换会继续运行,直到最后一个读取方离开。连接重置会清空缓存字节并取消待完成读取;插件卸载还会等待未结束的请求完成。PDF 从不持久化;传输字符串、解码存储和 PDF.js 渲染内存不计入缓存限制。
 
-首次读取、追加页及 HTML/PDF/图片准备共用仅图标的加载 spinner,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 spinner 居中在面板中,打开文件到正文出现始终是同一位置的一个 spinner。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
+后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。
+
+黄色提示条说明转换时不可用的字体。“显示更多”打开锚定字体列表;关闭列表保留提示条。关闭提示条会收起其占位并使 PDF 上移,关闭状态仅在预览持续挂载且源文件版本相同时保留;切换到其他标签再切回会重新显示提示条。过长提示文字在操作按钮前渐隐,减少动态效果偏好会禁用收起动画。
+
+<details>
+<summary>Office 实现——点击展开</summary>
+
+Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。字体提示位于 Office 滚动区上方。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。
+
+共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
+
+</details>
 
 <a id="navigation"></a>
 ## 导航
@@ -73,6 +104,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
 
 <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)负责。
 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。

+ 11 - 4
packages/client/ui-sidebar-documentpreview/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
-  "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text",
+  "description": "Extensible document previews for Sidebar files: Office, Markdown, highlighted code, images, PDF, HTML, and plain text",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -32,7 +32,8 @@
         "@deepseek-ai/dsh-api-workspace-files",
         "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-session",
-        "@deepseek-ai/dsh-api-remotes"
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-locale"
       ],
       "platform": "web"
     }
@@ -50,6 +51,7 @@
     "@deepseek-ai/dsh-api-gateway": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
@@ -60,6 +62,7 @@
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@testing-library/react": "^16.1.0",
@@ -68,12 +71,16 @@
     "clsx": "^2.0.0",
     "pdfjs-dist": "6.3.289",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-office-to-pdf": "workspace:^"
   },
   "files": [
     "lib/index.js",
     "lib/client.js",
     "lib/client.*.js",
     "lib/types/**/*.d.ts"
-  ]
+  ],
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  }
 }

+ 6 - 0
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css

@@ -107,6 +107,10 @@
   overflow: hidden;
 }
 
+.body:has([data-pdf-preview]) {
+  background: var(--dsw-alias-bg-document-preview);
+}
+
 .wrap {
   white-space: pre-wrap;
   word-break: break-word;
@@ -308,10 +312,12 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 .viewerTool {
+  flex: none;
   width: auto;
   max-width: 160px;
   padding: 0 6px;
   overflow: hidden;
+  color: var(--dsw-alias-label-secondary);
   font-size: 12px;
   white-space: nowrap;
   text-overflow: ellipsis;

+ 24 - 10
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -94,7 +94,7 @@ export type TextPreviewProps =
  */
 export function TextPreview({
   useTabInfo, useResource, useStore, actions, loadPage, reloadPages,
-  loadAll, reloadAll, useDocumentPreviews, renderSlot, t,
+  loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t,
 }: TextPreviewProps): ReactNode {
   const { tab } = useTabInfo()
   const { navigation, signal } = tab
@@ -113,7 +113,8 @@ export function TextPreview({
   }, [definitions, file.path, unviewable])
   const selected = candidates.find(candidate => candidate.id === state?.rendererId) ?? candidates[0]
   const mode = selected?.loading
-  const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
+  const contentRendererId = mode === 'renderer' ? selected?.id : undefined
+  const current = (state?.mode ?? 'text-pages') === mode && state?.contentRendererId === contentRendererId ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
   const scrollportRef = useRef<HTMLElement | null>(null)
   const storedScrollTopRef = useRef(0)
@@ -128,7 +129,7 @@ export function TextPreview({
   const pages = current?.pages
   const loaded = useMemo(() => loadedPages(pages ?? {}), [pages])
   const loadedThrough = lastLineLoaded(loaded)
-  const hasContent = loaded.length > 0 || current?.complete !== undefined
+  const hasContent = mode === 'renderer' ? current?.version !== undefined : loaded.length > 0 || current?.complete !== undefined
   storedScrollTopRef.current = state?.scrollTop ?? 0
   const bindBody = useCallback((body: HTMLDivElement | null): void => {
     const previous = bodyRef.current
@@ -145,10 +146,11 @@ export function TextPreview({
   // reads nothing, because the store outlives the body.
   const started = current !== undefined
   useEffect(() => {
-    if (started || !canRead || mode === undefined) return
+    if (started || !canRead || mode === undefined || selected === undefined) return
     if (mode === 'text-pages') loadPage(tab.id, file, 1, signal, meta.value?.version)
-    else loadAll(tab.id, file, signal, meta.value?.version)
-  }, [started, tab.id, file, signal, loadPage, loadAll, canRead, mode, meta.value?.version])
+    else if (mode === 'bytes-complete') loadAll(tab.id, file, signal, meta.value?.version)
+    else prepareRenderer(tab.id, signal, selected.id, meta.value?.version)
+  }, [started, tab.id, file, signal, loadPage, loadAll, prepareRenderer, canRead, mode, selected, meta.value?.version])
 
   // Come back where the reader was once there is content to scroll: on a remount,
   // after a reload rebuilt the content, or after the selected renderer changed.
@@ -186,13 +188,24 @@ export function TextPreview({
     selected?.id, mode, file, canRead, meta.value?.version,
   ])
 
+  const rendererReload = useCallback((): void => {
+    if (canRead && selected !== undefined) prepareRenderer(tab.id, signal, selected.id, meta.value?.version, true)
+  }, [canRead, prepareRenderer, tab.id, signal, selected?.id, meta.value?.version])
   const content = useMemo((): DocumentContent | undefined => {
+    if (mode === 'renderer') {
+      if (current === undefined) return undefined
+      const revision = current.loadRevision
+      return { kind: 'renderer', revision, reload: rendererReload,
+        loaded: (version) => { actions.rendered(tab.id, revision, version) } }
+    }
     if (mode === 'bytes-complete') {
-      return current?.complete === undefined ? undefined : { kind: 'bytes', data: current.complete.data }
+      return current?.complete === undefined ? undefined : {
+        kind: 'bytes', data: current.complete.data,
+      }
     }
     if (current === undefined || loaded.length === 0) return undefined
     return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof }
-  }, [mode, loaded, current?.complete, current?.eof])
+  }, [mode, loaded, current?.complete, current?.eof, current?.loadRevision, rendererReload, actions, tab.id])
 
   // A known binary suffix with no matching renderer never reads: no plain-text
   // fallback, no viewer control, only the path and the unsupported line.
@@ -233,7 +246,8 @@ export function TextPreview({
   const reload = (): void => {
     if (!canRead) return
     if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version)
-    else reloadAll(tab.id, file, signal, meta.value?.version)
+    else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, meta.value?.version)
+    else rendererReload()
   }
   return (
     <div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
@@ -331,7 +345,7 @@ export function TextPreview({
             && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
         }}
       >
-        {!hasContent && current?.failure === undefined && (
+        {mode !== 'renderer' && !hasContent && current?.failure === undefined && (
           <LoadingIndicator className={clsx(css.statusLine, css.bodyLoading)} label={t('loading')} />
         )}
         {content !== undefined && renderSlot('sidebar.right.tab.document', {

+ 23 - 11
packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts

@@ -11,12 +11,33 @@ export interface DocumentTextPage {
 }
 
 /**
- * Contents prepared by the preview owner using ordinary file reads.
+ * Ordinary file contents, or a request for the selected renderer to load its content.
  * Byte arrays are transient UI input, never persisted layout or Session data.
  */
 export type DocumentContent =
   | { readonly kind: 'text'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }
   | { readonly kind: 'bytes'; readonly data: Uint8Array<ArrayBuffer> }
+  | {
+    readonly kind: 'renderer'
+    /** Changes on reload or implementation replacement; retained contents belong to one revision. */
+    readonly revision: number
+    /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */
+    readonly loaded: (version: string) => void
+    /** Cancel the current load and start a new revision. */
+    readonly reload: () => void
+  }
+
+/** Content and viewing inputs shared by document bodies and nested PDF presentation. */
+export interface DocumentBodyOwner {
+  /** Original file address, also readable through the standard useResource hook. */
+  readonly resourceAddress: string
+  /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */
+  readonly content: DocumentContent
+  /** The document toolbar's current wrapping preference. */
+  readonly wrap: boolean
+  /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
+  readonly scrollportRef: RefCallback<HTMLElement>
+}
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
@@ -24,16 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     'sidebar.right.tab.document': {
       kind: 'keyed'
       scope: 'session'
-      owner: {
-        /** Original file address, also readable through the standard useResource hook. */
-        readonly resourceAddress: string
-        /** Loaded content; text is an accumulated prefix until eof. */
-        readonly content: DocumentContent
-        /** The document toolbar's current wrapping preference. */
-        readonly wrap: boolean
-        /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */
-        readonly scrollportRef: RefCallback<HTMLElement>
-      }
+      owner: DocumentBodyOwner
       hookContext: UseSidebarRightTabInfo
       inject: {
         hooks: {

+ 2 - 2
packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts

@@ -2,8 +2,8 @@
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
 import { documentFileName, matchedSuffixLength, normalizeSuffix } from './suffix.ts'
 
-/** How the document owner delivers file contents to a renderer. */
-export type DocumentLoadMode = 'text-pages' | 'bytes-complete'
+/** Shared text or byte reads, or content loading owned by the renderer. */
+export type DocumentLoadMode = 'text-pages' | 'bytes-complete' | 'renderer'
 
 /** One renderer implementation, independent of its component registration. */
 export interface DocumentPreviewDefinition {

+ 24 - 0
packages/client/ui-sidebar-documentpreview/src/client/document/tab-lifetime.ts

@@ -0,0 +1,24 @@
+/** Document view state belongs to tab records, including while their bodies are hidden. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+/**
+ * Release retained view state on tab closure or plugin disposal.
+ * @param ctx - owning preview plugin context.
+ * @returns a callback accepting the tab, its lifetime signal, and its store's forget action; repeated holds share one listener.
+ */
+export function retainDocumentTabs(ctx: Context): (tabId: TabId, signal: AbortSignal, forget: (tabId: TabId) => void) => void {
+  const retained = new Map<AbortSignal, () => void>()
+  ctx.effect(() => () => { for (const forget of retained.values()) forget() })
+  return (tabId, signal, forgetTab) => {
+    if (signal.aborted) { forgetTab(tabId); return }
+    if (retained.has(signal)) return
+    const forget = (): void => {
+      signal.removeEventListener('abort', forget)
+      retained.delete(signal)
+      forgetTab(tabId)
+    }
+    retained.set(signal, forget)
+    signal.addEventListener('abort', forget, { once: true })
+  }
+}

+ 46 - 19
packages/client/ui-sidebar-documentpreview/src/client/face.ts

@@ -20,7 +20,6 @@ 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 { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts'
-import { documentFileBytes } from './rpc.ts'
 import type { TextStore } from './store.ts'
 import type { DocumentLoadMode } from './document/registry.ts'
 
@@ -55,7 +54,9 @@ export interface TextInjected {
    * @param signal - tab lifetime.
    * @param observedVersion - metadata version observed at read start.
    */
-  readonly loadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void
+  readonly loadAll: (
+    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
+  ) => void
   /**
    * Discard the old complete result and read again.
    * @param tabId - owning tab.
@@ -63,7 +64,18 @@ export interface TextInjected {
    * @param signal - tab lifetime.
    * @param observedVersion - metadata version observed at read start.
    */
-  readonly reloadAll: (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string) => void
+  readonly reloadAll: (
+    tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
+  ) => void
+  /**
+   * Begin a renderer-owned load without reading source bytes.
+   * @param tabId - owning tab.
+   * @param signal - tab lifetime.
+   * @param rendererId - selected implementation.
+   * @param observedVersion - metadata version observed at request start.
+   * @param reload - discard the previous content revision.
+   */
+  readonly prepareRenderer: (tabId: TabId, signal: AbortSignal, rendererId: string, observedVersion?: string, reload?: boolean) => void
 }
 
 /**
@@ -75,6 +87,8 @@ interface TabReads {
   generation: number
   version: string | undefined
   mode: DocumentLoadMode
+  rendererId?: string
+  controller?: AbortController
 }
 
 /**
@@ -97,14 +111,18 @@ export function textFace(
       const created: TabReads = { generation: 0, version: undefined, mode: 'text-pages' }
       tabs.set(tabId, created)
       signal.addEventListener('abort', () => {
+        created.controller?.abort()
         tabs.delete(tabId)
         actions.forget(tabId)
       }, { once: true })
       return created
     }
-    const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode): TabReads => {
+    const modeOf = (tabId: TabId, signal: AbortSignal, mode: DocumentLoadMode, rendererId?: string): TabReads => {
       const reads = readsOf(tabId, signal)
-      if (reads.mode !== mode) {
+      if (reads.mode !== mode || reads.rendererId !== rendererId) {
+        reads.controller?.abort()
+        if (rendererId === undefined) delete reads.rendererId
+        else reads.rendererId = rendererId
         reads.mode = mode
         reads.generation++
         reads.version = undefined
@@ -133,29 +151,31 @@ export function textFace(
         actions.page(tabId, result.value)
       })
     }
-    const loadAll = (tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string): void => {
+    const loadAll = (
+      tabId: TabId, file: SessionFile, signal: AbortSignal, observedVersion?: string,
+    ): void => {
       if (signal.aborted) return
       const reads = modeOf(tabId, signal, 'bytes-complete')
-      const { generation } = reads
+      reads.controller?.abort()
+      const controller = new AbortController()
+      reads.controller = controller
+      const lifetime = AbortSignal.any([signal, controller.signal])
       actions.loading(tabId, 'bytes-complete', observedVersion)
-      void readAll(file, signal).then((result) => {
-        if (signal.aborted || reads.generation !== generation) return
+      void readAll(file, lifetime).then((result) => {
+        if (lifetime.aborted) return
         if (!result.ok) {
           actions.failed(tabId, result.error)
           return
         }
-        let file
-        try {
-          file = documentFileBytes(result.value)
-        } catch (error) {
-          actions.failed(tabId, Object.assign(
-            new Error('document file byte response has malformed base64 data', { cause: error }),
-            { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} },
-          ))
-          return
-        }
+        const file = result.value
         reads.version = file.version
         actions.complete(tabId, file)
+      }, (error: unknown) => {
+        if (lifetime.aborted) return
+        actions.failed(tabId, Object.assign(
+          new Error(error instanceof Error ? error.message : String(error), { cause: error }),
+          { name: 'RemoteError', isDSHRemoteError: true as const, code: 'gateway/internal' as const, details: {} },
+        ))
       })
     }
     const restart = (
@@ -163,6 +183,7 @@ export function textFace(
     ): void => {
       if (signal.aborted) return
       const reads = readsOf(tabId, signal)
+      reads.controller?.abort()
       reads.generation += 1
       reads.version = undefined
       actions.reset(tabId)
@@ -171,6 +192,12 @@ export function textFace(
     }
     return {
       loadPage, reloadPages: restart, loadAll,
+      prepareRenderer: (tabId, signal, rendererId, observedVersion, reload = false) => {
+        if (signal.aborted) return
+        modeOf(tabId, signal, 'renderer', rendererId)
+        if (reload) actions.reset(tabId)
+        actions.loading(tabId, 'renderer', observedVersion, rendererId)
+      },
       reloadAll: (tabId, file, signal, observedVersion) => { restart(tabId, file, signal, observedVersion, 'bytes-complete') },
     }
   }

+ 13 - 4
packages/client/ui-sidebar-documentpreview/src/client/index.ts

@@ -25,7 +25,7 @@ import type { TextPreviewInjected } from './TextPreview.tsx'
 import { TextTitle } from './TextTitle.tsx'
 import { TEXTPREVIEW_ID, textDefinition } from './definition.ts'
 import { textFace } from './face.ts'
-import { createReadPage } from './rpc.ts'
+import { createReadPage, documentFileBytes } from './rpc.ts'
 import { createTextStore } from './store.ts'
 import { en, zh } from './locales.ts'
 import { DocumentPreviewRegistry } from './document/registry.ts'
@@ -36,6 +36,8 @@ import { apply as registerHtml } from './html/index.ts'
 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 { Config } from '../config.ts'
 
 // Values stay package-private unless another package needs them; the plugin
 // surface is `apply`, `inject`, and the store factory another registration may
@@ -43,7 +45,7 @@ import { apply as registerCode } from './code/index.ts'
 export type { SidebarDocumentPreviewKey } from './locales.ts'
 export type { TextPreviewProps } from './TextPreview.tsx'
 export type { TextInjected } from './face.ts'
-export type { ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts'
+export type { ReadDocumentBytes, DocumentFileBytes, ReadWorkspaceFilePage, SessionFile, WorkspaceFilesReadRemote } from './rpc.ts'
 export type { TextPage, TextState, TextStore, TextTabState } from './store.ts'
 export type { DocumentContent, DocumentPreviewProps, DocumentTextPage } from './document/contract.ts'
 export type { DocumentLoadMode, DocumentPreviewDefinition } from './document/registry.ts'
@@ -83,6 +85,7 @@ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.
  * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face.
  */
 export function apply(ctx: ClientContext): void {
+  const config = Config((globalThis as { __DSH_DOCUMENT_PREVIEW_CONFIG__?: unknown }).__DSH_DOCUMENT_PREVIEW_CONFIG__ ?? {})
   const previews = new DocumentPreviewRegistry()
   const disposePreviews = ctx.reflect.provide('documentPreviews', previews)
   ctx.effect(() => disposePreviews)
@@ -92,7 +95,10 @@ export function apply(ctx: ClientContext): void {
   const store = createTextStore()
   const face = textFace(
     createReadPage(ctx.remote),
-    (file, signal) => ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal),
+    async (file, signal) => {
+      const result = await ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal)
+      return result.ok ? { ok: true, value: documentFileBytes(result.value) } : result
+    },
   )
   const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe }
   ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
@@ -101,7 +107,9 @@ export function apply(ctx: ClientContext): void {
       children: {
         'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } },
       },
-      inject: (sessionId, actions): TextPreviewInjected => ({ ...face(sessionId, actions), hooks: { documentPreviews: source } }),
+      inject: (sessionId, actions): TextPreviewInjected => ({
+        ...face(sessionId, actions), hooks: { documentPreviews: source },
+      }),
     },
     TextPreview,
   )), 'ui-sidebar-documentpreview: text body')
@@ -115,4 +123,5 @@ export function apply(ctx: ClientContext): void {
   registerImage(ctx)
   registerPdf(ctx)
   registerCode(ctx)
+  registerOffice(ctx, config.office)
 }

+ 139 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css

@@ -0,0 +1,139 @@
+.space {
+  display: grid;
+  flex: none;
+  grid-template-rows: 1fr;
+  background: var(--dsw-alias-bg-document-preview);
+  transition: grid-template-rows 180ms ease, opacity 180ms ease;
+}
+
+.space[data-dismissed='true'] {
+  grid-template-rows: 0fr;
+  opacity: 0;
+  pointer-events: none;
+}
+
+.clip {
+  min-height: 0;
+  overflow: hidden;
+}
+
+.notice {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+  height: 40px;
+  margin: 12px 12px 0;
+  padding: 0 8px 0 12px;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-state-warn-tertiary);
+  border: 0.5px solid var(--dsw-alias-state-warn-secondary);
+  border-radius: 10px;
+  box-shadow: var(--dsw-elevation-panel);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.warning, .anchor {
+  flex: none;
+}
+
+.warning {
+  color: var(--dsw-alias-state-warn-label);
+}
+
+.message {
+  position: relative;
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  white-space: nowrap;
+}
+
+.message > span {
+  display: block;
+  overflow: hidden;
+}
+
+.message[data-clipped='true'] > span {
+  mask-image: linear-gradient(to right, black calc(100% - 44px), transparent calc(100% - 12px));
+}
+
+.message[data-clipped='true']::after {
+  position: absolute;
+  right: 0;
+  top: 0;
+  content: '...';
+}
+
+.notice .more, .notice .close {
+  flex: none;
+  padding: 0 4px;
+  color: inherit;
+  background: transparent;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.notice .more:hover, .notice .close:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.panel {
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+  position: fixed;
+  z-index: 100;
+  box-sizing: border-box;
+  width: min(340px, calc(100vw - 24px));
+  max-height: calc(100dvh - 24px);
+  padding: 16px;
+  overflow: auto;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-2);
+  border: 0;
+  border-radius: 12px;
+  box-shadow: var(--dsw-elevation-prominent);
+  font-size: 13px;
+  line-height: 1.6;
+}
+
+.panelHeader {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.panelHeader h3 {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.description {
+  margin: 8px 0 16px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.count {
+  margin: 0 0 6px;
+  font-size: 12px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.fonts {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  overflow-wrap: anywhere;
+}
+
+.fonts li {
+  padding: 8px 0;
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .space { transition: none; }
+}

+ 89 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.tsx

@@ -0,0 +1,89 @@
+/** Missing-font notice and a non-modal details panel for one source version. */
+import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { Button, IconCloseOutline16, IconWarningOutline16, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './FontNotice.module.css'
+
+/** Notice inputs supplied by the document owner and Office locale registration. */
+export type FontNoticeProps = PropsLocale<'sidebarOffice'> & {
+  readonly resourceAddress: string
+  readonly sourceVersion: string
+  readonly fonts: readonly string[]
+}
+
+/**
+ * Show missing fonts; dismissal applies to the same source version while this component stays mounted.
+ * @param props - source identity, converted content, and localized copy.
+ * @returns a collapsible notice and its anchored details, or nothing when fonts are available.
+ */
+export function FontNotice({ resourceAddress, sourceVersion, fonts, t }: FontNoticeProps): ReactNode {
+  const identity = JSON.stringify([resourceAddress, sourceVersion])
+  const [dismissed, setDismissed] = useState<string>()
+  const [expanded, setExpanded] = useState<string>()
+  const visible = fonts.length > 0 && dismissed !== identity
+  const open = visible && expanded === identity
+  const root = useRef<HTMLDivElement>(null)
+  const anchor = useRef<HTMLSpanElement>(null)
+  const panel = useRef<HTMLDivElement>(null)
+  const message = useRef<HTMLSpanElement>(null)
+  const id = useId()
+  const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 8, margin: 12 })
+  useDismissOnOutsidePointer(root, open, () => { setExpanded(undefined) }, panel)
+  const closeDetails = (): void => {
+    setExpanded(undefined)
+    anchor.current?.querySelector('button')?.focus()
+  }
+  const positioned = position !== null
+  useLayoutEffect(() => {
+    if (!open || !positioned) return
+    panel.current?.focus()
+  }, [open, positioned])
+  useLayoutEffect(() => {
+    const element = message.current
+    if (element === null) return
+    const label = element.firstElementChild as HTMLSpanElement
+    const measure = (): void => { element.dataset.clipped = String(label.scrollWidth > element.clientWidth) }
+    measure()
+    const observer = new ResizeObserver(measure)
+    observer.observe(element)
+    return () => { observer.disconnect() }
+  }, [fonts, t])
+  if (fonts.length === 0) return null
+  return <>
+    <div className={css.space} data-office-font-notice data-dismissed={!visible} aria-hidden={!visible} {...(!visible ? { inert: '' } : {})}>
+      <div className={css.clip}>
+        <div ref={root} className={css.notice}>
+          <IconWarningOutline16 className={css.warning} />
+          <span ref={message} className={css.message} role="status">
+            <span>{t('missingFonts', { fonts: fonts.join(', ') })}</span>
+          </span>
+          <span ref={anchor} className={css.anchor}>
+            <Button size="sm" className={css.more} aria-expanded={open} aria-controls={open ? id : undefined}
+              aria-haspopup="dialog" onClick={() => { setExpanded(open ? undefined : identity) }}>
+              {t('showMore')}
+            </Button>
+          </span>
+          <Button size="sm" className={css.close} aria-label={t('dismissNotice')}
+            onClick={() => { setDismissed(identity); setExpanded(undefined) }} icon={<IconCloseOutline16 />} />
+        </div>
+      </div>
+    </div>
+    {open && createPortal(<div ref={panel} id={id} role="dialog" aria-labelledby={`${id}-title`}
+      aria-describedby={`${id}-description`} tabIndex={-1} className={css.panel}
+      style={{ ...position, visibility: position === null ? 'hidden' : undefined }}
+      onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeDetails() } }}
+      onBlur={(event) => {
+        if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)
+          && !anchor.current?.contains(event.relatedTarget)) setExpanded(undefined)
+      }}>
+      <div className={css.panelHeader}>
+        <h3 id={`${id}-title`}>{t('missingFontsTitle')}</h3>
+        <Button size="sm" aria-label={t('closeDetails')} onClick={closeDetails} icon={<IconCloseOutline16 />} />
+      </div>
+      <p id={`${id}-description`} className={css.description}>{t('missingFontsDescription')}</p>
+      <p className={css.count}>{t('missingFontsCount', { count: fonts.length })}</p>
+      <ul className={css.fonts}>{fonts.map(font => <li key={font}>{font}</li>)}</ul>
+    </div>, document.body)}
+  </>
+}

+ 12 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css

@@ -0,0 +1,12 @@
+.body {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
+.scrollport {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+}

+ 94 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx

@@ -0,0 +1,94 @@
+/** Office owns source loading, conversion failures, 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'
+import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+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 { OfficeStore } from './store.ts'
+import { FontNotice } from './FontNotice.tsx'
+import common from '../TextPreview.module.css'
+import css from './OfficeBody.module.css'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /** PDF presentation supplied with Office-owned converted bytes. */
+    'sidebar.right.tab.document.office.pdf': {
+      kind: 'keyed'
+      scope: 'session'
+      owner: DocumentBodyOwner
+      hookContext: UseSidebarRightTabInfo
+      inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> } }
+    }
+  }
+}
+
+/** 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
+  /** @param tab - owning tab. @param signal - tab lifetime, including hidden bodies. */
+  readonly retainTab: (tab: TabId, signal: AbortSignal) => void
+}
+
+/** Office body inputs and its private PDF child. */
+export type OfficeBodyProps = DocumentPreviewProps & PropsStore<OfficeStore> & OfficeBodyInjected
+  & PropsLocale<'sidebarOffice'> & PropsRenderSlots<'sidebar.right.tab.document.office.pdf'>
+
+/**
+ * Load one Office revision and preserve its result while its tab remains open.
+ * @param props - renderer loading request, tab state, conversion callbacks, and PDF slot.
+ * @returns conversion status or the font notice and PDF scrollport.
+ */
+export function OfficeBody(props: OfficeBodyProps): ReactNode {
+  const { tab } = props.useTabInfo()
+  const { actions, read, retainTab, describeFailure, 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])
+  useEffect(() => {
+    if (revision === 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) }),
+      })
+    })
+    return () => { controller.abort() }
+  }, [revision, resourceAddress, tab.id, tab.signal, read, actions, describeFailure, settled])
+  const file = view?.file
+  useEffect(() => { if (file !== undefined) request?.loaded(file.version) }, [file, request?.loaded])
+  if (request === undefined) return null
+  if (view?.failure !== undefined) {
+    const { name } = pathPartsOf(resourceAddress)
+    return <div className={common.empty} data-textpreview-failed={view.failure.code}>
+      <FileTypeIcon kind={classifyFileType(name)} size={36} />
+      <p className={common.emptyLine}>{view.failure.message}</p>
+      <Button size="sm" onClick={request.reload}>{t('retry')}</Button>
+    </div>
+  }
+  if (file === undefined) return <LoadingIndicator className={common.statusLine} label={t('loading')} />
+  return <div className={css.body}>
+    <FontNotice resourceAddress={resourceAddress} sourceVersion={file.version} fonts={file.missingFonts} t={t} />
+    <div className={css.scrollport} ref={props.scrollportRef}>
+      {props.renderSlot('sidebar.right.tab.document.office.pdf', {
+        resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
+      }, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
+    </div>
+  </div>
+}

+ 193 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts

@@ -0,0 +1,193 @@
+/** Session-authorized, version-checked Office bytes shared by concurrent preview reads. */
+import type { OfficeToPdfPriority, OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
+import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
+import type { DocumentFileBytes, SessionFile } from '../rpc.ts'
+
+/** PDF bytes and conversion metadata owned by Office preview. */
+export type OfficeFileBytes = DocumentFileBytes & {
+  readonly missingFonts: readonly string[]
+  readonly generation: OfficeToPdfGeneration
+}
+
+/**
+ * Load authorized PDF contents for one Office source.
+ * @param file - Session and source path.
+ * @param signal - this reader's lifetime.
+ * @returns converted PDF bytes or a declared source-access failure.
+ */
+export type ReadOfficeDocument = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<OfficeFileBytes>>
+
+/** Authorized cached reads carry explicit scheduling intent to the Host. */
+export type ReadOfficeBytes = (file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority) => ReturnType<ReadOfficeDocument>
+
+type Result = Awaited<ReturnType<ReadOfficeDocument>>
+type Success = Extract<Result, { ok: true }>
+type Stat = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<WorkspaceFileStat>>
+interface Pending {
+  readonly controller: AbortController
+  readonly promise: Promise<Result>
+  users: number
+}
+
+/** Bounded successful results; each caller reauthorizes and checks source freshness before reuse. */
+export class OfficePreviewCache {
+  private readonly ready = new Map<string, Success>()
+  private readonly pending = new Map<string, Pending>()
+  private bytes = 0
+  private readers = 0
+  private generation: OfficeToPdfGeneration | undefined
+  private generationQuery = 0
+  private acceptedQuery = 0
+  private readonly superseded = new Error()
+  private readonly lifetime = new AbortController()
+  private readonly tasks = new Set<Promise<Result>>()
+  private readonly reads = new Set<Promise<Result>>()
+
+  /**
+   * @param stat - authorized source metadata lookup.
+   * @param convert - Host render Remote returning binary PDF bytes borrowed read-only by callers.
+   * @param maxEntries - maximum completed results retained.
+   * @param maxBytes - maximum retained PDF byteLength.
+   * @param maxPending - maximum unsettled Host conversion requests, including cancellation teardown.
+   * @param maxReaders - maximum readers, including metadata lookups.
+   * @param generation - current Host renderer generation, checked before cached reuse.
+   * @param busy - localized capacity failure.
+   */
+  constructor(private readonly stat: Stat, private readonly convert: ReadOfficeBytes,
+    private readonly maxEntries: number, private readonly maxBytes: number,
+    private readonly maxPending: number, private readonly maxReaders: number,
+    private readonly currentGeneration: (signal: AbortSignal) => Promise<RemoteResult<OfficeToPdfGeneration>>,
+    private readonly busy: () => Error) {}
+
+  /**
+   * Share a conversion without letting one caller cancel another caller's work.
+   * Renderer replacement retries authorization once; repeated replacement reports localized capacity failure.
+   * @param file - Session authorization scope and source path.
+   * @param signal - this caller's lifetime.
+   * @param priority - foreground preview or speculative read.
+   * @returns current PDF bytes borrowed read-only, or a declared source-read failure; cancellation rejects.
+   */
+  async read(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority = 'foreground'): Promise<Result> {
+    signal.throwIfAborted()
+    this.lifetime.signal.throwIfAborted()
+    const readerLimit = priority === 'background' ? this.maxReaders - 1 : this.maxReaders
+    if (this.readers >= readerLimit || (priority === 'background' && this.maxPending === 1)) throw this.busy()
+    this.readers++
+    const operation = this.lookup(file, signal, priority)
+    this.reads.add(operation)
+    try { return await operation } finally { this.readers--; this.reads.delete(operation) }
+  }
+
+  private async lookup(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise<Result> {
+    for (let attempt = 0; attempt < 2; attempt++) {
+      try { return await this.lookupGeneration(file, signal, priority) }
+      catch (error) { if (error !== this.superseded) throw error }
+    }
+    throw this.busy()
+  }
+
+  private async lookupGeneration(file: SessionFile, signal: AbortSignal, priority: OfficeToPdfPriority): Promise<Result> {
+    signal = AbortSignal.any([signal, this.lifetime.signal])
+    signal.throwIfAborted()
+    const query = ++this.generationQuery
+    const generation = await this.currentGeneration(signal)
+    signal.throwIfAborted()
+    if (!generation.ok) return generation
+    if (query < this.acceptedQuery && generation.value !== this.generation) throw this.superseded
+    this.acceptedQuery = Math.max(query, this.acceptedQuery)
+    if (generation.value !== this.generation) {
+      this.generation = generation.value
+      this.ready.clear()
+      this.bytes = 0
+      for (const pending of this.pending.values()) pending.controller.abort(this.superseded)
+      this.pending.clear()
+    }
+    const metadata = await this.stat(file, signal)
+    signal.throwIfAborted()
+    if (!metadata.ok) return metadata
+    if (generation.value !== this.generation) throw this.superseded
+    const key = JSON.stringify([generation.value, file.sessionId, metadata.value.absolutePath, metadata.value.version])
+    const cached = this.ready.get(key)
+    if (cached !== undefined) {
+      this.ready.delete(key)
+      this.ready.set(key, cached)
+      return cached
+    }
+    // A foreground RPC joins and promotes background work at the shared Host queue.
+    const pendingKey = JSON.stringify([key, priority])
+    let entry = this.pending.get(pendingKey)
+    if (entry === undefined) {
+      const pendingLimit = priority === 'background' ? this.maxPending - 1 : this.maxPending
+      if (this.tasks.size >= pendingLimit) throw this.busy()
+      const controller = new AbortController()
+      const promise = Promise.resolve().then(() => {
+        controller.signal.throwIfAborted()
+        return this.convert(file, controller.signal, priority)
+      }).then((result) => {
+        controller.signal.throwIfAborted()
+        if (generation.value === this.generation && result.ok && result.value.generation === generation.value
+          && result.value.version === metadata.value.version
+          && result.value.absolutePath === metadata.value.absolutePath) {
+          this.retain(key, result)
+        }
+        return result
+      }).finally(() => {
+        this.tasks.delete(promise)
+        if (this.pending.get(pendingKey)?.controller === controller) this.pending.delete(pendingKey)
+      })
+      this.tasks.add(promise)
+      entry = { controller, promise, users: 0 }
+      this.pending.set(pendingKey, entry)
+    }
+    const shared = entry
+    shared.users += 1
+    return new Promise<Result>((resolve, reject) => {
+      let settled = false
+      const finish = (): boolean => {
+        if (settled) return false
+        settled = true
+        signal.removeEventListener('abort', abort)
+        shared.users -= 1
+        if (shared.users === 0 && this.pending.get(pendingKey) === shared) {
+          this.pending.delete(pendingKey)
+          shared.controller.abort()
+        }
+        return true
+      }
+      const abort = (): void => {
+        const reason: unknown = signal.reason
+        finish()
+        reject(reason instanceof Error ? reason : new Error('Office preview cancelled', { cause: reason }))
+      }
+      signal.addEventListener('abort', abort, { once: true })
+      shared.promise.then(
+        (result) => { if (finish()) resolve(result) },
+        (error: unknown) => { if (finish()) reject(error instanceof Error ? error : new Error('Office preview failed', { cause: error })) },
+      )
+    })
+  }
+
+  /** Clear retained bytes, cancel outstanding conversions, and await their completion. */
+  async dispose(): Promise<void> {
+    this.lifetime.abort()
+    this.pending.clear()
+    this.ready.clear()
+    this.bytes = 0
+    await Promise.allSettled([...this.reads, ...this.tasks])
+  }
+
+  private retain(key: string, result: Success): void {
+    const previous = this.ready.get(key)
+    if (previous !== undefined) { this.ready.delete(key); this.bytes -= previous.value.data.byteLength }
+    const size = result.value.data.byteLength
+    if (size > this.maxBytes) return
+    while (this.ready.size >= this.maxEntries || this.bytes + size > this.maxBytes) {
+      const oldest = this.ready.entries().next().value as [string, Success]
+      this.ready.delete(oldest[0])
+      this.bytes -= oldest[1].value.data.byteLength
+    }
+    this.ready.set(key, result)
+    this.bytes += size
+  }
+}

+ 127 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/index.ts

@@ -0,0 +1,127 @@
+/** Office preview registration backed by authorized Host rendering and the existing PDF body. */
+import type { Context } from '@deepseek-ai/cordis'
+import { retainDocumentTabs } from '../document/tab-lifetime.ts'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-office-to-pdf/remote'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
+import type {} from '@deepseek-ai/dsh-client-connection/client'
+import { documentFileBytes } from '../rpc.ts'
+import { failureLine } from '../failure-line.ts'
+import { documentTabInfoFactory } from '../document/contract.ts'
+import { en, zh, type OfficePreviewKey } from './locales.ts'
+import { OfficePreviewCache, type ReadOfficeBytes, type ReadOfficeDocument } from './cache.ts'
+import { pdfBodyRegistration } from '../pdf/index.ts'
+import { LazyPdfBody } from '../pdf/LazyPdfBody.tsx'
+import { OfficeBody, type OfficeBodyInjected } from './OfficeBody.tsx'
+import { createOfficeStore } from './store.ts'
+import type { Config } from '../../config.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    sidebarOffice: OfficePreviewKey
+  }
+}
+
+/**
+ * Register Office previews with versioned PDF reuse and missing-font notices.
+ * @param ctx - Client renderer registry, localized copy, and optional Host Remotes.
+ * @param config - Resolved Office preview cache limits.
+ */
+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']
+  ctx.effect(() => ctx.locale.register('sidebarOffice', { zh, en }))
+  const t = ctx.locale.bind('sidebarOffice')
+  const unavailable: ReadOfficeDocument = (_file, signal) => {
+    signal.throwIfAborted()
+    return Promise.reject(new Error(t('unavailable')))
+  }
+  let read = unavailable
+  ctx.effect(() => ctx.documentPreviews.register({
+    id,
+    extensions, binaryExtensions: extensions, priority: 'builtin',
+    title: () => t('title'), loading: 'renderer', wrap: false,
+  }))
+  const store = createOfficeStore()
+  const retainTab = retainDocumentTabs(ctx)
+  const documentT = ctx.locale.bind('sidebarDocumentPreview')
+  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 }),
+      retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
+    }),
+  }, OfficeBody)))
+  const pdfPresentation = pdfBodyRegistration(ctx)
+  ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.office.pdf', () => ctx.slots.register({
+    name: 'sidebar.right.tab.document.office.pdf', key: id, locale: 'sidebarPdf', ...pdfPresentation,
+  }, LazyPdfBody)))
+  ctx.inject(['remote', 'remote.officeToPdf', 'remote.workspaceFiles'], (scope) => {
+    const convert: ReadOfficeBytes = async (file, signal, priority) => {
+      signal.throwIfAborted()
+      const result = await scope.remote.officeToPdf.render(file.sessionId, file.path, priority, signal)
+      signal.throwIfAborted()
+      if (!result.ok) {
+        if (result.error.code === 'document-render/failed') {
+          throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error })
+        }
+        return result
+      }
+      return { ok: true, value: { ...documentFileBytes(result.value),
+        missingFonts: result.value.missingFonts, generation: result.value.generation } }
+    }
+    const createCache = () => new OfficePreviewCache(
+      async (file, signal) => {
+        const authorized = await scope.remote.workspaceFiles.readBytes(file.sessionId, file.path, { offset: 0, length: 1 }, signal)
+        signal.throwIfAborted()
+        if (!authorized.ok) return authorized
+        const metadata = await scope.remote.workspaceFiles.stat(file.sessionId, file.path, signal)
+        if (metadata.ok && (authorized.value.absolutePath !== metadata.value.absolutePath
+          || authorized.value.version !== metadata.value.version)) {
+          throw new Error(t('changed'))
+        }
+        return metadata
+      },
+      convert, config.maxCachedEntries, config.maxCachedBytes, config.maxPending, config.maxReaders,
+      async (signal) => {
+        const result = await scope.remote.officeToPdf.generation(signal)
+        signal.throwIfAborted()
+        if (!result.ok) throw new Error(t('unavailable'), { cause: result.error })
+        return result
+      }, () => new Error(t('busy')),
+    )
+    let cache = createCache()
+    const retired = new Set<Promise<void>>()
+    read = async (file, signal) => {
+      const result = await cache.read(file, signal)
+      if (!result.ok && (result.error.code === 'gateway/invocation-unavailable' || result.error.code === 'gateway/service-unavailable')) {
+        throw new Error(t('unavailable'), { cause: result.error })
+      }
+      return result
+    }
+    scope.on('connection/reset', () => {
+      const previous = cache
+      cache = createCache()
+      const closing = previous.dispose().finally(() => { retired.delete(closing) })
+      retired.add(closing)
+    })
+    scope.effect(() => async () => { read = unavailable; await Promise.all([...retired, cache.dispose()]) })
+  })
+}
+
+function conversionErrorKey(code: string): OfficePreviewKey {
+  switch (code) {
+    case 'input-too-large': case 'output-too-large': return 'tooLarge'
+    case 'invalid-document': case 'unsupported-format': return 'invalid'
+    case 'timeout': return 'timeout'
+    case 'unavailable': return 'unavailable'
+    case 'busy': return 'busy'
+    case 'source-changed': return 'changed'
+    default: return 'failed'
+  }
+}

+ 44 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/locales.ts

@@ -0,0 +1,44 @@
+/** Office preview copy and Host render configuration guidance. */
+export const zh = {
+  title: 'Office 文档',
+  loading: '正在读取…',
+  retry: '重试',
+  missingFonts: '缺少文档使用的字体:{fonts},可能影响文字和排版。',
+  showMore: '显示更多',
+  dismissNotice: '关闭字体提示',
+  missingFontsTitle: '缺失的字体',
+  missingFontsDescription: '本次预览无法使用以下字体,预览中的文字和排版可能与原文档不同。',
+  missingFontsCount: '{count} 种字体',
+  closeDetails: '关闭字体详情',
+  unavailable: 'Office 预览不可用。请在运行 DeepSeek Harness 的主机上启用文档预览服务。',
+  invalid: '无法预览此 Office 文件。文件可能已损坏、受密码保护,或与扩展名不符。',
+  tooLarge: 'Office 文件或转换后的 PDF 超过预览大小上限,请缩小文件或调整预览配置。',
+  failed: 'Office 转换失败,未生成可用的 PDF。请检查该文件后重试。',
+  timeout: 'Office 转换超时,请重试。',
+  busy: 'Office 预览任务较多,请稍后重试。',
+  changed: '文件在读取时已更改,请重新打开预览。',
+} satisfies Record<string, string>
+
+/** Office preview locale keys. */
+export type OfficePreviewKey = keyof typeof zh
+
+/** English translations checked against the Chinese key set. */
+export const en = {
+  title: 'Office document',
+  loading: 'Reading…',
+  retry: 'Retry',
+  missingFonts: 'Fonts used in this document are unavailable: {fonts}. Text and layout may differ.',
+  showMore: 'Show more',
+  dismissNotice: 'Dismiss font notice',
+  missingFontsTitle: 'Missing fonts',
+  missingFontsDescription: 'These fonts are unavailable for this preview. Text and layout may differ from the original document.',
+  missingFontsCount: 'Fonts: {count}',
+  closeDetails: 'Close font details',
+  unavailable: 'Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.',
+  invalid: 'This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.',
+  tooLarge: 'The Office file or converted PDF exceeds the preview size limit. Reduce the file size or adjust the preview configuration.',
+  failed: 'Office conversion did not produce a usable PDF. Check the file and try again.',
+  timeout: 'Office conversion timed out. Try again.',
+  busy: 'Office preview is busy. Try again shortly.',
+  changed: 'The file changed while being read. Reopen the preview.',
+} satisfies Record<OfficePreviewKey, string>

+ 53 - 0
packages/client/ui-sidebar-documentpreview/src/client/office/store.ts

@@ -0,0 +1,53 @@
+/** Loaded Office previews survive body remounts until reload or tab closure. */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { OfficeFileBytes } from './cache.ts'
+
+/** One requested source revision and its settled preview. */
+export interface OfficeView {
+  readonly revision: number
+  readonly file?: OfficeFileBytes
+  readonly failure?: { readonly code: string; readonly message: string }
+}
+
+/** Office-owned content, isolated by tab identity. */
+export interface OfficeState {
+  byTab: Record<TabId, OfficeView>
+}
+
+type OfficeActions = {
+  loading: (state: OfficeState, tab: TabId, revision: number) => void
+  complete: (state: OfficeState, tab: TabId, revision: number, file: OfficeFileBytes) => void
+  failed: (state: OfficeState, tab: TabId, revision: number, failure: NonNullable<OfficeView['failure']>) => void
+  forget: (state: OfficeState, tab: TabId) => void
+}
+
+/**
+ * Retain Office contents across body remounts within a Session.
+ * @returns the tab-content store declaration.
+ */
+export function createOfficeStore(): EngineStoreHandle<OfficeState, OfficeActions> {
+  return defineStore({
+    init: (): OfficeState => ({ byTab: {} }),
+    actions: {
+      /** @param state - draft. @param tab - owning tab. @param revision - new content revision. */
+      loading(state, tab: TabId, revision: number) { state.byTab[tab] = { revision } },
+      /** @param state - draft. @param tab - owning tab. @param revision - completed revision. @param file - borrowed PDF bytes. */
+      complete(state, tab: TabId, revision: number, file: OfficeFileBytes) {
+        state.byTab[tab] = { revision, file }
+      },
+      /** @param state - draft. @param tab - owning tab. @param revision - failed revision. @param failure - displayable failure. */
+      failed(state, tab: TabId, revision: number, failure: NonNullable<OfficeView['failure']>) {
+        state.byTab[tab] = { revision, failure }
+      },
+      /** @param state - draft. @param tab - closed tab. */
+      forget(state, tab: TabId) {
+        const { [tab]: _closed, ...remaining } = state.byTab
+        state.byTab = remaining
+      },
+    },
+  })
+}
+
+/** Store declaration used by the Office body. */
+export type OfficeStore = ReturnType<typeof createOfficeStore>

+ 94 - 1
packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css

@@ -5,6 +5,7 @@
   width: 100%;
   min-height: 0;
   overflow-x: hidden;
+  background: var(--dsw-alias-bg-document-preview);
   font-family: var(--dsw-font, sans-serif);
   white-space: normal;
 }
@@ -32,10 +33,98 @@
   margin-inline: auto;
 }
 
-.canvas[hidden] {
+.surface {
+  position: relative;
+  max-width: 100%;
+  margin-inline: auto;
+  box-shadow: var(--dsw-elevation-prominent);
+}
+
+.surface[hidden] {
   display: none;
 }
 
+.text {
+  position: absolute;
+  inset: 0;
+  overflow: clip;
+}
+
+/* TextLayerBuilder selection styles follow pdfjs-dist/web/pdf_viewer.css. */
+.text :global(.textLayer) {
+  position: absolute;
+  inset: 0;
+  text-align: initial;
+  overflow: clip;
+  line-height: 1;
+  letter-spacing: normal;
+  word-spacing: normal;
+  text-size-adjust: none;
+  forced-color-adjust: none;
+  transform-origin: 0 0;
+  caret-color: CanvasText;
+  z-index: 0;
+  --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
+  --min-font-size-inv: calc(1 / var(--min-font-size));
+}
+
+.text :global(.textLayer)[data-main-rotation="90"] {
+  transform: rotate(90deg) translateY(-100%);
+}
+
+.text :global(.textLayer)[data-main-rotation="180"] {
+  transform: rotate(180deg) translate(-100%, -100%);
+}
+
+.text :global(.textLayer)[data-main-rotation="270"] {
+  transform: rotate(270deg) translateX(-100%);
+}
+
+.text :is(span, br) {
+  position: absolute;
+  color: transparent;
+  white-space: pre;
+  cursor: text;
+  transform-origin: 0 0;
+  user-select: text;
+}
+
+.text :global(.textLayer) > :not(:global(.markedContent)),
+.text :global(.markedContent) span:not(:global(.markedContent)) {
+  z-index: 1;
+  --font-height: 0;
+  --scale-x: 1;
+  --rotate: 0deg;
+  font-size: calc(var(--text-scale-factor) * var(--font-height));
+  transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
+}
+
+.text :global(.markedContent) {
+  display: contents;
+}
+
+.text ::selection {
+  background: var(--dsw-alias-interactive-bg-hover-accent);
+  color: transparent;
+}
+
+.text br::selection {
+  background: transparent;
+}
+
+.text :global(.textLayer) :global(.endOfContent) {
+  display: block;
+  position: absolute;
+  inset: 100% 0 0;
+  z-index: 0;
+  cursor: default;
+  user-select: none;
+}
+
+.text :global(.selecting) :global(.endOfContent) {
+  top: 0;
+}
+
 .status {
   display: flex;
   gap: 8px;
@@ -46,6 +135,10 @@
   line-height: 1.5;
 }
 
+.body .status {
+  color: var(--dsw-alias-label-document-preview);
+}
+
 /* The document-open wait fills the still-empty body and centres, matching the
    owner's read spinner position. */
 .opening {

+ 23 - 3
packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts

@@ -1,5 +1,5 @@
 /** Canvas rendering with cancellation and page cleanup, shared by the PDF body and real-library smoke. */
-import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
+import type { PDFDocumentProxy, PDFPageProxy, PageViewport } from 'pdfjs-dist'
 
 /** The document operations used by one mounted PDF body. */
 export type PdfDocument = Pick<PDFDocumentProxy, 'numPages' | 'getPage'>
@@ -21,6 +21,12 @@ export interface PdfPageSize {
   readonly height: number
 }
 
+/** Optional DOM text rendering that shares the canvas page's cleanup barrier. */
+export type RenderPdfText = (page: PDFPageProxy, viewport: PageViewport) => {
+  readonly promise: Promise<void>
+  cancel(): void
+}
+
 /**
  * Render one page into an exclusively owned canvas. Cancellation cannot write
  * dimensions after a delayed getPage; active render tasks are cancelled and
@@ -30,6 +36,7 @@ export interface PdfPageSize {
  * @param canvas - canvas owned by this render only.
  * @param signal - render lifetime.
  * @param pixelRatio - display pixel ratio.
+ * @param renderText - optional selectable text layer sharing this page and viewport.
  * @returns the page's CSS dimensions after rendering completes.
  */
 export async function renderPdfPage(
@@ -38,6 +45,7 @@ export async function renderPdfPage(
   canvas: HTMLCanvasElement,
   signal: AbortSignal,
   pixelRatio: number,
+  renderText?: RenderPdfText,
 ): Promise<PdfPageSize> {
   signal.throwIfAborted()
   const page: PDFPageProxy = await document.getPage(pageNumber)
@@ -55,13 +63,25 @@ export async function renderPdfPage(
       viewport,
       transform: ratio === 1 ? undefined : [ratio, 0, 0, ratio, 0, 0],
     })
-    const cancel = (): void => { task.cancel() }
+    let text: ReturnType<RenderPdfText> | undefined
+    let cancelled = false
+    const cancel = (): void => {
+      if (cancelled) return
+      cancelled = true
+      task.cancel()
+      text?.cancel()
+    }
     signal.addEventListener('abort', cancel, { once: true })
     try {
+      text = renderText?.(page, viewport)
       if (signal.aborted) cancel()
-      await task.promise
+      await Promise.all([task.promise, text?.promise])
       signal.throwIfAborted()
       return { width: viewport.width, height: viewport.height }
+    } catch (error) {
+      cancel()
+      await Promise.allSettled([task.promise, text?.promise])
+      throw error
     } finally {
       signal.removeEventListener('abort', cancel)
     }

+ 24 - 19
packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts

@@ -1,10 +1,13 @@
 /** Builtin PDF registration through document metadata and the keyed body slot. */
 import type { Context } from '@deepseek-ai/cordis'
+import { retainDocumentTabs } from '../document/tab-lifetime.ts'
 import type {} from '../index.ts'
 import type { DocumentPreviewDefinition } from '../document/registry.ts'
 import type { PdfBodyInjected } from './pdf.tsx'
 import { LazyPdfBody } from './LazyPdfBody.tsx'
-import { createPdfStore } from './store.ts'
+import type { BoundActions } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createPdfStore, type PdfStore } from './store.ts'
 import { en, zh } from './locales.ts'
 
 /** PDF metadata and keyed body share this package-local implementation identity. */
@@ -24,25 +27,27 @@ export function apply(ctx: Context): void {
   ctx.effect(() => ctx.locale.register('sidebarPdf', { zh, en }))
   const t = ctx.locale.bind('sidebarPdf')
   ctx.effect(() => ctx.documentPreviews.register(pdfBodyDefinition(() => t('title'))))
-  const store = createPdfStore()
-  const retained = new Map<AbortSignal, () => void>()
-  ctx.effect(() => () => {
-    for (const forget of retained.values()) forget()
-  })
+  const presentation = pdfBodyRegistration(ctx)
   ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
-    name: 'sidebar.right.tab.document', key: PDF_BODY_ID, locale: 'sidebarPdf', store,
+    name: 'sidebar.right.tab.document', key: PDF_BODY_ID, locale: 'sidebarPdf', ...presentation,
+  }, LazyPdfBody)))
+}
+
+/**
+ * Retain PDF viewing state for a document entry's tab lifetime.
+ * @param ctx - owning registration context.
+ * @returns the store and injection shared by ordinary and Office PDF registrations.
+ */
+export function pdfBodyRegistration(ctx: Context): {
+  store: PdfStore
+  inject: (sessionId: SessionId, actions: BoundActions<PdfStore>) => PdfBodyInjected
+} {
+  const store = createPdfStore()
+  const retainTab = retainDocumentTabs(ctx)
+  return {
+    store,
     inject: (_sessionId, actions): PdfBodyInjected => ({
-      retainTab: (tabId, signal) => {
-        if (signal.aborted) { actions.forget(tabId); return }
-        if (retained.has(signal)) return
-        const forget = (): void => {
-          signal.removeEventListener('abort', forget)
-          retained.delete(signal)
-          actions.forget(tabId)
-        }
-        retained.set(signal, forget)
-        signal.addEventListener('abort', forget, { once: true })
-      },
+      retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) },
     }),
-  }, LazyPdfBody)))
+  }
 }

+ 15 - 5
packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx

@@ -8,6 +8,7 @@ import type { PdfStore, PdfView } from './store.ts'
 import { renderPdfPage, type PdfDocument } from './document.ts'
 import { openPdf } from './runtime.ts'
 import { PdfWorkerFailure } from './errors.ts'
+import { pdfTextRenderer } from './text.ts'
 import type {} from './locales.ts'
 import css from './PdfBody.module.css'
 
@@ -94,6 +95,8 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
 } & PropsLocale<'sidebarPdf'>): ReactNode {
   const host = useRef<HTMLDivElement>(null)
   const canvas = useRef<HTMLCanvasElement>(null)
+  const text = useRef<HTMLDivElement>(null)
+  const [width, setWidth] = useState<number>()
   const [requested, setRequested] = useState(initiallyRequested)
   const [state, setState] = useState<'loading' | 'ready'>('loading')
   const [failure, setFailure] = useState<{ readonly error: unknown }>()
@@ -125,11 +128,16 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
     const renderSignal = AbortSignal.any([lifetime.signal, signal])
     setState('loading')
     setFailure(undefined)
-    void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio).then(
-      () => { if (!renderSignal.aborted) setState('ready') },
+    const createText = pdfTextRenderer(text.current as HTMLDivElement)
+    let textTask: ReturnType<typeof createText> | undefined
+    void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio, (pdfPage, viewport) => {
+      textTask = createText(pdfPage, viewport)
+      return textTask
+    }).then(
+      (size) => { if (!renderSignal.aborted) { setWidth(size.width); setState('ready') } },
       (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) },
     )
-    return () => { lifetime.abort() }
+    return () => { lifetime.abort(); textTask?.cancel() }
   }, [document, page, requested, signal, attempt])
   return <div ref={host} className={css.page} data-pdf-page={page}>
     {failure === undefined && state !== 'ready' && (requested
@@ -139,8 +147,10 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
       <span>{failureText(failure.error, t)}</span>
       <Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
     </div>}
-    <canvas ref={canvas} className={css.canvas} role="img" aria-label={t('pageImage', { page })}
-      hidden={state !== 'ready' || failure !== undefined} />
+    <div className={css.surface} style={{ width }} hidden={state !== 'ready' || failure !== undefined}>
+      <canvas ref={canvas} className={css.canvas} role="img" aria-label={t('pageImage', { page })} />
+      <div ref={text} className={css.text} data-pdf-text />
+    </div>
   </div>
 }
 

+ 38 - 0
packages/client/ui-sidebar-documentpreview/src/client/pdf/text.ts

@@ -0,0 +1,38 @@
+/** PDF.js's viewer owns selection boundaries and copy normalization; the overlay follows its canvas. */
+// The official viewer resolves its version-matched engine through pdfjsLib.
+import 'pdfjs-dist'
+import { TextLayerBuilder } from 'pdfjs-dist/web/pdf_viewer.mjs'
+import type { RenderPdfText } from './document.ts'
+
+/**
+ * Create a selectable overlay in a component-owned host.
+ * @param host - absolute overlay matching the displayed canvas dimensions.
+ * @returns renderer whose cancellation also releases its resize observer and DOM.
+ */
+export function pdfTextRenderer(host: HTMLDivElement): RenderPdfText {
+  return (page, viewport) => {
+    // cancel() releases the builder's shared selection listener after the last page leaves.
+    const layer = new TextLayerBuilder({ pdfPage: page })
+    const container = layer.div
+    container.style.setProperty('--total-scale-factor', String(viewport.scale * viewport.userUnit))
+    container.style.setProperty('--scale-round-x', '1px')
+    container.style.setProperty('--scale-round-y', '1px')
+    host.append(container)
+    const resize = (): void => {
+      // Width fitting must compose with the viewer's page rotation and translation.
+      container.style.scale = String(host.getBoundingClientRect().width / viewport.width)
+    }
+    const observer = new ResizeObserver(resize)
+    observer.observe(host)
+    resize()
+    return {
+      // PDF.js 6 declares images as required, but its viewer also renders text-only layers without it.
+      promise: layer.render({ viewport } as Parameters<TextLayerBuilder['render']>[0]),
+      cancel() {
+        observer.disconnect()
+        layer.cancel()
+        container.remove()
+      },
+    }
+  }
+}

+ 6 - 3
packages/client/ui-sidebar-documentpreview/src/client/rpc.ts

@@ -88,9 +88,9 @@ export type DocumentFileBytes = Omit<WorkspaceFileBytes, 'data'> & { readonly da
  * Read a complete file through the Host endpoint.
  * @param file - Session and path decoded from the tab address.
  * @param signal - owning tab lifetime.
- * @returns complete wire bytes, including declared failures.
+ * @returns complete binary bytes, including declared failures.
  */
-export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<WorkspaceFileBytes>>
+export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise<RemoteResult<DocumentFileBytes>>
 
 /**
  * Decode one successful Remote byte result for document renderers.
@@ -98,5 +98,8 @@ export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Prom
  * @returns the same metadata with native bytes; malformed base64 throws.
  */
 export function documentFileBytes(file: WorkspaceFileBytes): DocumentFileBytes {
-  return { ...file, data: Uint8Array.from(atob(file.data), character => character.charCodeAt(0)) }
+  const binary = atob(file.data)
+  const data = new Uint8Array(binary.length)
+  for (let index = 0; index < binary.length; index++) data[index] = binary.charCodeAt(index)
+  return { ...file, data }
 }

+ 22 - 2
packages/client/ui-sidebar-documentpreview/src/client/store.ts

@@ -35,6 +35,10 @@ export interface TextTabState {
   rendererId?: string
   /** Current display-loading mode; absent before the first read. */
   mode?: DocumentLoadMode
+  /** Implementation owning source loading; absent for ordinary file reads. */
+  contentRendererId?: string
+  /** Current content revision, incremented whenever loaded content is discarded. */
+  loadRevision: number
   /** Full byte result used by complete-file renderers. */
   complete?: DocumentFileBytes
   /** The file version the loaded pages belong to; absent before the first page. */
@@ -68,6 +72,7 @@ export interface TextState {
  */
 export function fresh(): TextTabState {
   return {
+    loadRevision: 0,
     version: undefined,
     observedVersion: undefined,
     pages: {},
@@ -88,7 +93,8 @@ function bucket(state: TextState, tabId: TabId): TextTabState {
 /** The preview store's write set; every action names the tab it writes. */
 type TextActions = {
   selected: (draft: TextState, tabId: TabId, rendererId: string | undefined) => void
-  loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string) => void
+  loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => void
+  rendered: (draft: TextState, tabId: TabId, revision: number, version: string) => void
   complete: (draft: TextState, tabId: TabId, file: DocumentFileBytes) => void
   page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void
   failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void
@@ -121,14 +127,27 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
        * @param tabId - the tab being drawn.
        * @param mode - selected renderer's loading mode.
        * @param observedVersion - metadata version at read start; later pages retain the initial observation.
+       * @param contentRendererId - implementation owning source loading.
        */
-      loading: (d, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string) => {
+      loading: (d, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => {
         const state = bucket(d, tabId)
         if (state.version === undefined && !state.loading) state.observedVersion = observedVersion
+        if (contentRendererId === undefined) delete state.contentRendererId
+        else state.contentRendererId = contentRendererId
         state.loading = true
         state.failure = undefined
         if (mode !== undefined) state.mode = mode
       },
+      /**
+       * @param d - draft. @param tabId - owning tab.
+       * @param revision - active content revision. @param version - displayed source version.
+       */
+      rendered: (d, tabId: TabId, revision: number, version: string) => {
+        const state = d.byTab[tabId]
+        if (state?.mode !== 'renderer' || state.loadRevision !== revision) return
+        state.version = version
+        state.loading = false
+      },
       /** @param d - draft. @param tabId - owning tab. @param file - complete byte result for this view. */
       complete: (d, tabId: TabId, file: DocumentFileBytes) => {
         const state = bucket(d, tabId)
@@ -172,6 +191,7 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
        */
       reset: (d, tabId: TabId) => {
         const state = bucket(d, tabId)
+        state.loadRevision++
         state.pages = {}
         delete state.complete
         state.eof = false

+ 27 - 0
packages/client/ui-sidebar-documentpreview/src/config.ts

@@ -0,0 +1,27 @@
+/** Cache limits shared by the Host configuration and browser document previews. */
+import z from '@deepseek-ai/schemastery'
+
+/** Transient Office conversion reuse within one Client connection. */
+export interface Config {
+  /** Retained PDF limits; pending conversions share cancellation by reader lifetime. */
+  office: {
+    /** Maximum retained completed PDFs. */
+    maxCachedEntries: number
+    /** Maximum retained PDF bytes, counted by each binary buffer's byteLength. */
+    maxCachedBytes: number
+    /** Maximum unsettled Host conversion RPCs, including cancellation teardown. */
+    maxPending: number
+    /** Maximum readers including source and renderer metadata lookups. */
+    maxReaders: number
+  }
+}
+
+/** Deployment limits applied before Office preview registration. */
+export const Config: z<{ office?: Partial<Config['office']> }, 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),
+  }),
+})

+ 16 - 3
packages/client/ui-sidebar-documentpreview/src/index.ts

@@ -1,4 +1,17 @@
-/** Pure host half; the whole preview lives in the browser export. */
+/** Host configuration for browser document previews. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-host-webserver'
+import type { Config } from './config.ts'
 
-/** Host plugin body: the preview contributes nothing to the host tree. */
-export function apply(): void {}
+export { Config } from './config.ts'
+
+/**
+ * Embed validated preview settings in browser pages.
+ * @param ctx - Host context serving browser pages.
+ * @param config - Cache limits adopted when the page loads.
+ */
+export function apply(ctx: Context, config: Config): void {
+  ctx.on('webserver/index-inject', (table) => {
+    table.push({ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config })
+  })
+}

+ 11 - 7
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -12,7 +12,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
 import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
 import { apply, inject } from '../src/client/index.ts'
-import { apply as hostApply } from '../src/index.ts'
+import { OfficeBody } from '../src/client/office/OfficeBody.tsx'
 import { TextPreview } from '../src/client/TextPreview.tsx'
 import { TextTitle } from '../src/client/TextTitle.tsx'
 import { TextBody } from '../src/client/text/TextBody.tsx'
@@ -27,6 +27,7 @@ import { LazyPdfBody } from '../src/client/pdf/LazyPdfBody.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'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type { textFace } from '../src/client/face.ts'
 import type { TextStore } from '../src/client/store.ts'
 import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
@@ -78,10 +79,6 @@ async function boot() {
 }
 
 describe('ui-sidebar-documentpreview apply', () => {
-  it('keeps the host Loader entry inert', () => {
-    expect(hostApply).not.toThrow()
-  })
-
   it('registers the type, its dictionaries, and the body and title seats under the type\'s id, the body with a store and a face', async () => {
     const { tabs, registered, dictionaries } = await boot()
     expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
@@ -98,6 +95,8 @@ describe('ui-sidebar-documentpreview apply', () => {
       ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
       ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', LazyPdfBody],
       ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
+      ['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],
     ])
     expect(registered[0]?.store).toBeDefined()
     expect(typeof registered[0]?.inject).toBe('function')
@@ -125,7 +124,12 @@ describe('ui-sidebar-documentpreview apply', () => {
     expect(instance.getSnapshot().byTab[TAB_ID]?.pages[1]?.text).toBe('first')
     face.loadAll(TAB_ID, FILE, controller.signal, 'v1')
     await workspaceFiles.readAll.mock.results[0]?.value
-    expect(workspaceFiles.readAll).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, controller.signal)
-    expect(instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255]))
+    expect(workspaceFiles.readAll).toHaveBeenCalledExactlyOnceWith(FILE.sessionId, FILE.path, expect.any(AbortSignal))
+    await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.complete?.data).toEqual(new Uint8Array([0, 1, 255]))
+    const failure = new RemoteError('workspace-file/not-found', 'File missing', { path: FILE.path })
+    workspaceFiles.readAll.mockResolvedValueOnce({ ok: false, error: failure })
+    face.reloadAll(TAB_ID, FILE, controller.signal, 'v2')
+    await expect.poll(() => instance.getSnapshot().byTab[TAB_ID]?.failure).toBe(failure)
+    expect(instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
   })
 })

+ 29 - 0
packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts

@@ -0,0 +1,29 @@
+/** Host configuration supplies bounded Office reuse settings to browser pages. */
+import { Context } from '@deepseek-ai/cordis'
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+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', () => {
+  expect(host.Config).toBe(Config)
+  expect(Config({})).toEqual({ office: {
+    maxCachedEntries: 8, maxCachedBytes: 64 * 1024 * 1024, maxPending: 8, maxReaders: 32,
+  } })
+  for (const key of ['maxCachedEntries', 'maxCachedBytes']) expect(() => Config({ office: { [key]: 0 } })).toThrow()
+})
+
+it('embeds YAML cache settings in browser pages and withdraws them on disposal', async () => {
+  const ctx = new Context()
+  onTestFinished(async () => { await ctx.fiber.dispose() })
+  const config = Config({ office: { maxCachedEntries: 2 } })
+  const fiber = ctx.plugin(host, config)
+  await fiber.await()
+  const rows: IndexInjection[] = []
+  ctx.emit('webserver/index-inject', rows)
+  expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }])
+  await fiber.dispose()
+  const after: IndexInjection[] = []
+  ctx.emit('webserver/index-inject', after)
+  expect(after).toEqual([])
+})

+ 31 - 0
packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts

@@ -0,0 +1,31 @@
+import { spawnSync } from 'node:child_process'
+import { expect, it } from 'vitest'
+import { documentFileBytes } from '../src/client/rpc.ts'
+
+const source = { absolutePath: '/report.pdf', version: 'v1', offset: 0, eof: true, missingFonts: ['Missing Serif'] }
+
+it('preserves every byte value, source metadata, font notices, padding, and empty input', () => {
+  for (const size of [0, 1, 2, 256, 257]) {
+    const input = Uint8Array.from({ length: size }, (_, index) => index % 256)
+    expect(documentFileBytes({ ...source, data: Buffer.from(input).toString('base64') }))
+      .toEqual({ ...source, data: input })
+  }
+  expect(() => documentFileBytes({ ...source, data: '!invalid!' })).toThrow()
+})
+
+it('decodes a large response within a bounded JavaScript heap', () => {
+  // Typed buffers fit this heap; expanding the binary string into JS elements does not.
+  const decoder = new URL('../src/client/rpc.ts', import.meta.url).href
+  const child = spawnSync(process.execPath, ['--max-old-space-size=96', '--import', 'tsx/esm', '--input-type=module', '-e', `
+    import { documentFileBytes } from ${JSON.stringify(decoder)}
+    const input = Buffer.alloc(12 * 1024 * 1024)
+    for (let index = 0; index < input.length; index++) input[index] = index % 256
+    const output = documentFileBytes({ ...${JSON.stringify(source)}, data: input.toString('base64') }).data
+    if (!Buffer.from(output).equals(input)) throw new Error('Decoded PDF bytes differ')
+    process.stdout.write(String(output.length))
+  `], { encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, env: { ...process.env, NODE_OPTIONS: '' } })
+  expect(child.error).toBeUndefined()
+  expect(child.signal, child.stderr).toBeNull()
+  expect(child.status, child.stderr).toBe(0)
+  expect(child.stdout).toBe(String(12 * 1024 * 1024))
+})

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx

@@ -83,7 +83,7 @@ async function boot() {
             data-renderer={id} data-renderer-tab={tab.id}
             data-renderer-path={resource.value?.absolutePath} data-renderer-version={resource.value?.version}
           >
-            {props.content.kind === 'text' ? props.content.text : new TextDecoder().decode(props.content.data)}
+            {props.content.kind === 'text' ? props.content.text : props.content.kind === 'bytes' ? new TextDecoder().decode(props.content.data) : 'renderer'}
           </div>
         )
       },

+ 77 - 20
packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx

@@ -7,8 +7,11 @@ import { TextPreview } from '../src/client/TextPreview.tsx'
 import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
 import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
 import { CodeBody } from '../src/client/code/CodeBody.tsx'
-import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
-import { ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
+import { textBodyDefinition, PLAIN_BODY_ID } from '../src/client/text/index.ts'
+import { pdfBodyDefinition } from '../src/client/pdf/index.ts'
+import { imageBodyDefinition } from '../src/client/image/index.ts'
+import { htmlBodyDefinition } from '../src/client/html/index.ts'
+import { documentSlots, ABSOLUTE_PATH, FILE, harness, page, settle, TAB_ID } from './fixtures.client.ts'
 
 afterEach(cleanup)
 
@@ -24,12 +27,56 @@ function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
   return {
     ...props,
     useDocumentPreviews: selector => selector([definition]),
-    // This adapter only receives the concrete document slot, not an arbitrary generic key.
-    renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
+    renderSlot: documentSlots((_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />),
   }
 }
 
 describe('document toolbar', () => {
+  it.each([
+    ['report.doc', false], ['sheet.xls', false], ['slides.ppt', false],
+    ['report.docx', false], ['sheet.xlsx', false], ['slides.pptx', false],
+    ['report.pdf', false], ['image.png', false], ['image.SVG', true], ['page.html', true],
+  ])('offers plain text for %s only when supported', async (path, supportsText) => {
+    const h = harness()
+    const props = h.props()
+    const info = props.useTabInfo()
+    const definitions = [
+      textBodyDefinition(() => 'Plain text'), pdfBodyDefinition(() => 'PDF'),
+      imageBodyDefinition(() => 'Image'), htmlBodyDefinition(() => 'HTML'),
+      { ...binary, extensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'], binaryExtensions: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'] },
+    ]
+    h.bytes.mockResolvedValue({ ok: true, value: {
+      absolutePath: path, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true,
+    } })
+    if (!supportsText) h.instance.actions.selected(TAB_ID, PLAIN_BODY_ID)
+    const view = render(<TextPreview
+      {...props}
+      useTabInfo={() => ({ ...info, tab: { ...info.tab, contentId: `dsh-resource://file/session/s-1/${path}` } })}
+      useDocumentPreviews={selector => selector(definitions)}
+      renderSlot={() => null}
+    />)
+    await settle()
+    const picker = view.queryByRole('button', { name: 'openWith' })
+    expect(picker !== null).toBe(supportsText)
+    expect(h.read).not.toHaveBeenCalled()
+    expect(h.bytes).toHaveBeenCalledTimes(1)
+    if (picker !== null) {
+      fireEvent.click(picker)
+      expect(screen.getByRole('menuitem', { name: 'Plain text' })).toBeDefined()
+    } else {
+      expect(view.container.textContent).not.toContain('Plain text')
+      expect(view.queryByRole('button', { name: 'wrap.aria' })).toBeNull()
+    }
+  })
+
+  it('shows no implementation picker when plain text is the only viewer', async () => {
+    const h = harness({ 1: page(1, ['held'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(view.queryByRole('button', { name: 'openWith' })).toBeNull()
+    expect(view.container.textContent).toContain('held')
+  })
+
   it('keeps Reading above the first code page and reload, retaining code during pagination', async () => {
     const h = harness()
     const first = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
@@ -97,7 +144,7 @@ describe('document toolbar', () => {
   it('shows the shared loading indicator until a complete read settles', async () => {
     const h = harness()
     const pending = Promise.withResolvers<Awaited<ReturnType<typeof h.bytes>>>()
-    const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
+    const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } }
     onTestFinished(async () => {
       h.controller.abort()
       pending.resolve(result)
@@ -119,13 +166,28 @@ describe('document toolbar', () => {
     expect(view.queryByRole('status')).toBeNull()
   })
 
-  it('retries and refreshes complete content and uses the read result path before metadata arrives', async () => {
+  it('mounts a renderer-owned body before content is available and withholds the previous pages', async () => {
+    const h = harness({ 1: page(1, ['previous reader content'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    const renderSlot = vi.fn(() => null)
+    view.rerender(<TextPreview {...h.props()} useDocumentPreviews={selector => selector([{ ...binary, loading: 'renderer' }])} renderSlot={renderSlot} />)
+    expect(view.container.textContent).not.toContain('previous reader content')
+    expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.document', expect.objectContaining({
+      content: expect.objectContaining({ kind: 'renderer' }) as unknown,
+    }), expect.any(Object))
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.complete).toBeUndefined()
+    expect(h.bytes).not.toHaveBeenCalled()
+  })
+
+  it('retries and refreshes complete content when metadata lookup fails', async () => {
     const h = harness()
-    const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
+    const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } }
     const read = h.bytes.mockResolvedValueOnce({
       ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }),
     }).mockResolvedValue(result)
-    h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined })
+    h.useResource.mockReturnValue({ status: 'failed', value: undefined,
+      failure: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) })
     const props: TextPreviewProps = {
       ...h.props(), useDocumentPreviews: selector => selector([binary]),
     }
@@ -141,7 +203,7 @@ describe('document toolbar', () => {
     fireEvent.click(view.container.querySelector('[data-textpreview-tool="reload"]')!)
     await settle()
     expect(read).toHaveBeenCalledTimes(3)
-    expect(read).toHaveBeenLastCalledWith(FILE, h.controller.signal)
+    expect(read).toHaveBeenLastCalledWith(FILE, expect.any(AbortSignal))
     h.controller.abort()
   })
 
@@ -160,7 +222,7 @@ describe('document toolbar', () => {
 
   it('drops the plain-text fallback for a declared binary suffix and hides the viewer control entirely', async () => {
     const h = harness()
-    h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: btoa('x'), bytes: 1, eof: true } })
+    h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/photo.png', version: 'v1', offset: 0, data: new TextEncoder().encode('x'), bytes: 1, eof: true } })
     const image: DocumentPreviewDefinition = {
       id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
     }
@@ -185,7 +247,7 @@ describe('document toolbar', () => {
 
   it('keeps the plain-text fallback in the picker for a non-binary suffix of the same viewer', async () => {
     const h = harness()
-    h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: btoa('<svg/>'), bytes: 6, eof: true } })
+    h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: '/host/project/work/logo.svg', version: 'v1', offset: 0, data: new TextEncoder().encode('<svg/>'), bytes: 6, eof: true } })
     const image: DocumentPreviewDefinition = {
       id: 'image', extensions: ['png', 'svg'], binaryExtensions: ['png'], title: () => 'Image', loading: 'bytes-complete',
     }
@@ -211,15 +273,10 @@ describe('document toolbar', () => {
 
   it('dismisses the implementation picker with Escape without changing the selected implementation', async () => {
     const h = harness({ 1: page(1, ['held'], true) })
-    const base = h.props()
-    const code: DocumentPreviewDefinition = {
-      id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
-    }
-    const plain: DocumentPreviewDefinition = {
-      id: PLAIN_BODY_ID, extensions: [], title: () => 'viewer.text', loading: 'text-pages', wrap: true,
-    }
-    const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector([code, plain]) }
-    const view = render(<TextPreview {...props} />)
+    const props = codeProps(h)
+    const view = render(<TextPreview {...props} useDocumentPreviews={selector => selector([
+      ...props.useDocumentPreviews(value => value), textBodyDefinition(() => 'viewer.text'),
+    ])} />)
     await settle()
     fireEvent.click(view.container.querySelector('[data-document-viewer-menu]')!)
     expect(screen.getByRole('menuitem', { name: 'viewer.text' })).toBeDefined()

+ 33 - 25
packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts

@@ -9,7 +9,7 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
-import type { WorkspaceFileBytes, WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
+import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
 import { textFace } from '../src/client/face.ts'
 import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts'
 import { hostFileOf } from '../src/client/rpc.ts'
@@ -81,7 +81,7 @@ function bench(sessionId = 'other-session' as SessionId) {
     pending.push({ offset, ...deferred })
     return deferred.promise
   })
-  const whole = readQueue<WorkspaceFileBytes>()
+  const whole = readQueue<DocumentFileBytes>()
   let sequence = 0
   const bytes = vi.fn<ReadDocumentBytes>(() => whole.request(++sequence))
   const controller = new AbortController()
@@ -105,10 +105,7 @@ function bench(sessionId = 'other-session' as SessionId) {
   }
   return {
     instance, read, face, forget, settle, bytes, controller,
-    settleAll: (result: RemoteResult<DocumentFileBytes>, key?: number) => whole.settle(result.ok
-      ? { ok: true, value: { ...result.value, data: btoa(String.fromCharCode(...result.value.data)) } }
-      : result, key),
-    settleAllWire: whole.settle,
+    settleAll: whole.settle,
     outstandingAll: whole.outstanding,
     outstanding: () => pending.map(call => call.offset),
     tab: () => instance.getSnapshot().byTab[TAB_1],
@@ -216,11 +213,11 @@ describe('textFace', () => {
     await settle(page(1, ['A'], true, 'v2'))
     expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: true })
   })
-  it('loads native complete bytes with only the tab signal', async () => {
+  it('loads native complete bytes with cancellable read lifetime', async () => {
     const { face, read, bytes, settleAll, tab, controller } = bench()
     const result = complete()
     face.loadAll(TAB_1, FILE, controller.signal)
-    expect(bytes).toHaveBeenCalledExactlyOnceWith(FILE, controller.signal)
+    expect(bytes).toHaveBeenCalledExactlyOnceWith(FILE, expect.any(AbortSignal))
     expect(read).not.toHaveBeenCalled()
     expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, pages: {}, failure: undefined })
     expect(tab()?.complete).toBeUndefined()
@@ -240,20 +237,6 @@ describe('textFace', () => {
     expect(tab()).toMatchObject({ loading: false, failure: undefined, complete: complete().value })
   })
 
-  it('records malformed complete-byte wire data as a failed read', async () => {
-    const { face, settleAllWire, tab, controller } = bench()
-    face.loadAll(TAB_1, FILE, controller.signal)
-    await settleAllWire({
-      ok: true,
-      value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: '!!!', eof: true, bytes: 3 },
-    })
-    expect(tab()).toMatchObject({
-      mode: 'bytes-complete', loading: false, version: undefined,
-      failure: { code: 'gateway/internal', message: 'document file byte response has malformed base64 data' },
-    })
-    expect(tab()?.complete).toBeUndefined()
-  })
-
   it('reloads complete bytes, discarding the old result and preserving the view', async () => {
     const { instance, face, bytes, settleAll, tab, controller } = bench()
     face.loadAll(TAB_1, FILE, controller.signal)
@@ -264,7 +247,7 @@ describe('textFace', () => {
     instance.actions.navigated(TAB_1, 3)
     face.reloadAll(TAB_1, FILE, controller.signal)
     expect(bytes).toHaveBeenCalledTimes(2)
-    expect(bytes).toHaveBeenLastCalledWith(FILE, controller.signal)
+    expect(bytes).toHaveBeenLastCalledWith(FILE, expect.any(AbortSignal))
     expect(tab()).toMatchObject({ mode: 'bytes-complete', loading: true, version: undefined, eof: false, pages: {} })
     expect(tab()?.complete).toBeUndefined()
     const result = complete('v2', new Uint8Array([2, 3, 255]))
@@ -406,11 +389,36 @@ describe('textFace', () => {
     await second.settle(page(1, ['second'], true))
     first.face.loadAll(TAB_1, firstFile, first.controller.signal)
     second.face.reloadAll(TAB_1, secondFile, second.controller.signal)
-    expect(first.bytes).toHaveBeenCalledExactlyOnceWith(firstFile, first.controller.signal)
-    expect(second.bytes).toHaveBeenCalledExactlyOnceWith(secondFile, second.controller.signal)
+    expect(first.bytes).toHaveBeenCalledExactlyOnceWith(firstFile, expect.any(AbortSignal))
+    expect(second.bytes).toHaveBeenCalledExactlyOnceWith(secondFile, expect.any(AbortSignal))
     await first.settleAll(complete('v1'))
     await second.settleAll(complete('v2'))
     expect(first.tab()?.version).toBe('v1')
     expect(second.tab()?.version).toBe('v2')
   })
 })
+
+it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active complete-read decoding failure: %s', async (failure) => {
+  const instance = createTextStore().create()
+  const controller = new AbortController()
+  const read = vi.fn<ReadDocumentBytes>().mockRejectedValue(failure)
+  try {
+    textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal)
+    await Promise.resolve()
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' })
+  } finally { controller.abort() }
+})
+
+it('ignores a complete-read rejection after renderer-owned loading takes over', async () => {
+  const instance = createTextStore().create()
+  const controller = new AbortController()
+  const pending = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
+  const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions)
+  try {
+    face.loadAll(TAB_1, FILE, controller.signal)
+    face.prepareRenderer(TAB_1, controller.signal, 'office')
+    pending.reject(new Error('retired'))
+    await pending.promise.catch(() => {})
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined()
+  } finally { controller.abort() }
+})

+ 9 - 3
packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts

@@ -8,6 +8,7 @@
  * not the slot runtime.
  */
 import { onTestFinished, vi } from 'vitest'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { Mock } from 'vitest'
 import { act } from '@testing-library/react'
 import { createElement, useSyncExternalStore } from 'react'
@@ -26,6 +27,11 @@ import { TextBody } from '../src/client/text/TextBody.tsx'
 import { textBodyDefinition } from '../src/client/text/index.ts'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 
+type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
+
+/** Preserve the body-slot callback used by component fixtures. */
+export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return body }
+
 export const TAB_ID = 'tab-1' as TabId
 export const SESSION = 's-1' as SessionId
 /** The path relative to the session's workspace root, as the Host receives it. */
@@ -122,9 +128,9 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
   onTestFinished(() => { controller.abort() })
   const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
   const definitions = [textBodyDefinition(() => t('viewer.text'))]
-  const renderSlot: TextPreviewProps['renderSlot'] = (_key, owner, opts) => createElement(TextBody, {
+  const renderSlot = documentSlots((_key, owner, opts) => createElement(TextBody, {
     ...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource,
-  } as unknown as DocumentPreviewProps)
+  } as unknown as DocumentPreviewProps))
   const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({
     useTabInfo: () => ({
       sidebar: { expanded: true, fullscreen: false },
@@ -142,7 +148,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
     actions: instance.actions,
     loadPage: face.loadPage,
     reloadPages: face.reloadPages,
-    loadAll: face.loadAll,
+    prepareRenderer: face.prepareRenderer, loadAll: face.loadAll,
     reloadAll: face.reloadAll,
     useDocumentPreviews: () => definitions,
     renderSlot,

+ 4 - 0
packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml

@@ -0,0 +1,4 @@
+- id: ui-sidebar-documentpreview
+  config:
+    office:
+      maxCachedEntries: 2

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts

@@ -21,7 +21,7 @@ afterEach(async () => { await dispose?.(); dispose = undefined })
 describe('HTML registration', () => {
   it('claims HTML suffixes as a builtin complete-byte renderer without wrap', () => {
     const title = vi.fn(() => 'localized HTML')
-    expect(htmlBodyDefinition(title)).toEqual({
+    expect(htmlBodyDefinition(title)).toMatchObject({
       id: HTML_BODY_ID, extensions: ['html', 'htm'], priority: 'builtin', title, loading: 'bytes-complete', wrap: false,
     })
     expect(title).not.toHaveBeenCalled()

+ 1 - 1
packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts

@@ -13,7 +13,7 @@ describe('image registration', () => {
   it('claims common image suffixes as a builtin complete-byte renderer without wrap', () => {
     const title = vi.fn(() => 'localized image')
     const definition = imageBodyDefinition(title)
-    expect(definition).toEqual({
+    expect(definition).toMatchObject({
       id: IMAGE_BODY_ID,
       extensions: IMAGE_EXTENSIONS,
       binaryExtensions: BINARY_IMAGE_EXTENSIONS,

+ 395 - 0
packages/client/ui-sidebar-documentpreview/tests/office-cache.client.spec.ts

@@ -0,0 +1,395 @@
+/** Controlled conversion completions pin shared cancellation, freshness, and bounded binary reuse. */
+import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionFile } from '../src/client/rpc.ts'
+import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
+import { OfficePreviewCache } from '../src/client/office/cache.ts'
+
+const file = { sessionId: 's1' as SessionId, path: 'report.docx' }
+const result = (version = 'v1', text = 'pdf!', generation = 'renderer'): Awaited<ReturnType<ReadOfficeDocument>> => ({
+  ok: true, value: { absolutePath: '/report.docx', version, offset: 0, eof: true, missingFonts: [], data: new TextEncoder().encode(text), generation: (generation as OfficeToPdfGeneration) },
+})
+function harness(entries = 2, bytes = 32, pending = 8, readers = 32) {
+  const stat = vi.fn().mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
+  const convert = vi.fn<import('../src/client/office/cache.ts').ReadOfficeBytes>().mockResolvedValue(result())
+  const generation = vi.fn().mockResolvedValue({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
+  const cache = new OfficePreviewCache(stat, convert, entries, bytes, pending, readers, generation, () => new Error('busy'))
+  return { stat, convert, cache, generation, read: (signal = new AbortController().signal) => cache.read(file, signal) }
+}
+
+it('shares a pending conversion between readers and reauthorizes cached reads', async () => {
+  const h = harness()
+  const started = Promise.withResolvers<AbortSignal>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise })
+  const background = new AbortController()
+  const first = h.read(background.signal)
+  const aborted = expect(first).rejects.toMatchObject({ name: 'AbortError' })
+  const conversionSignal = await started.promise
+  const second = h.read()
+  await vi.waitFor(() => { expect(h.stat).toHaveBeenCalledTimes(2) })
+  background.abort()
+  await aborted
+  expect(conversionSignal.aborted).toBe(false)
+  completed.resolve(result())
+  expect(await second).toEqual(result())
+  expect(await h.read()).toEqual(result())
+  expect(h.convert).toHaveBeenCalledOnce()
+  expect(h.stat).toHaveBeenCalledTimes(3)
+  await h.cache.dispose()
+})
+
+it('rejects stale or unauthorized reuse and never caches a declared conversion failure', async () => {
+  const h = harness()
+  await h.read()
+  h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'v2' } })
+  const failure = { ok: false, error: { message: 'failed' } } as Awaited<ReturnType<ReadOfficeDocument>>
+  h.convert.mockResolvedValueOnce(failure).mockResolvedValue(result('v2'))
+  expect(await h.read()).toEqual(failure)
+  expect(await h.read()).toEqual(result('v2'))
+  h.stat.mockResolvedValue(failure)
+  expect(await h.read()).toEqual(failure)
+  expect(h.convert).toHaveBeenCalledTimes(3)
+  await h.cache.dispose()
+})
+
+it('discards a cancelled conversion that finishes late without replacing a newer result', async () => {
+  const h = harness()
+  const started = Promise.withResolvers<undefined>()
+  const late = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementationOnce(() => { started.resolve(undefined); return late.promise })
+  const caller = new AbortController()
+  const pending = h.read(caller.signal)
+  const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
+  await started.promise
+  caller.abort()
+  await rejected
+  h.convert.mockResolvedValue(result('v1', 'new'))
+  await h.read()
+  late.resolve(result('v1', 'old'))
+  await late.promise
+  expect(await h.read()).toEqual(result('v1', 'new'))
+  await h.cache.dispose()
+})
+
+it('bounds retained entries and byte storage, and separates Session authorization scopes', async () => {
+  const h = harness(1, 8)
+  await h.read()
+  await h.cache.read({ ...file, sessionId: 's2' as SessionId }, new AbortController().signal)
+  await h.read()
+  expect(h.convert).toHaveBeenCalledTimes(3)
+  h.stat.mockResolvedValue({ ok: true, value: { absolutePath: '/report.docx', version: 'large' } })
+  h.convert.mockResolvedValue(result('large', 'x'.repeat(10)))
+  await h.read()
+  await h.read()
+  expect(h.convert).toHaveBeenCalledTimes(5)
+  await h.cache.dispose()
+})
+
+it('does not retain a conversion whose source changed during conversion', async () => {
+  const h = harness()
+  h.convert.mockResolvedValue(result('v2'))
+  await h.read()
+  await h.read()
+  expect(h.convert).toHaveBeenCalledTimes(2)
+  await h.cache.dispose()
+})
+
+it('reuses the borrowed binary PDF at the exact byte budget without copying', async () => {
+  const h = harness(2, 4)
+  const converted = result()
+  h.convert.mockResolvedValue(converted)
+  try {
+    expect(await h.read()).toBe(converted)
+    const cached = await h.read()
+    expect(cached).toBe(converted)
+    if (!cached.ok || !converted.ok) throw new Error('Expected PDF bytes')
+    expect(cached.value.data).toBe(converted.value.data)
+    expect(cached.value.data.byteLength).toBe(4)
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { await h.cache.dispose() }
+})
+
+it('does not retain a different canonical source even when its version token matches', async () => {
+  const h = harness()
+  h.convert.mockResolvedValue({ ok: true, value: {
+    absolutePath: '/replacement.docx', version: 'v1', offset: 0, eof: true, missingFonts: [], generation: 'renderer' as OfficeToPdfGeneration, data: new Uint8Array([1]),
+  } })
+  try {
+    await h.read()
+    await h.read()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally { await h.cache.dispose() }
+})
+
+it('cancels before source authorization and after a delayed stat without starting conversion', async () => {
+  const h = harness()
+  try {
+    await expect(h.read(AbortSignal.abort())).rejects.toMatchObject({ name: 'AbortError' })
+    expect(h.stat).not.toHaveBeenCalled()
+    const metadata = Promise.withResolvers<Awaited<ReturnType<typeof h.stat>>>()
+    h.stat.mockReturnValue(metadata.promise)
+    const caller = new AbortController()
+    const pending = h.read(caller.signal)
+    const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
+    caller.abort()
+    metadata.resolve({ ok: true, value: { absolutePath: '/report.docx', version: 'v1' } })
+    await rejected
+    expect(h.convert).not.toHaveBeenCalled()
+  } finally { await h.cache.dispose() }
+})
+
+it.each([new Error('transport'), 'transport'])('retries rejected conversions (%s) and waits for cancelled Host work when disposed', async (failure) => {
+  const h = harness()
+  const started = Promise.withResolvers<AbortSignal>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  try {
+    h.convert.mockRejectedValueOnce(failure)
+    await expect(h.read()).rejects.toMatchObject(failure instanceof Error ? { message: 'transport' } : {
+      message: 'Office preview failed', cause: failure,
+    })
+    h.convert.mockImplementation((_file, signal) => { started.resolve(signal); return completed.promise })
+    const pending = h.read()
+    const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' })
+    const signal = await started.promise
+    let disposed = false
+    const disposing = h.cache.dispose().then(() => { disposed = true })
+    await rejected
+    expect(signal.aborted).toBe(true)
+    expect(disposed).toBe(false)
+    completed.resolve(result())
+    await disposing
+    await expect(h.read()).rejects.toMatchObject({ name: 'AbortError' })
+  } finally {
+    completed.resolve(result())
+    await h.cache.dispose()
+  }
+})
+
+it('preserves an AbortSignal cancellation reason while waiting for the shared conversion to settle', async () => {
+  const h = harness()
+  const caller = new AbortController()
+  const started = Promise.withResolvers<undefined>()
+  const completion = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementation(() => { started.resolve(undefined); return completion.promise })
+  const pending = h.read(caller.signal)
+  const rejected = expect(pending).rejects.toMatchObject({ message: 'Office preview cancelled', cause: 'left document' })
+  try {
+    await started.promise
+    caller.abort('left document')
+    await rejected
+  } finally {
+    completion.resolve(result())
+    await h.cache.dispose()
+  }
+})
+
+it('clears Client reuse when the Host renderer generation changes', async () => {
+  const h = harness()
+  try {
+    await h.read()
+    h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
+    h.convert.mockResolvedValue(result('v1', 'pdf!', 'replacement'))
+    await h.read()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+    await h.read()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally { await h.cache.dispose() }
+})
+
+it('bounds metadata readers and unsettled conversion RPCs including cancellation teardown', async () => {
+  const h = harness(2, 32, 1, 2)
+  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
+  const caller = new AbortController()
+  const first = expect(h.read(caller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+  await entered.promise
+  caller.abort()
+  await first
+  try {
+    await expect(h.read()).rejects.toThrow('busy')
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { completed.resolve(result()); await h.cache.dispose() }
+})
+
+it('sends foreground intent to the Host when joining an in-flight prewarm', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<undefined>(), completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementation(() => { entered.resolve(undefined); return completed.promise })
+  const prewarm = h.cache.read(file, new AbortController().signal, 'background')
+  await entered.promise
+  const foreground = h.cache.read(file, new AbortController().signal, 'foreground')
+  try {
+    await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) })
+    expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground'])
+  } finally { completed.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() }
+})
+
+it('counts unresolved renderer metadata against the reader limit and joins it on disposal', async () => {
+  const h = harness(2, 32, 8, 1)
+  const completed = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
+  h.generation.mockReturnValueOnce(completed.promise)
+  const first = expect(h.read()).rejects.toMatchObject({ name: 'AbortError' })
+  await expect(h.read()).rejects.toThrow('busy')
+  expect(h.generation).toHaveBeenCalledOnce()
+  let disposed = false
+  const closing = h.cache.dispose().then(() => { disposed = true })
+  expect(disposed).toBe(false)
+  completed.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
+  await first
+  await closing
+  expect(h.stat).not.toHaveBeenCalled()
+})
+
+it('passes renderer discovery failures through before source authorization and allows retry', async () => {
+  const h = harness()
+  const failure = { ok: false, error: new RemoteError('document-render/failed', 'Renderer unavailable', { reason: 'unavailable' }) }
+  try {
+    h.generation.mockResolvedValueOnce(failure)
+    expect(await h.read()).toBe(failure)
+    expect(h.stat).not.toHaveBeenCalled()
+    expect(h.convert).not.toHaveBeenCalled()
+    expect(await h.read()).toEqual(result())
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally { await h.cache.dispose() }
+})
+
+it('restarts a stale generation query without invalidating the current renderer', async () => {
+  const h = harness()
+  const current = { ok: true as const, value: ('current' as OfficeToPdfGeneration) }
+  const stale = { ok: true as const, value: ('stale' as OfficeToPdfGeneration) }
+  const converted = result('v1', 'pdf!', 'current')
+  h.convert.mockResolvedValue(converted)
+  const firstQuery = Promise.withResolvers<typeof current>()
+  const middleQuery = Promise.withResolvers<typeof current>()
+  h.generation.mockReturnValueOnce(firstQuery.promise).mockReturnValueOnce(middleQuery.promise).mockResolvedValue(current)
+  const earlier = h.read()
+  const outdated = h.read()
+  const restarted = expect(outdated).resolves.toEqual(converted)
+  try {
+    expect(await h.read()).toEqual(converted)
+    firstQuery.resolve(current)
+    expect(await earlier).toEqual(converted)
+    middleQuery.resolve(stale)
+    await restarted
+    expect(await h.read()).toEqual(converted)
+    expect(h.stat).toHaveBeenCalledTimes(4)
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally {
+    firstQuery.resolve(current)
+    middleQuery.resolve(stale)
+    await h.cache.dispose()
+  }
+})
+
+it('restarts a pending conversion when a newer renderer generation is accepted', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<AbortSignal>()
+  const completed = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementationOnce((_file, signal) => {
+    signal.addEventListener('abort', () => { completed.reject(signal.reason) }, { once: true })
+    entered.resolve(signal)
+    return completed.promise
+  })
+  const previous = h.read()
+  const converted = result('v1', 'pdf!', 'replacement')
+  const restarted = expect(previous).resolves.toEqual(converted)
+  try {
+    const signal = await entered.promise
+    h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
+    h.convert.mockResolvedValue(converted)
+    expect(await h.read()).toEqual(converted)
+    await restarted
+    expect(signal.aborted).toBe(true)
+    expect(h.convert).toHaveBeenCalledTimes(2)
+    await h.read()
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally {
+    completed.resolve(result())
+    await h.cache.dispose()
+  }
+})
+
+it('reauthorizes source metadata that completes after the renderer generation changes', async () => {
+  const h = harness()
+  const entered = Promise.withResolvers<undefined>()
+  const metadata = { ok: true, value: { absolutePath: '/report.docx', version: 'v1' } }
+  const completed = Promise.withResolvers<typeof metadata>()
+  h.stat.mockImplementationOnce(() => { entered.resolve(undefined); return completed.promise })
+  const previous = h.read()
+  const converted = result('v1', 'pdf!', 'replacement')
+  const restarted = expect(previous).resolves.toEqual(converted)
+  try {
+    await entered.promise
+    h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
+    h.convert.mockResolvedValue(converted)
+    expect(await h.read()).toEqual(converted)
+    completed.resolve(metadata)
+    await restarted
+    expect(h.convert).toHaveBeenCalledOnce()
+  } finally {
+    completed.resolve(metadata)
+    await h.cache.dispose()
+  }
+})
+
+it.each([[1, 32], [8, 1]])('leaves foreground admission available when pending/readers are %i/%i', async (pending, readers) => {
+  const h = harness(2, 32, pending, readers)
+  try {
+    await expect(h.cache.read(file, new AbortController().signal, 'background')).rejects.toThrow('busy')
+    expect(h.convert).not.toHaveBeenCalled()
+    expect(await h.read()).toEqual(result())
+  } finally { await h.cache.dispose() }
+})
+
+it.each([[2, 32], [8, 2]])('reserves the final pending/reader slot for foreground promotion with %i/%i', async (pending, readers) => {
+  const h = harness(2, 32, pending, readers)
+  h.stat.mockImplementation(async (requested: SessionFile) => ({ ok: true, value: { absolutePath: `/${requested.path}`, version: 'v1' } }))
+  const entered = Promise.withResolvers<undefined>(), complete = Promise.withResolvers<Awaited<ReturnType<ReadOfficeDocument>>>()
+  h.convert.mockImplementation(() => { entered.resolve(undefined); return complete.promise })
+  const prewarm = h.cache.read(file, new AbortController().signal, 'background')
+  await entered.promise
+  let foreground: ReturnType<typeof h.read> | undefined
+  try {
+    await expect(h.cache.read({ ...file, path: 'other.docx' }, new AbortController().signal, 'background')).rejects.toThrow('busy')
+    foreground = h.read()
+    await vi.waitFor(() => { expect(h.convert).toHaveBeenCalledTimes(2) })
+    expect(h.convert.mock.calls.map(call => call[2])).toEqual(['background', 'foreground'])
+  } finally { complete.resolve(result()); await Promise.all([prewarm, foreground]); await h.cache.dispose() }
+})
+
+it('reports repeated generation supersession through the localized capacity failure', async () => {
+  const h = harness()
+  const first = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
+  const retry = Promise.withResolvers<{ ok: true; value: ReturnType<typeof OfficeToPdfGeneration> }>()
+  h.generation.mockReturnValueOnce(first.promise)
+  const work = expect(h.read()).rejects.toThrow('busy')
+  try {
+    await h.read()
+    h.generation.mockReturnValueOnce(retry.promise)
+    first.resolve({ ok: true, value: ('obsolete' as OfficeToPdfGeneration) })
+    await vi.waitFor(() => { expect(h.generation).toHaveBeenCalledTimes(3) })
+    h.generation.mockResolvedValue({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
+    await h.read()
+    retry.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
+    await work
+    expect(h.generation).toHaveBeenCalledTimes(4)
+  } finally {
+    first.resolve({ ok: true, value: ('renderer' as OfficeToPdfGeneration) })
+    retry.resolve({ ok: true, value: ('replacement' as OfficeToPdfGeneration) })
+    await h.cache.dispose()
+  }
+})
+
+it('does not retain a PDF returned by a generation newer than the preceding metadata query', async () => {
+  const h = harness()
+  const converted = result('v1', 'pdf!', 'replacement')
+  h.convert.mockResolvedValue(converted)
+  try {
+    expect(await h.read()).toBe(converted)
+    expect(await h.read()).toBe(converted)
+    expect(h.convert).toHaveBeenCalledTimes(2)
+  } finally { await h.cache.dispose() }
+})

+ 84 - 0
packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx

@@ -0,0 +1,84 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { FontNotice, type FontNoticeProps } from '../src/client/office/FontNotice.tsx'
+import { en, zh } from '../src/client/office/locales.ts'
+
+beforeEach(() => {
+  vi.stubGlobal('ResizeObserver', class {
+    observe(): void {}
+    disconnect(): void {}
+  })
+})
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+
+function props(overrides: Partial<FontNoticeProps> = {}): FontNoticeProps {
+  return {
+    resourceAddress: 'dsh-resource://file/session/s-1/report.docx', sourceVersion: 'v1',
+    fonts: ['Consolas', 'Missing Serif'],
+    t: makeTranslate(en), ...overrides,
+  }
+}
+
+it('opens details, restores focus on Escape or close, and dismisses outside without stealing focus', () => {
+  render(<FontNotice {...props()} />)
+  const more = screen.getByRole('button', { name: en.showMore })
+  fireEvent.click(more)
+  const dialog = screen.getByRole('dialog', { name: en.missingFontsTitle })
+  expect(dialog.textContent).toContain('Consolas')
+  expect(dialog.textContent).toContain('Missing Serif')
+  expect(document.activeElement).toBe(dialog)
+  fireEvent.keyDown(dialog, { key: 'ArrowDown' })
+  expect(screen.getByRole('dialog')).toBe(dialog)
+  fireEvent.keyDown(dialog, { key: 'Escape' })
+  expect(screen.queryByRole('dialog')).toBeNull()
+  expect(document.activeElement).toBe(more)
+  fireEvent.click(more)
+  fireEvent.click(screen.getByRole('button', { name: en.closeDetails }))
+  expect(screen.getByRole('status').textContent).toContain('Consolas')
+  expect(document.activeElement).toBe(more)
+  fireEvent.click(more)
+  fireEvent.pointerDown(document.body)
+  expect(screen.queryByRole('dialog')).toBeNull()
+  fireEvent.click(more)
+  fireEvent.click(more)
+  expect(screen.queryByRole('dialog')).toBeNull()
+})
+
+it('keeps details open while focus moves inside them and closes when focus leaves the notice', () => {
+  render(<><FontNotice {...props()} /><button>Other action</button></>)
+  const more = screen.getByRole('button', { name: en.showMore })
+  fireEvent.click(more)
+  const dialog = screen.getByRole('dialog')
+  fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: en.closeDetails }) })
+  expect(screen.getByRole('dialog')).toBe(dialog)
+  fireEvent.blur(dialog, { relatedTarget: more })
+  expect(screen.getByRole('dialog')).toBe(dialog)
+  fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: 'Other action' }) })
+  expect(screen.queryByRole('dialog')).toBeNull()
+})
+
+it('hides the entire notice until another source version is loaded', () => {
+  const input = props()
+  const view = render(<FontNotice {...input} />)
+  fireEvent.click(screen.getByRole('button', { name: en.showMore }))
+  fireEvent.click(screen.getByRole('button', { name: en.dismissNotice }))
+  expect(screen.queryByRole('status')).toBeNull()
+  expect(screen.queryByRole('dialog')).toBeNull()
+  expect(screen.queryByRole('button', { name: en.showMore })).toBeNull()
+  view.rerender(<FontNotice {...input} />)
+  expect(screen.queryByRole('status')).toBeNull()
+  view.rerender(<FontNotice {...input} sourceVersion="v2" />)
+  expect(screen.getByRole('status')).toBeDefined()
+  expect(screen.queryByRole('dialog')).toBeNull()
+})
+
+it('has localized copy and does not reserve a notice for fonts that are available', () => {
+  const view = render(<FontNotice {...props({ t: makeTranslate(zh) })} />)
+  fireEvent.click(screen.getByRole('button', { name: zh.showMore }))
+  expect(screen.getByRole('dialog', { name: zh.missingFontsTitle })).toBeDefined()
+  view.rerender(<FontNotice {...props({ fonts: [] })} />)
+  expect(view.container.childElementCount).toBe(0)
+  expect(screen.queryByRole('dialog')).toBeNull()
+})

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä