From 18bb0453039914603d2fc4881522a4a5e9a575ba Mon Sep 17 00:00:00 2001 From: chaxus Date: Tue, 4 Aug 2026 23:08:27 +0800 Subject: [PATCH] fix(embed): send document buffer as base64 to avoid ArrayBuffer loss in embedded WebViews (#113) document:open-buffer had no support for base64-encoded payloads, the only transport a host like Qt WebEngine's runJavaScript() can use to hand over binary data. Separately, asc_openDocument sent binData as a raw ArrayBuffer through OnlyOffice's internal iframe postMessage, which relies on the host's structured-clone support for binary types. Send it as base64 instead, mirroring the already-working empty "new document" template path, which sidesteps that dependency entirely. Co-Authored-By: Claude Sonnet 5 --- ...-issue-113-embed-buffer-format-mismatch.md | 81 +++++++++++++++++++ lib/embed-api.ts | 28 ++++++- lib/onlyoffice-editor.ts | 28 ++++++- packages/shared/src/document-types.ts | 2 +- test/unit/embed-api.test.ts | 24 ++++++ test/unit/onlyoffice-editor.test.ts | 49 +++++++++++ types/editor.d.ts | 2 +- 7 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 docs/explorations/2026-08-04-issue-113-embed-buffer-format-mismatch.md diff --git a/docs/explorations/2026-08-04-issue-113-embed-buffer-format-mismatch.md b/docs/explorations/2026-08-04-issue-113-embed-buffer-format-mismatch.md new file mode 100644 index 00000000..f0697139 --- /dev/null +++ b/docs/explorations/2026-08-04-issue-113-embed-buffer-format-mismatch.md @@ -0,0 +1,81 @@ +# Issue #113:document:open-buffer 在 Qt WebEngine 下报 -85 格式不匹配 + +日期:2026-08-04 +分支:main +涉及:`lib/onlyoffice-editor.ts`、`lib/embed-api.ts`、`packages/shared/src/document-types.ts`、 +`types/editor.d.ts`、`test/unit/onlyoffice-editor.test.ts`、`test/unit/embed-api.test.ts` + +## 问题(GitHub issue #113) + +在 Qt WebEngine (PySide6) 里通过 `document:open-buffer` 传 Base64 编码的 docx, +OnlyOffice 报错码 -85:"文件内容对应于 pdf/djvu/xps/oxps 之一,但扩展名是 docx"。 +提交者自己加了调试日志,观察到 `onAppReady` 里 `binData` 的 `typeof` 是 +`"object"`,怀疑 ArrayBuffer 在传递过程中退化成了普通对象;反复改了 +`onlyoffice-editor.ts`/`embed-api.ts`/`converter.ts` 但没修复成功。 + +## 分析 + +`typeof` 是 `"object"`本身不能说明问题——ArrayBuffer、Uint8Array 的 `typeof` +在 JS 里都是 `"object"`,这条诊断是误导。真正有信息量的是错误文案本身: +OnlyOffice 内部按 `_rels/.rels` 特征区分 pdf/djvu/xps/oxps 家族,docx 和 +xps/oxps 一样是 ZIP/OPC 容器,只有当引擎收到的 `buf` **不是** x2t 转换产物、 +而是原始 docx 的 ZIP 字节时,才会走到这条误判分支。 + +顺着 `window.editor.sendCommand({command:'asc_openDocument', data:{buf}})` +往下追到 vendored 的 `public/web-apps/apps/api/documents/api.js`: + +```js +function i(e, o) { + e && e.postMessage && t.JSON && (o.data?.event && (o = JSON.stringify(o)), e.postMessage(o, '*')); +} +``` + +`sendCommand` 把整个命令对象交给内部编辑器 iframe 的 `postMessage`,走浏览器 +原生结构化克隆(不满足 `o.data.event` 时不会被 `JSON.stringify`)。这依赖宿主 +环境对 `ArrayBuffer`/`TypedArray` 的结构化克隆实现是正确的——这正是内嵌 +WebView(Qt WebEngine、以及类似的原生壳/WebChannel 桥接场景)历史上容易出问题 +的地方。 + +关键佐证:项目里"新建空白文档"这条路径(`lib/empty_bin.ts` 的 +`g_sEmpty_bin['.docx']` 等)存的就是 Base64 **字符串**,同样直接塞进 +`data: { buf }` 发给 `asc_openDocument`,而这条路径一直工作正常。说明 +OnlyOffice 引擎本身就接受 `buf` 是 Base64 字符串——字符串在任何环境下 +`postMessage`/结构化克隆都不会失真,天然绕开了 ArrayBuffer 跨边界失真的整 +类问题。 + +## 修复 + +1. **`lib/onlyoffice-editor.ts`**:`onAppReady` 里发送 `asc_openDocument` + 前,把 `binData`(Uint8Array/ArrayBuffer)统一转成 Base64 字符串再发送 + (已是字符串的"新建文档"分支保持不变),复用项目里已验证可行的路径。 + 分块 `String.fromCharCode` 避免大文档时对 `...bytes` 展开撑爆调用栈。 + 顺带效果:如果 `binData` 不是合法的二进制类型(比如未来 x2t 返回了奇怪 + 的对象),`toUint8Array()` 会立刻抛出清晰的错误,而不是让 OnlyOffice + 报出难以定位的 -85。 +2. **`lib/embed-api.ts`**:`document:open-buffer` 之前完全不支持 Base64 + 字符串 payload——`payload.data` 是字符串时会直接落入 + `throw new Error('document:open requires ...')`。这是任何只能通过 + JSON 跨语言边界传数据的宿主(Qt WebEngine 的 `runJavaScript`、Electron + IPC 等)唯一可行的传输方式,现在补上 `atob` 解码(含 `data:...;base64,` + 前缀的兼容)。 +3. 类型层面把 `Window.editor.sendCommand` 的 `buf` 从 `ArrayBuffer` 放宽成 + `ArrayBuffer | string`(`packages/shared/src/document-types.ts` 与 + legacy 的 `types/editor.d.ts` 两处都要改,二者做 declaration merging, + 必须完全一致否则 TS2717)。`@ranuts/shared` 的类型是从 `dist/` 消费的, + 改完 `src` 记得 `pnpm --filter @ranuts/shared build` 重新生成 `.d.ts`。 + +## 局限性说明 + +没有 Qt WebEngine 环境可以直接复现,这个修复是基于代码走读 + vendored +OnlyOffice SDK 的静态分析定位的最可能根因(ArrayBuffer 结构化克隆跨内部 +iframe 边界失真),而不是端到端复现验证过的。已请提交者在他们的实际环境里 +验证。如果问题依旧存在,下一步要看 Qt WebEngine 是否对 +`window.postMessage` 做了额外拦截/包装(例如注入了自定义的 +`qwebchannel.js` 桥接逻辑),那就不是这个仓库能单方面修的了。 + +## 验证 + +- `pnpm run lint:ts`(oxlint + tsc)、`pnpm run format:check` 全过 +- `pnpm run test`:20 个文件 263 个单测全过,新增 4 个用例覆盖 + Base64 payload 解码(含 data URL 前缀)与 `asc_openDocument` 收到的 + `buf` 确实是 Base64 字符串(含"新建文档"字符串分支保持不变的回归用例) diff --git a/lib/embed-api.ts b/lib/embed-api.ts index dc72abb5..3415331a 100644 --- a/lib/embed-api.ts +++ b/lib/embed-api.ts @@ -69,6 +69,23 @@ function postToParent(type: string, payload: EmbedResponsePayload = {}, id?: str ); } +// Some hosts (e.g. a Qt WebEngine app driving the page via +// QWebEngineView.page().runJavaScript()) can only inject JSON-serializable +// literals -- there is no way to hand over a real ArrayBuffer from the host +// language. Base64-encoding the file content is the standard workaround, so +// accept a base64 string alongside the binary forms below. Strips an optional +// data-URL prefix ("data:...;base64,") for convenience. +function decodeBase64ToUint8Array(base64: string): Uint8Array { + const commaIndex = base64.indexOf(','); + const raw = base64.startsWith('data:') && commaIndex !== -1 ? base64.slice(commaIndex + 1) : base64; + const binaryString = atob(raw); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + function makeFileFromPayload(payload: Record): File { const fileName = payload.fileName || payload.name || 'document.xlsx'; @@ -82,7 +99,7 @@ function makeFileFromPayload(payload: Record): File { }); } - const buffer = payload.buffer || payload.arrayBuffer || payload.bytes || payload.data; + const buffer = payload.buffer || payload.arrayBuffer || payload.bytes || payload.data || payload.base64; if (buffer instanceof ArrayBuffer) { return new File([buffer], fileName, { type: payload.mimeType || 'application/octet-stream', @@ -96,7 +113,14 @@ function makeFileFromPayload(payload: Record): File { }); } - throw new Error('document:open requires url, file, blob, buffer, arrayBuffer, bytes, or data'); + if (typeof buffer === 'string') { + const bytes = decodeBase64ToUint8Array(buffer); + return new File([bytes.buffer as ArrayBuffer], fileName, { + type: payload.mimeType || 'application/octet-stream', + }); + } + + throw new Error('document:open requires url, file, blob, buffer, arrayBuffer, bytes, data, or base64'); } async function openFile(file: File, readonly = false): Promise { diff --git a/lib/onlyoffice-editor.ts b/lib/onlyoffice-editor.ts index f732e9cf..c3ed9fa5 100644 --- a/lib/onlyoffice-editor.ts +++ b/lib/onlyoffice-editor.ts @@ -72,6 +72,27 @@ export function toUint8Array(data: BlobPart): Uint8Array { throw new Error('Unsupported saved data type'); } +/** + * Base64-encode binary data in chunks (avoids blowing the call stack on + * String.fromCharCode(...bytes) for large documents). + * + * asc_openDocument's `buf` is sent to OnlyOffice's internal editor iframe via + * window.postMessage. Some embedding hosts (e.g. Qt WebEngine, see #113) have + * been observed losing ArrayBuffer/TypedArray contents across that boundary, + * which OnlyOffice then can't recognize as a valid document and reports as a + * format mismatch. A base64 string survives postMessage/structured-clone + * universally, so we send that instead -- the same approach already used for + * the empty "new document" template in empty_bin.ts. + */ +function toBase64(bytes: Uint8Array): string { + const CHUNK_SIZE = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)); + } + return btoa(binary); +} + function getFileExtension(fileName: string): string { return fileName.split('.').pop()?.toUpperCase() || ''; } @@ -419,11 +440,12 @@ export function createEditorInstance(config: { }); } - // Load document content + // Load document content. See toBase64() for why this is sent as a + // base64 string rather than the raw ArrayBuffer/Uint8Array. + const buf = typeof binData === 'string' ? binData : toBase64(toUint8Array(binData)); window.editor?.sendCommand({ command: 'asc_openDocument', - // @ts-expect-error binData type is handled by the editor - data: { buf: binData }, + data: { buf }, }); }, onDocumentReady: () => { diff --git a/packages/shared/src/document-types.ts b/packages/shared/src/document-types.ts index 6173ad57..0534bdf3 100644 --- a/packages/shared/src/document-types.ts +++ b/packages/shared/src/document-types.ts @@ -52,7 +52,7 @@ declare global { urls?: Record; path?: string; imgName?: string; - buf?: ArrayBuffer; + buf?: ArrayBuffer | string; success?: boolean; error?: string; enabled?: boolean; diff --git a/test/unit/embed-api.test.ts b/test/unit/embed-api.test.ts index b75fff94..bde395e6 100644 --- a/test/unit/embed-api.test.ts +++ b/test/unit/embed-api.test.ts @@ -252,6 +252,30 @@ describe('embed-api', () => { expectMessagePosted(postMessageSpy, 'document:opened', 'uint8-1'); }); + it('decodes a base64 string payload (via "data" key) into a File', async () => { + const original = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]); + const base64 = btoa(String.fromCharCode(...original)); + + await openWithPayload({ data: base64, fileName: 'from-base64.docx' }, 'base64-1'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + expect(callArg.fileName).toBe('from-base64.docx'); + const bytes = new Uint8Array(await callArg.file.arrayBuffer()); + expect(Array.from(bytes)).toEqual(Array.from(original)); + expectMessagePosted(postMessageSpy, 'document:opened', 'base64-1'); + }); + + it('strips a data-URL prefix before decoding a base64 payload', async () => { + const original = new Uint8Array([1, 2, 3, 4]); + const base64 = btoa(String.fromCharCode(...original)); + + await openWithPayload({ data: `data:application/octet-stream;base64,${base64}`, fileName: 'x.docx' }, 'base64-2'); + + const [callArg] = mockHandleDocumentOperation.mock.calls.at(-1)!; + const bytes = new Uint8Array(await callArg.file.arrayBuffer()); + expect(Array.from(bytes)).toEqual(Array.from(original)); + }); + it('uses default filename "document.xlsx" when no name is supplied', async () => { const buffer = new Uint8Array([1]).buffer; diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index 4570afc9..a75e460a 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -167,6 +167,55 @@ describe('onlyoffice-editor', () => { expect(config.document.permissions.edit).toBe(false); expect(config.document.permissions.download).toBe(false); }); + + it('sends binData as a base64 string to asc_openDocument (#113)', async () => { + vi.useFakeTimers(); + const DocEditor = vi.fn(); + (window as any).DocsAPI = { DocEditor }; + const original = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 9, 8, 7]); + + const promise = createEditorInstance({ + fileName: 'report.docx', + fileType: 'docx', + binData: original.buffer, + }); + await vi.advanceTimersByTimeAsync(200); + await promise; + + const config = DocEditor.mock.calls[0][1] as any; + const editor = { sendCommand: vi.fn() }; + (window as any).editor = editor; + config.events.onAppReady(); + + const call = editor.sendCommand.mock.calls.find((c: any[]) => c[0].command === 'asc_openDocument'); + expect(call).toBeDefined(); + const buf = call![0].data.buf; + expect(typeof buf).toBe('string'); + const decoded = Uint8Array.from(atob(buf), (c) => c.charCodeAt(0)); + expect(Array.from(decoded)).toEqual(Array.from(original)); + }); + + it('passes a string binData (empty-template case) through to asc_openDocument unchanged', async () => { + vi.useFakeTimers(); + const DocEditor = vi.fn(); + (window as any).DocsAPI = { DocEditor }; + + const promise = createEditorInstance({ + fileName: 'New_Document.docx', + fileType: 'docx', + binData: 'already-base64==', + }); + await vi.advanceTimersByTimeAsync(200); + await promise; + + const config = DocEditor.mock.calls[0][1] as any; + const editor = { sendCommand: vi.fn() }; + (window as any).editor = editor; + config.events.onAppReady(); + + const call = editor.sendCommand.mock.calls.find((c: any[]) => c[0].command === 'asc_openDocument'); + expect(call![0].data.buf).toBe('already-base64=='); + }); }); describe('setConverterCallbacks', () => { diff --git a/types/editor.d.ts b/types/editor.d.ts index 1cc2d367..599cf273 100644 --- a/types/editor.d.ts +++ b/types/editor.d.ts @@ -87,7 +87,7 @@ interface DocEditor { urls?: Record; path?: string; imgName?: string; - buf?: ArrayBuffer; + buf?: ArrayBuffer | string; success?: boolean; error?: string; enabled?: boolean;