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',