From d0ddf939f080a5e2f12c9dfd4875f72b9b1b67ba Mon Sep 17 00:00:00 2001 From: Yudong Han Date: Mon, 21 Sep 2026 18:30:10 +0800 Subject: [PATCH 1/3] refactor(office): transfer PDFs as multipart bytes (#4830) * refactor(office): transfer PDFs as multipart bytes * fix(document-preview): address Office review feedback --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +-- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 17 ++++++++++ docs/subsystems/office-to-pdf.i18n.yaml | 4 +-- docs/subsystems/office-to-pdf.md | 6 ++-- docs/subsystems/office-to-pdf.zh.md | 6 ++-- .../web-app/tests/document-preview.spec.ts | 2 +- .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/office/index.ts | 11 ++----- .../src/client/rpc.ts | 12 ------- .../tests/document-bytes.client.spec.ts | 31 ------------------- .../tests/office-registration.client.spec.ts | 2 +- .../document/office-to-pdf/README.i18n.yaml | 4 +-- packages/document/office-to-pdf/README.md | 4 +-- packages/document/office-to-pdf/README.zh.md | 4 +-- packages/document/office-to-pdf/src/index.ts | 5 +-- packages/document/office-to-pdf/src/types.ts | 6 ++-- .../office-to-pdf/tests/remote.spec.ts | 4 +-- .../extensions/tool-cordis/src/api-catalog.ts | 4 +-- 22 files changed, 53 insertions(+), 85 deletions(-) delete mode 100644 packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 8e3e128348..eea688362d 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 36897178014daa839399adb5fdf72a4ca53cec8b -2026-09-11-node-office-kit.zh.md: 1255012bf6281abe9a95ac338480cf2424fcbe9a +2026-09-11-node-office-kit.md: 2c95b7e27e059f6aaee21970fb30fca9ba1bb11b +2026-09-11-node-office-kit.zh.md: bec5d6bb59f987f629ebfd81bb753eee121566f3 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 3689717801..2c95b7e27e 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for 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 are available from the document toolbar’s warning icon; 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. -Office responses use `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. +Office responses use Typert binary-result projection and Connection multipart framing. The Client receives one `ArrayBuffer`-backed `Uint8Array` without a base64 string or a separate decode buffer. The built browser scenario verifies the multipart attachment 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. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 1255012bf6..bec5d6bb59 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF [Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 -Office 响应使用 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 +Office 响应使用 Typert 二进制结果投影和 Connection multipart 封装。Client 直接收到一个由 `ArrayBuffer` 支撑的 `Uint8Array`,不会生成 base64 字符串或单独的解码缓冲区。构建后的浏览器场景会同时验证 multipart 附件和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 [kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index c20666b429..73a7cee14b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -990,7 +990,24 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { 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-textpreview-url]') + const pdfResponse = page.waitForResponse( + response => new URL(response.url()).pathname === '/api/officeToPdf/render', + { timeout: 60_000 }, + ) await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() + const officeTransfer = await pdfResponse + expect(officeTransfer.headers()['content-type']).toMatch(/^multipart\/form-data;/) + const officeBody = await new Response(new Uint8Array(await officeTransfer.body()), { headers: officeTransfer.headers() }).formData() + const officeMetadata = officeBody.get('metadata') + if (typeof officeMetadata !== 'string') throw new Error('missing Office PDF metadata') + const { attachments } = JSON.parse(officeMetadata) as { attachments: { path: (string | number)[]; codec: string; part: string }[] } + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ path: ['data'], codec: 'bytes' }) + const officeFile = officeBody.get(attachments[0]!.part) + if (officeFile === null || typeof officeFile === 'string') throw new Error('missing Office PDF payload') + const officePdf = Buffer.from(await officeFile.arrayBuffer()) + expect(officePdf.subarray(0, 5).toString('ascii')).toBe('%PDF-') + expect(officePdf.subarray(-1024).toString('ascii').trimEnd()).toMatch(/%%EOF$/u) 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 }) diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml index 200c1c670b..d6f78bacdb 100644 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ b/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: dd0f6392f264775f12c0f7612656f1c07366567d -office-to-pdf.zh.md: 5c99f87681dd8d98da3717a311a7f858d4b52b32 +office-to-pdf.md: a01478df546a8873126abbf0efcfd19f9aeb2a09 +office-to-pdf.zh.md: b81468f13011a8aaa117ae866d17f6184c5902b2 diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md index dd0f6392f2..a01478df54 100644 --- a/docs/subsystems/office-to-pdf.md +++ b/docs/subsystems/office-to-pdf.md @@ -29,9 +29,9 @@ The provider admits the deferred read before allocating source bytes, shares con ## Preview reads -`RenderedDocumentBytes` carries workspace file metadata, base64 PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. +`RenderedDocumentBytes` carries workspace file metadata, native PDF `data`, `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 `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 binary Remote projects the PDF into a multipart attachment and restores an `ArrayBuffer`-backed `Uint8Array` on the Client. 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. @@ -71,7 +71,7 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md index 5c99f87681..b81468f130 100644 --- a/docs/subsystems/office-to-pdf.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -29,9 +29,9 @@ ## 预览读取 -`RenderedDocumentBytes` 携带工作区文件元数据、base64 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 +`RenderedDocumentBytes` 携带工作区文件元数据、原生 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 -`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 +`officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。二进制 Remote 将 PDF 投影为 multipart 附件,并在 Client 恢复为由 `ArrayBuffer` 支撑的 `Uint8Array`。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 `api/remotes` 挂载转换服务生成的 Remote 描述符。共享文档预览包使用完整字节加载和现有 PDF.js Worker 注册 Office 格式。每次预览读取都会重新检查渲染 generation、源文件授权和版本,再共享进行中的转换或缓存 PDF。连接重置和插件卸载会取消请求并清空缓存字节。缺少服务时显示本地化配置引导。 @@ -71,7 +71,7 @@ convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index 8512a1c39f..0c7f0d5c77 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -105,7 +105,7 @@ it('loads the shipped Office rows with separately patched settings and authorize 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 }) + data: Uint8Array.from(pdf), 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() diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index fe86787ea2..d4aa914f70 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: b82a1ca3179c6a7bb6bf9a67369a4e877d229ed3 -README.zh.md: 4313b815c01c1d2d0d9b9669a2fb6274b0ca61f8 +README.md: 35aee8a2d643e875e27cf36c55e209b964434a2b +README.zh.md: 4e714946b1c0a2e61f21b9702cfd14f34606450c diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index b82a1ca317..35aee8a2d6 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -106,7 +106,7 @@ A yellow rounded-triangle warning appears before Reload in the document toolbar Office registration, loading, caching, and font notices live in `src/client/office/`. The injected Office face writes converted PDF bytes, font metadata, and failures through the declared store actions. The Office body triggers loading, binds cancellation to its lifecycle, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. -The `documentFileBytes()` helper decodes converted Office PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. +The Office Remote returns converted PDFs as native `Uint8Array` values through Connection's multipart binary transport. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 4313b815c0..4e714946b1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -106,7 +106,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别 Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注入的 Office face 通过已声明的 store action 写入转换后的 PDF 字节、字体元数据和失败。Office 正文触发加载,将取消绑定到自身生命周期,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 -`documentFileBytes()` 辅助函数将 Office 转换后的 PDF 响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 +Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8Array` PDF。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts index 34dcd60c83..6afe15f289 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/index.ts @@ -5,7 +5,6 @@ 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 { documentFileBytes } from '../rpc.ts' import { failureLine } from '../failure-line.ts' import { documentTabInfoFactory } from '../document/contract.ts' import { en, zh, type OfficePreviewKey } from './locales.ts' @@ -73,14 +72,10 @@ export function apply(ctx: Context, config: Config['office']): void { 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 + if (!result.ok && result.error.code === 'document-render/failed') { + throw new Error(t(conversionErrorKey(result.error.details.reason)), { cause: result.error }) } - return { ok: true, value: { ...documentFileBytes(result.value), - missingFonts: result.value.missingFonts, generation: result.value.generation } } + return result } const createCache = () => new OfficePreviewCache( async (file, signal) => { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts index 8289374cdc..5b52d4cc68 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/rpc.ts @@ -91,15 +91,3 @@ export type DocumentFileBytes = WorkspaceFileBytes> * @returns complete native bytes, including declared failures. */ export type ReadDocumentBytes = (file: SessionFile, signal: AbortSignal) => Promise> - -/** - * Decode the Office conversion Remote's base64 PDF response. - * @param file - converted PDF metadata and base64 contents. - * @returns the same metadata with native bytes; malformed base64 throws. - */ -export function documentFileBytes(file: Omit & { readonly data: string }): DocumentFileBytes { - 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 } -} diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts deleted file mode 100644 index 209f8f8970..0000000000 --- a/packages/client/ui-sidebar-documentpreview/tests/document-bytes.client.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -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)) -}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index bed2275f85..29b1626518 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -23,8 +23,8 @@ vi.mock('../src/client/office/face.ts', async (importOriginal) => { const file = { sessionId: 's1' as SessionId, path: 'report.DOCX' } const generation = ('renderer' as OfficeToPdfGeneration) -const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: 'JVBERg==' } const pdf = new Uint8Array([37, 80, 68, 70]) +const source = { absolutePath: '/report.docx', version: 'v1', offset: 0, eof: true, bytes: 4, data: pdf } const converted = { ok: true as const, value: { ...source, generation, missingFonts: ['Missing Serif'] } } it('retains Office view state across remounts and releases it on tab close or plugin disposal', async () => { diff --git a/packages/document/office-to-pdf/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml index 482d1cb2a2..b298d219f5 100644 --- a/packages/document/office-to-pdf/README.i18n.yaml +++ b/packages/document/office-to-pdf/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/document/office-to-pdf/README.md -README.md: e6017e121566b248026e9c8f9573afa1d402fd75 -README.zh.md: b00f9c7dbacf72bd9beb71c644970fcc0bcf0528 +README.md: 6655ccff204e33848589468326fc8df7e4d1a07b +README.zh.md: e3aa43a42780378439812874a8eaf178e9c153b9 diff --git a/packages/document/office-to-pdf/README.md b/packages/document/office-to-pdf/README.md index e6017e1215..6655ccff20 100644 --- a/packages/document/office-to-pdf/README.md +++ b/packages/document/office-to-pdf/README.md @@ -31,7 +31,7 @@ Callers submit authorized source identity, version, optional byte size, a deferr The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. -Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry base64 PDF bytes, missing fonts, and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor. +Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry native PDF bytes through the binary Remote multipart transport, plus missing fonts and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor. | Field | Default | Meaning | |---|---|---| @@ -50,7 +50,7 @@ The provider retains successful PDFs by converter generation, Office extension, Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. -The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, base64 transport expansion, caller-retained results, or PDF.js page rendering. +The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, multipart framing, caller-retained results, or PDF.js page rendering. ----- diff --git a/packages/document/office-to-pdf/README.zh.md b/packages/document/office-to-pdf/README.zh.md index b00f9c7dba..e3aa43a427 100644 --- a/packages/document/office-to-pdf/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 -浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,携带 base64 PDF、缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。 +浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,通过二进制 Remote 的 multipart 传输携带原生 PDF 字节,并携带缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。 | 字段 | 默认值 | 含义 | |---|---|---| @@ -50,7 +50,7 @@ kind: "package-reference" 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 -默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、base64 传输膨胀、调用方保留的结果或 PDF.js 页面渲染。 +默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、multipart 封装、调用方保留的结果或 PDF.js 页面渲染。 ----- diff --git a/packages/document/office-to-pdf/src/index.ts b/packages/document/office-to-pdf/src/index.ts index f8d3b993c9..8131f08fcf 100644 --- a/packages/document/office-to-pdf/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -154,7 +154,7 @@ export class OfficeToPdf extends TypertRemoteService { * @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. + * @returns complete PDF bytes with original source identity and missing font families. */ @Remote async render( @@ -219,7 +219,8 @@ export class OfficeToPdf extends TypertRemoteService { } }, signal) signal.throwIfAborted() return { absolutePath: source.absolutePath, version: source.version, - offset: 0, eof: true, bytes: result.pdf.byteLength, data: Buffer.from(result.pdf).toString('base64'), missingFonts: result.missingFonts, generation: result.generation } + offset: 0, eof: true, bytes: result.pdf.byteLength, data: result.pdf, + missingFonts: result.missingFonts, generation: result.generation } } catch (cause) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'The document preview was cancelled.', {}, { cause }) if (cause instanceof OfficeToPdfError) { diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts index 5138dad52c..d5f6f438db 100644 --- a/packages/document/office-to-pdf/src/types.ts +++ b/packages/document/office-to-pdf/src/types.ts @@ -44,10 +44,8 @@ export type OfficeToPdfErrorCode = | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' -/** PDF content carries the original Office file's absolute path and version. */ -export interface RenderedDocumentBytes extends Omit { - /** Base64 PDF contents carried by the JSON Remote response. */ - readonly data: string +/** PDF content carries the original Office file's identity and multipart-transferred bytes. */ +export interface RenderedDocumentBytes extends WorkspaceFileBytes { readonly missingFonts: string[] readonly generation: OfficeToPdfGeneration } diff --git a/packages/document/office-to-pdf/tests/remote.spec.ts b/packages/document/office-to-pdf/tests/remote.spec.ts index 4029f3ea74..2f12444248 100644 --- a/packages/document/office-to-pdf/tests/remote.spec.ts +++ b/packages/document/office-to-pdf/tests/remote.spec.ts @@ -11,7 +11,7 @@ import OfficeToPdf from '../src/index.ts' const scope = { sessionId: SessionId('document-test'), workspaceRoot: '/workspace' } const source = { absolutePath: '/workspace/report.DOCX', version: 'source-v1', bytes: 4 } const rawSource = { ...source, data: new Uint8Array([80, 75, 3, 4]) } -const wireSource = { ...source, offset: 0, eof: true, data: 'UEsDBA==' } +const wireSource = { ...source, offset: 0, eof: true, data: rawSource.data } let generation: OfficeToPdf['generation'] const cacheKey = OfficeToPdfKey('test-result') const pdf = new Uint8Array([37, 80, 68, 70, 45]) @@ -51,7 +51,7 @@ it.each(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])('converts authorized %s b expect(authorize).toHaveBeenCalledExactlyOnceWith(scope, path, { range: { offset: 0, length: 1 } }, expect.any(AbortSignal)) expect(render).toHaveBeenCalledOnce() expect(render.mock.calls[0]?.[0]).toMatchObject({ extension, priority: 'foreground', source: { version: source.version, bytes: 4 } }) - expect(result).toEqual({ ...wireSource, data: Buffer.from(pdf).toString('base64'), bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) + expect(result).toEqual({ ...wireSource, data: pdf, bytes: pdf.length, missingFonts: ['Missing Serif'], generation }) expect(ctx.get('agents')).toBeUndefined() }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 82789f5647..f2fdab770b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1452,7 +1452,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote async render( workspaceFileScope: WorkspaceFileScope, path: string, priority: OfficeToPdfPriority, signal: AbortSignal, ): Promise', description: 'Read and convert one Office file using the Session\'s ordinary filesystem authorization.', parameters: [{ name: 'workspaceFileScope', description: 'Session header lookup shared with workspaceFiles.' }, { name: 'path', description: 'absolute or workspace-relative Office path.' }, { name: 'priority', description: 'foreground preview or speculative background work.' }, { name: 'signal', description: 'Remote cancellation; disposal also cancels outstanding reads and conversions.' }], - returns: 'complete base64 PDF with original source identity and missing font families.', + returns: 'complete PDF bytes with original source identity and missing font families.', }, { signature: '@Remote(\'generation\') getGeneration(signal: AbortSignal): OfficeToPdfGeneration', @@ -5760,7 +5760,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RenderedDocumentBytes', - declaration: 'export interface RenderedDocumentBytes extends Omit {\n readonly data: string;\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', + declaration: 'export interface RenderedDocumentBytes extends WorkspaceFileBytes {\n readonly missingFonts: string[];\n readonly generation: OfficeToPdfGeneration;\n}', }, { name: 'ReplayEnvelope', From f65c15a5cf406465742f6ab7ba1fdad91fda3ead Mon Sep 17 00:00:00 2001 From: Yudong Han Date: Mon, 21 Sep 2026 18:49:45 +0800 Subject: [PATCH 2/3] feat(preview): unify document zoom controls (#4785) --- apps/web/tests/document-preview.e2e.ts | 102 ++++++++- apps/web/tests/expected/office-font-notice.md | 2 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 9 +- .../ui-sidebar-documentpreview/README.zh.md | 9 +- .../src/client/image/ImageBody.module.css | 22 +- .../src/client/image/ImageBody.tsx | 78 ++++--- .../src/client/image/index.ts | 15 +- .../src/client/image/locales.ts | 4 + .../src/client/office/OfficeBody.module.css | 6 - .../src/client/office/OfficeBody.tsx | 10 +- .../src/client/pdf/PdfBody.module.css | 11 +- .../src/client/pdf/index.ts | 3 + .../src/client/pdf/locales.ts | 4 + .../src/client/pdf/pdf.tsx | 42 +++- .../src/client/pdf/store.ts | 13 +- .../src/client/zoom/ZoomControls.module.css | 48 ++++ .../src/client/zoom/ZoomControls.tsx | 78 +++++++ .../src/client/zoom/ZoomViewport.module.css | 30 +++ .../src/client/zoom/ZoomViewport.tsx | 212 ++++++++++++++++++ .../src/client/zoom/locales.ts | 22 ++ .../src/client/zoom/store.ts | 43 ++++ .../src/client/zoom/types.ts | 29 +++ .../tests/image-body.client.spec.tsx | 52 ++++- .../tests/image-registration.client.spec.ts | 33 ++- .../tests/pdf-body.client.spec.tsx | 129 ++++++++++- .../tests/pdf-registration.client.spec.ts | 8 +- .../tests/zoom-store.client.spec.ts | 19 ++ .../tests/zoom-viewport.client.spec.tsx | 164 ++++++++++++++ .../web/document-preview/document.expected.md | 8 + 30 files changed, 1111 insertions(+), 98 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/locales.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/store.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/zoom-store.client.spec.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/zoom-viewport.client.spec.tsx diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 73a7cee14b..07e7397d12 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -83,6 +83,26 @@ async function openPreviewFile(column: Locator, filesTab: Locator, preview: Loca await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) } +/** Move into the bottom reveal zone and wait for the shared zoom control. */ +async function revealDocumentZoom(page: Page, preview: Locator): Promise { + const frame = preview.locator('[data-document-zoom-frame]') + const bounds = await frame.boundingBox() + if (bounds === null) throw new Error('document zoom frame has no bounds') + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height - 8) + const controls = preview.locator('[data-document-zoom-controls]') + await expect.poll(() => controls.getAttribute('data-document-zoom-visible')).toBe('true') + return preview.getByRole('button', { name: 'Choose zoom', exact: true }) +} + +/** Leave the bottom reveal zone and wait for the delayed dismissal. */ +async function hideDocumentZoom(page: Page, preview: Locator): Promise { + const frame = preview.locator('[data-document-zoom-frame]') + const bounds = await frame.boundingBox() + if (bounds === null) throw new Error('document zoom frame has no bounds') + await page.mouse.move(bounds.x + 8, bounds.y + 8) + await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull() +} + describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser @@ -468,7 +488,25 @@ else process.exit(1); const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) await canvas.waitFor({ state: 'visible', timeout: 30_000 }) expect(await viewer.count()).toBe(0) - expect(await preview.locator('[role="toolbar"]').count()).toBe(0) + const zoomControls = preview.locator('[data-document-zoom-controls]') + await expect.poll(() => zoomControls.getAttribute('data-document-zoom-visible')).toBeNull() + let pdfZoom = await revealDocumentZoom(page, preview) + await hideDocumentZoom(page, preview) + pdfZoom = await revealDocumentZoom(page, preview) + const pdfWidth = (await canvas.boundingBox())!.width + const pdfIntrinsicWidth = await canvas.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--pdf-page-width'))) + await expect.poll(() => pdfZoom.innerText()).toBe(`${String(Math.round(pdfWidth / pdfIntrinsicWidth * 100))}%`) + await pdfZoom.click() + await page.getByRole('menuitem', { name: '100%', exact: true }).click() + await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfIntrinsicWidth, 0) + pdfZoom = await revealDocumentZoom(page, preview) + await pdfZoom.click() + await page.getByRole('menuitem', { name: '150%', exact: true }).click() + await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1) + pdfZoom = await revealDocumentZoom(page, preview) + await pdfZoom.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0) expect(await preview.locator('[data-pdf-page]').count()).toBe(2) await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') const firstColor = await canvasColor(canvas) @@ -501,6 +539,8 @@ else process.exit(1); `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`, `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, + '- Zoom reveal: hidden -> bottom hover -> delayed hidden', + '- Zoom modes: fit width -> 100% -> 150% -> fit width', `- 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)}`, @@ -613,6 +653,16 @@ else process.exit(1); }) expect(centering.horizontal).toBeLessThan(10) expect(centering.vertical).toBeLessThan(10) + let imageZoom = await revealDocumentZoom(page, preview) + await expect.poll(() => imageZoom.innerText()).toBe('100%') + const tinyWidth = (await tinyImage.boundingBox())!.width + await imageZoom.click() + await page.getByRole('menuitem', { name: '200%', exact: true }).click() + await expect.poll(async () => (await tinyImage.boundingBox())!.width / tinyWidth).toBeCloseTo(2, 1) + imageZoom = await revealDocumentZoom(page, preview) + await imageZoom.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expect.poll(() => imageZoom.innerText()).toBe('100%') await openFile('large.svg') await expect.poll(() => viewer.innerText()).toBe('Image') @@ -640,12 +690,30 @@ else process.exit(1); // Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds. expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1) expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2) - const scrolled = await body.evaluate((node) => { + let svgZoom = await revealDocumentZoom(page, preview) + await expect.poll(async () => Number.parseInt(await svgZoom.innerText(), 10)).toBeCloseTo(fitted.width / 12, 0) + const imageScrollport = preview.locator('[data-document-zoom-scrollport]') + const fitScrolled = await imageScrollport.evaluate((node) => { node.scrollLeft = node.scrollWidth return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth } }) - expect(scrolled).toEqual({ left: 0, horizontalOverflow: false }) + expect(fitScrolled).toEqual({ left: 0, horizontalOverflow: false }) + await svgZoom.click() + await page.getByRole('menuitem', { name: '100%', exact: true }).click() + await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0) + expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true) + svgZoom = await revealDocumentZoom(page, preview) + await svgZoom.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(fitted.width, 0) + expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(false) expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull() + sections.push([ + '## Image zoom', '', + '- Small PNG fit width remains at intrinsic size; 200% doubles it', + '- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false', + '- Image and Blob identities remain stable while zoom changes', + ].join('\n')) const releaseRead = Promise.withResolvers() let waitingForRead = false @@ -957,7 +1025,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { 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)), + extraOverlayPath: [ + fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)), + fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + ], }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -1027,6 +1098,27 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await copyPdfText(page, preview, 'Office preview') await copyPdfText(page, preview, '中文文档') expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档') + const zoomMenu = await revealDocumentZoom(page, preview) + const initialWidth = (await canvas.boundingBox())!.width + const intrinsicWidth = await canvas.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--pdf-page-width'))) + const fitPercent = `${String(Math.round(initialWidth / intrinsicWidth * 100))}%` + await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent) + await zoomMenu.click() + await page.getByRole('menuitem', { name: '150%', exact: true }).click() + await expect.poll(() => zoomMenu.innerText()).toBe('150%') + await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1) + const zoomScrollport = preview.locator('[data-document-zoom-scrollport]') + await zoomScrollport.evaluate((node) => { + const bounds = node.getBoundingClientRect() + node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, + deltaY: -10, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 })) + }) + await expect.poll(() => zoomMenu.innerText()).toBe('166%') + await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1) + await zoomMenu.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent) + await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) expect(convert).toHaveBeenCalledTimes(1) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() await canvas.waitFor({ state: 'visible' }) @@ -1078,6 +1170,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { '- Requested absent family is listed: true', '- Escape restores focus to the warning: true', '- Closing details preserves the warning and document position: true', + '- Fit width, presets, and pinch resize the Office PDF continuously: true', + '- Pinch updates the displayed percentage during the gesture: 166%', `- Document top inset: ${topInset}px`, ].join('\n'), MODE) await successShot(page, 'office-docx') diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 21b132dd58..7e2deb69b1 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -6,4 +6,6 @@ - Requested absent family is listed: true - Escape restores focus to the warning: true - Closing details preserves the warning and document position: true +- Fit width, presets, and pinch resize the Office PDF continuously: true +- Pinch updates the displayed percentage during the gesture: 166% - Document top inset: 0px diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index d4aa914f70..d46092d14f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 35aee8a2d643e875e27cf36c55e209b964434a2b -README.zh.md: 4e714946b1c0a2e61f21b9702cfd14f34606450c +README.md: 510e43fb723039b918672a58ec2bd26cf3b6023a +README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 35aee8a2d6..510e43fb72 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -57,9 +57,9 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. -PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` 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. +PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. 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. 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. +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. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. @@ -85,7 +85,7 @@ The read-only formula bar displays formulas and cell text literally. Copying pre ## Office preview -Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. +Open `.doc`, `.docx`, `.ppt`, and `.pptx` as PDF previews with the same loading state, zoom controls, cancellation, and selectable text as PDF files. The [Host provider](../../document/office-to-pdf/README.md) performs local conversion and retains its spreadsheet conversion API for other consumers. Invalid supported files, conversion failures, and timeouts receive localized messages. Missing Host services show configuration guidance. The [Web bundle](../../bundle/web-app/README.md) mounts this package as `ui-sidebar-documentpreview`. Configure its transient Office cache through that entry's `office` settings; the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-client-ui-sidebar-documentpreview) defines accepted values. Settings are embedded in each served page; reload the browser page after changing YAML. @@ -130,7 +130,8 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. - **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. -- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context. +- **PDF zoom does not increase raster resolution.** PDF pages render at 96 DPI times the device pixel ratio, subject to the existing 16-million-pixel canvas limit, and fixed zoom enlarges that bitmap. High percentages can therefore soften page text; viewport-near rerendering at the settled zoom remains deferred. +- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow 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. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. - **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 4e714946b1..5bd9fba7c7 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -57,9 +57,9 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 -PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 +PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 -共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。 +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。 首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 @@ -85,7 +85,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别 ## Office 预览 -将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 +将 `.doc`、`.docx`、`.ppt` 和 `.pptx` 打开为 PDF 预览,使用与 PDF 文件相同的加载状态、缩放控件、取消和文本选择能力。[Host 提供方](../../document/office-to-pdf/README.zh.md)负责本地转换,并为其他消费者保留电子表格转换 API。受支持的无效文件、转换失败和超时会显示本地化消息。缺少 Host 服务时显示配置引导。 [Web bundle](../../bundle/web-app/README.zh.md) 以 `ui-sidebar-documentpreview` 挂载本包。通过该条目的 `office` 设置配置临时 Office 缓存;[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-client-ui-sidebar-documentpreview)定义可接受的值。设置注入到每个页面;修改 YAML 后重新加载浏览器页面。 @@ -130,7 +130,8 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 - **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 -- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 +- **PDF 缩放不会提高栅格分辨率。** PDF 页面按 96 DPI 乘以设备像素比绘制,并受现有 1600 万像素 Canvas 上限约束;固定缩放会放大这张位图,因此高比例下页面文字可能变虚。按稳定缩放比例重绘视口附近页面仍属于延期工作。 +- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 - **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css index 408c298681..746da8af3c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css @@ -5,6 +5,7 @@ display: flex; box-sizing: border-box; width: 100%; + min-width: 100%; height: max-content; min-height: 100%; padding: 12px; @@ -14,20 +15,23 @@ /* A wider image shrinks to the pane's width at its aspect ratio; a smaller one keeps its intrinsic size. The auto margins centre it either way. */ -.image { +.surface { display: block; flex: none; - width: auto; - max-width: 100%; - height: auto; - max-height: none; + max-width: none; margin: auto; - border-radius: 8px; - user-select: none; } -.image[hidden] { - display: none; +.surface[hidden] { display: none; } + +.image { + display: block; + width: 100%; + max-width: none; + height: auto; + max-height: none; + border-radius: 8px; + user-select: none; } .status { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx index 2f0ac09119..2517ab7fcf 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx @@ -1,10 +1,13 @@ -/** Complete image bytes rendered rounded within an inset frame, scaled down to the pane's width. */ -import { useEffect, useMemo, useState, type ReactNode } from 'react' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +/** Complete image bytes rendered in a shared zoom viewport. */ +import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { DocumentPreviewProps } from '../document/contract.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' import { hostFileOf } from '../rpc.ts' +import { ZoomViewport, zoomSurfaceClass } from '../zoom/ZoomViewport.tsx' +import { DEFAULT_ZOOM, type ZoomInjected, type ZoomStore } from '../zoom/store.ts' +import type { ZoomLabels, ZoomPreference } from '../zoom/types.ts' import type {} from './locales.ts' import css from './ImageBody.module.css' @@ -22,7 +25,7 @@ const IMAGE_MEDIA_TYPES = { type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES] /** Standard document props plus the image renderer's dictionary. */ -export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> +export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore & ZoomInjected type ImageSource = | { @@ -46,17 +49,23 @@ export function imageMediaType(path: string): ImageMediaType | undefined { } /** - * Present complete image bytes fitted to the pane's width. + * Present complete image bytes with fit-width and fixed-scale viewing. * @param props - document bytes, resource identity, and locale. - * @returns a rounded image scaled down to the pane's width at its aspect - * ratio — a smaller image centres at its intrinsic size — whose containing - * document body provides vertical scrolling. + * @returns a rounded image fitted or scaled at its intrinsic aspect ratio. */ -export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): ReactNode { +export function ImageBody(props: ImageBodyProps): ReactNode { + const { content, resourceAddress, t } = props + const { tab } = props.useTabInfo() + const preference = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_ZOOM) const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress]) const mediaType = imageMediaType(path) const data = content.kind === 'bytes' ? content.data : undefined const [source, setSource] = useState() + const setPreference = useCallback((value: ZoomPreference): void => { + props.actions.zoom(tab.id, value) + }, [props.actions, tab.id]) + + useEffect(() => { props.retainTab(tab.id, tab.signal) }, [props.retainTab, tab.id, tab.signal]) useEffect(() => { if (data === undefined || mediaType === undefined) return @@ -80,29 +89,46 @@ export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): Reac } if (source.kind === 'failed') return

{t('failed')}

const { name } = pathPartsOf(path) - return + const labels: ZoomLabels = { + controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'), + fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }), + } + return } /** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */ -function LoadedImage({ url, name, t }: { +function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: { readonly url: string readonly name: string + readonly preference: ZoomPreference + readonly onPreference: (preference: ZoomPreference) => void + readonly labels: ZoomLabels + readonly signal: AbortSignal + readonly scrollportRef: ImageBodyProps['scrollportRef'] readonly t: ImageBodyProps['t'] }): ReactNode { const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') - return
- {state === 'loading' && } - {state === 'failed' &&

{t('failed')}

} - { setState('ready') }} - onError={() => { setState('failed') }} - /> -
+ const [width, setWidth] = useState() + return +
+ {state === 'loading' && } + {state === 'failed' &&

{t('failed')}

} + +
+
} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts index 51794da53a..b7f86ff0ae 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts @@ -1,7 +1,11 @@ /** Builtin image metadata and keyed document-body registration. */ import type { Context } from '@deepseek-ai/cordis' +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '../index.ts' import type { DocumentPreviewDefinition } from '../document/registry.ts' +import { retainDocumentTabs } from '../document/tab-lifetime.ts' +import { createZoomStore, type ZoomInjected, type ZoomStore } from '../zoom/store.ts' import { ImageBody } from './ImageBody.tsx' import { en, zh } from './locales.ts' @@ -39,7 +43,12 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind('sidebarImage') ctx.effect(() => ctx.locale.register('sidebarImage', { zh, en }), 'document-image: dictionaries') ctx.effect(() => ctx.documentPreviews.register(imageBodyDefinition(() => t('title'))), 'document-image: metadata') - ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register( - { name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' }, ImageBody, - )), 'document-image: body') + const store = createZoomStore() + const retainTab = retainDocumentTabs(ctx) + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({ + name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage', store, + inject: (_sessionId: SessionId, actions: BoundActions): ZoomInjected => ({ + retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) }, + }), + }, ImageBody)), 'document-image: body') } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts index eab44ce0b0..f5e37296d1 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts @@ -1,5 +1,8 @@ +import { zoomEn, zoomZh } from '../zoom/locales.ts' + /** Locale-owned image renderer labels and status text. */ export const zh = { + ...zoomZh, title: '图片', preview: '图片预览:{name}', loading: '正在读取…', @@ -12,6 +15,7 @@ export type ImagePreviewKey = keyof typeof zh /** English dictionary with the same keys as the Chinese dictionary. */ export const en = { + ...zoomEn, title: 'Image', preview: 'Image preview: {name}', loading: 'Reading…', diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css index 2a0418c4ac..c39c4cac20 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.module.css @@ -4,9 +4,3 @@ height: 100%; min-height: 0; } - -.scrollport { - flex: 1; - min-height: 0; - overflow: auto; -} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx index 5915ab3655..5d74774e45 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx @@ -70,11 +70,9 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode { } if (file === undefined) return return
-
- {props.renderSlot('sidebar.right.tab.document.office.pdf', { - resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef, - addResource: props.addResource, setResources: props.setResources, - }, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })} -
+ {props.renderSlot('sidebar.right.tab.document.office.pdf', { + resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef, + addResource: props.addResource, setResources: props.setResources, + }, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css index 87017ff976..c1548464c1 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/PdfBody.module.css @@ -4,7 +4,8 @@ box-sizing: border-box; width: 100%; min-height: 0; - overflow-x: hidden; + min-width: 100%; + overflow: visible; background: var(--dsw-alias-bg-document-preview); font-family: var(--dsw-font, sans-serif); white-space: normal; @@ -14,7 +15,7 @@ box-sizing: border-box; width: 100%; min-width: 0; - overflow-x: hidden; + overflow-x: visible; } /* An unrendered page holds its place as a quiet block; the block itself is @@ -27,15 +28,15 @@ .canvas { display: block; - width: min(var(--pdf-page-width), 100%); - max-width: 100%; + width: 100%; + max-width: none; height: auto; margin-inline: auto; } .surface { position: relative; - max-width: 100%; + max-width: none; margin-inline: auto; box-shadow: var(--dsw-elevation-prominent); } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts index 2039afc67c..0dbe890eb0 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/index.ts @@ -8,6 +8,7 @@ import { LazyPdfBody } from './LazyPdfBody.tsx' 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 { ZoomViewport, zoomSurfaceClass } from '../zoom/ZoomViewport.tsx' import { en, zh } from './locales.ts' /** PDF metadata and keyed body share this package-local implementation identity. */ @@ -48,6 +49,8 @@ export function pdfBodyRegistration(ctx: Context): { store, inject: (_sessionId, actions): PdfBodyInjected => ({ retainTab: (tabId, signal) => { retainTab(tabId, signal, actions.forget) }, + ZoomViewport, + zoomSurfaceClass, }), } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts index 2db19c53f6..7b24b73157 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/locales.ts @@ -1,5 +1,8 @@ +import { zoomEn, zoomZh } from '../zoom/locales.ts' + /** Copy owned by the PDF renderer. */ export const zh = { + ...zoomZh, title: 'PDF', pageImage: 'PDF 第 {page} 页', loading: '正在读取…', @@ -16,6 +19,7 @@ export type PdfLocaleKey = keyof typeof zh /** English PDF-renderer dictionary. */ export const en = { + ...zoomEn, title: 'PDF', pageImage: 'PDF page {page}', loading: 'Reading…', diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx index 737c7bb6b7..fc11b38254 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx @@ -1,5 +1,5 @@ /** PDF page presentation; binary content and tab information come from the document owner. */ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react' import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -9,6 +9,8 @@ import { renderPdfPage, type PdfDocument } from './document.ts' import { openPdf } from './runtime.ts' import { PdfWorkerFailure } from './errors.ts' import { pdfTextRenderer } from './text.ts' +import type { ZoomViewportProps } from '../zoom/ZoomViewport.tsx' +import type { ZoomLabels, ZoomPreference } from '../zoom/types.ts' import type {} from './locales.ts' import css from './PdfBody.module.css' @@ -20,6 +22,10 @@ export interface PdfBodyInjected { * @param signal - tab-record lifetime, not body visibility. */ readonly retainTab: (tabId: TabId, signal: AbortSignal) => void + /** Main-bundle viewport reused by the lazy PDF body. */ + readonly ZoomViewport: ComponentType + /** Main-bundle class implementing fit-width and fixed-size surfaces. */ + readonly zoomSurfaceClass: string } /** Standard document props plus the PDF entry's locale, viewing store, and lifetime callback. */ @@ -30,7 +36,7 @@ type LoadState = | { readonly kind: 'failed'; readonly data: Uint8Array; readonly error: unknown } const DEFAULT_PDF_VIEW: PdfView = { page: 1 } - +const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' } /** * Present a PDF with tab-local viewing preferences and component-owned rendering resources. * @param props - complete bytes and framework-owned tab/store/locale seats. @@ -42,10 +48,19 @@ export function PdfBody(props: PdfBodyProps): ReactNode { const data = props.content.kind === 'bytes' ? props.content.data : undefined const [load, setLoad] = useState() const [attempt, setAttempt] = useState(0) - const { retainTab, actions, t } = props + const [pageWidth, setPageWidth] = useState() + const { retainTab, actions, t, ZoomViewport, zoomSurfaceClass } = props + const preference = view.zoom ?? FIT_WIDTH + const labels: ZoomLabels = { + controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'), + fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }), + } const pageVisible = useCallback((page: number): void => { actions.page(tab.id, page) }, [actions, tab.id]) + const setPreference = useCallback((value: ZoomPreference): void => { + actions.zoom(tab.id, value) + }, [actions, tab.id]) useEffect(() => { retainTab(tab.id, tab.signal) }, [retainTab, tab.id, tab.signal]) useEffect(() => { @@ -78,20 +93,27 @@ export function PdfBody(props: PdfBodyProps): ReactNode { } - return
+ const pages =
{Array.from({ length: load.document.numPages }, (_, index) => ( + requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal} + onWidth={index === 0 ? setPageWidth : undefined} zoomSurfaceClass={zoomSurfaceClass} t={t} /> ))}
+ return + {pages} + } -function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, t }: { +function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoomSurfaceClass, t }: { readonly document: PdfDocument readonly page: number readonly requested: boolean readonly onVisible: (page: number) => void readonly signal: AbortSignal + readonly onWidth: ((width: number) => void) | undefined + readonly zoomSurfaceClass: string } & PropsLocale<'sidebarPdf'>): ReactNode { const host = useRef(null) const canvas = useRef(null) @@ -134,11 +156,11 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig textTask = createText(pdfPage, viewport) return textTask }).then( - (size) => { if (!renderSignal.aborted) { setWidth(size.width); setState('ready') } }, + (size) => { if (!renderSignal.aborted) { setWidth(size.width); onWidth?.(size.width); setState('ready') } }, (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }, ) return () => { lifetime.abort(); textTask?.cancel() } - }, [document, page, requested, signal, attempt]) + }, [document, page, requested, signal, attempt, onWidth]) return
{failure === undefined && state !== 'ready' && (requested ?
@@ -147,7 +169,9 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig {failureText(failure.error, t)}
} -