diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a633b191..835916349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ notes. Entries describe what users experience, not internal refactors. ### Fixed +- Read-only embeds can export again: `document:save` returns the file while + editing stays locked, and the File tab's download control inside an iframe + starts a real download instead of silently dropping the bytes ([#307](https://github.com/ranuts/document/issues/307)). - Read-only documents now look read-only: the toolbar keeps only the File and View tabs, editing buttons such as paste and macros are hidden, and the side panels are locked, while search, zoom and navigation stay available. Switching read-only off restores the full toolbar. - Exporting a PDF or another format keeps unsaved changes in the editable original visible until the original is saved. - Failed file opens restore usable file controls, including keyboard access. diff --git a/CLAUDE.md b/CLAUDE.md index d20494b0c..d6bb0166c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,9 +240,9 @@ test/setup/vitest.ts # 全局 mock:matchMedia、URL.createObjectURL - 多 sheet 工作簿 open-buffer 打开 + 保存往返数据完整(#113、#31) - xlsx → PDF 导出(canvas 渲染管线,#28 / 错误码 80 的场景) - CSV 打开 + 存回 CSV 内容一致(#13、#33) -- 只读打开:状态正确且保存被拒(#25、#87) +- 只读打开:状态正确且仍可导出(#25、#87、#307;只读禁编辑不禁下载) - 运行时只读切换:restriction 真实生效(iframe 内 `restrictions` 属性)、 - 锁定期保存被拒、解锁后保存往返完整 + 锁定期仍可导出、解锁后保存往返完整(#307) - docx open-buffer 打开 + 存回 docx(页内零依赖手拼最小 OOXML zip,#113 直接钉死) - PDF 打开:真实挂载 pdfeditor(断言 iframe URL 路由),页内手拼合法最小 PDF - URL 插图后保存:产物 zip 含 media 条目且字节完整(守护 serverless image @@ -1152,7 +1152,7 @@ E2E 回归套件覆盖**(`pnpm run test:e2e` 的 embed-regression.spec:打 - `getSavedFileMimeType` / `getNormalizedFile` / `toUint8Array` — 纯计算逻辑 - `setReadonlyMode` / `getReadonlyMode` — 状态读写 -- `requestSaveDocument` — 所有拒绝路径(无编辑器、只读、并发、超时、不支持 downloadAs) +- `requestSaveDocument` — 拒绝路径(无编辑器、并发、超时、不支持 downloadAs;只读允许导出,见 #307) ### AI assistant product entry diff --git a/content/de/embed-document-editor.md b/content/de/embed-document-editor.md index 163f6160f..b002bd5bf 100644 --- a/content/de/embed-document-editor.md +++ b/content/de/embed-document-editor.md @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## Schreibgeschützt und Vorschau -Öffnen Sie ein Dokument schreibgeschützt (als Betrachter, als Prüfschritt, als gesperrter Datensatz), indem Sie `readonly: true` an den Öffnen-Befehl übergeben, und wechseln Sie jederzeit mit `document:set-readonly` — ohne Neuladen, das Dokument bleibt an der Stelle des Nutzers. Im schreibgeschützten Modus ist Bearbeiten deaktiviert und `document:save` antwortet mit `document:error`; `document:get-state` meldet das aktuelle `readonly`-Flag. +Öffnen Sie ein Dokument schreibgeschützt (als Betrachter, als Prüfschritt, als gesperrter Datensatz), indem Sie `readonly: true` an den Öffnen-Befehl übergeben, und wechseln Sie jederzeit mit `document:set-readonly` — ohne Neuladen, das Dokument bleibt an der Stelle des Nutzers. Im schreibgeschützten Modus ist Bearbeiten deaktiviert, aber `document:save` liefert die aktuellen Bytes weiterhin über `document:saved` (Export ist erlaubt; gesperrt ist nur das Bearbeiten); `document:get-state` meldet das aktuelle `readonly`-Flag. ```js // gesperrt öffnen, später entsperren @@ -88,4 +88,4 @@ Ergänzen Sie die iframe-URL um `embedOrigin`, um den Nachrichtenaustausch auf e ### Kann ich ein Dokument schreibgeschützt zeigen oder später sperren? -Ja. Übergeben Sie beim Öffnen `readonly: true` oder senden Sie jederzeit `document:set-readonly` — es wechselt im laufenden Editor ohne Neuladen, und Speichern wird währenddessen abgelehnt. +Ja. Übergeben Sie beim Öffnen `readonly: true` oder senden Sie jederzeit `document:set-readonly` — es wechselt im laufenden Editor ohne Neuladen. Bearbeiten bleibt gesperrt, der Export über `document:save` funktioniert weiterhin. diff --git a/content/en/embed-document-editor.md b/content/en/embed-document-editor.md index 694da1394..2c0c5d26f 100644 --- a/content/en/embed-document-editor.md +++ b/content/en/embed-document-editor.md @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## Read-only and preview mode -Open a document read-only (a viewer, a review step, a locked record) by passing `readonly: true` with the open command, and switch at any time with `document:set-readonly` — no reload, the document stays where the user was. In read-only mode editing is disabled and `document:save` answers with `document:error`; `document:get-state` reports the current `readonly` flag. +Open a document read-only (a viewer, a review step, a locked record) by passing `readonly: true` with the open command, and switch at any time with `document:set-readonly` — no reload, the document stays where the user was. In read-only mode editing is disabled, but `document:save` still returns the current bytes via `document:saved` (export is allowed; only edits are locked); `document:get-state` reports the current `readonly` flag. ```js // open locked, unlock later @@ -88,4 +88,4 @@ Add `embedOrigin` to the iframe URL to lock messaging to a specific origin, and ### Can I show a document read-only, or lock it after a while? -Yes. Pass `readonly: true` when opening, or send `document:set-readonly` at any time — it switches the live editor without reloading, and saves are refused while locked. +Yes. Pass `readonly: true` when opening, or send `document:set-readonly` at any time — it switches the live editor without reloading. Editing stays locked, but export via `document:save` still works. diff --git a/content/es/embed-document-editor.md b/content/es/embed-document-editor.md index ac72f8297..42e9ea27f 100644 --- a/content/es/embed-document-editor.md +++ b/content/es/embed-document-editor.md @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## Solo lectura y modo vista previa -Abre un documento en solo lectura (un visor, un paso de revisión, un registro bloqueado) pasando `readonly: true` con el comando de apertura, y cambia cuando quieras con `document:set-readonly`: sin recargar, y el documento se queda donde estaba la persona. En modo de solo lectura la edición se desactiva y `document:save` responde con `document:error`; `document:get-state` informa del valor actual de `readonly`. +Abre un documento en solo lectura (un visor, un paso de revisión, un registro bloqueado) pasando `readonly: true` con el comando de apertura, y cambia cuando quieras con `document:set-readonly`: sin recargar, y el documento se queda donde estaba la persona. En modo de solo lectura la edición se desactiva, pero `document:save` sigue devolviendo los bytes actuales mediante `document:saved` (se permite exportar; solo se bloquea la edición); `document:get-state` informa del valor actual de `readonly`. ```js // ábrelo bloqueado, desbloquéalo después @@ -88,4 +88,4 @@ Añade `embedOrigin` a la URL del iframe para limitar los mensajes a un origen c ### ¿Puedo mostrar un documento en solo lectura, o bloquearlo más tarde? -Sí. Pasa `readonly: true` al abrirlo, o envía `document:set-readonly` cuando quieras: cambia el editor en vivo sin recargar, y los guardados se rechazan mientras está bloqueado. +Sí. Pasa `readonly: true` al abrirlo, o envía `document:set-readonly` cuando quieras: cambia el editor en vivo sin recargar. La edición permanece bloqueada, pero la exportación con `document:save` sigue funcionando. diff --git a/content/ja/embed-document-editor.md b/content/ja/embed-document-editor.md index 27d0a9b2c..6276e5ae9 100644 --- a/content/ja/embed-document-editor.md +++ b/content/ja/embed-document-editor.md @@ -12,7 +12,7 @@ howTo: 自分のサイトにドキュメントエディタを埋め込む方法 appDescription: 'iframe と postMessage でエディタを埋め込み、ファイルのアクセスやアップロードは埋め込み先が管理します。' --- -基本のローカル編集では:エディタは OnlyOffice の WebAssembly エンジンでブラウザ内だけで動作するため、ドキュメントはクライアント側で描画・編集されます——ドキュメントサーバーを立てる必要はありません。推奨する構成は境界をきれいに保ちます: **親アプリが認証・取得・保存を担当し、iframe は編集だけを担当する。** トークンも Cookie も業務 API も、あなたのアプリの中に留まります。 +基本のローカル編集では:エディタは OnlyOffice の WebAssembly エンジンでブラウザ内だけで動作するため、ドキュメントはクライアント側で描画・編集されます——ドキュメントサーバーを立てる必要はありません。推奨する構成は境界をきれいに保ちます:**親アプリが認証・取得・保存を担当し、iframe は編集だけを担当する。** トークンも Cookie も業務 API も、あなたのアプリの中に留まります。 ## 一つの iframe で追加する @@ -24,7 +24,7 @@ appDescription: 'iframe と postMessage でエディタを埋め込み、ファ > ``` -そのあとは `postMessage` で通信します。各コマンドは返信と対応づけるための `id` を持ち、エディタのイベントはすべて `document:*` メッセージです: +そのあとは `postMessage` で通信します。各コマンドは返信と対応づけるための `id` を持ち、エディタのイベントはすべて `document:*` メッセージです: ```js // open a file your app already fetched (auth stays with you) @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## 読み取り専用・プレビューモード -ビューア、レビュー工程、ロックされたレコードなど、読み取り専用で開きたいときは open コマンドに `readonly: true` を渡します。`document:set-readonly` でいつでも切り替えられます——再読み込みは不要で、ユーザーが見ていた位置も保たれます。読み取り専用のあいだは編集が無効になり、`document:save` は `document:error` を返します。`document:get-state` は現在の `readonly` フラグを報告します。 +ビューア、レビュー工程、ロックされたレコードなど、読み取り専用で開きたいときは open コマンドに `readonly: true` を渡します。`document:set-readonly` でいつでも切り替えられます——再読み込みは不要で、ユーザーが見ていた位置も保たれます。読み取り専用のあいだは編集が無効ですが、`document:save` は `document:saved` 経由で現在のバイト列を返します(エクスポートは可能、ロックされるのは編集だけ)。`document:get-state` は現在の `readonly` フラグを報告します。 ```js // open locked, unlock later @@ -88,4 +88,4 @@ iframe の URL に `embedOrigin` を付けてメッセージのやり取りを ### ドキュメントを読み取り専用で表示したり、途中からロックしたりできますか? -はい。開くときに `readonly: true` を渡すか、いつでも `document:set-readonly` を送ってください——再読み込みなしにその場で切り替わり、ロック中の保存は拒否されます。 +はい。開くときに `readonly: true` を渡すか、いつでも `document:set-readonly` を送ってください——再読み込みなしにその場で切り替わります。ロック中も編集はできませんが、`document:save` によるエクスポートは可能です。 diff --git a/content/ko/embed-document-editor.md b/content/ko/embed-document-editor.md index d08a42da7..2f37bba3c 100644 --- a/content/ko/embed-document-editor.md +++ b/content/ko/embed-document-editor.md @@ -1,18 +1,18 @@ --- title: '내 사이트에 문서 편집기 임베드하기 — iframe + postMessage API' -description: 'iframe과 postMessage로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' +description: 'iframe 과 postMessage 로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' eyebrow: 개발자 · 임베드 h1: 내 웹 앱에 문서 편집기 임베드하기 -lead: 'iframe 하나와 **postMessage** API만으로 **DOCX·XLSX·PPTX·CSV** 편집기를 제품에 추가하세요. 인증, 파일 접근, 업로드는 내 앱이 계속 담당하고 편집기는 편집만 합니다 — 사용자의 토큰을 보지 않습니다.' +lead: 'iframe 하나와 **postMessage** API 만으로 **DOCX·XLSX·PPTX·CSV** 편집기를 제품에 추가하세요. 인증, 파일 접근, 업로드는 내 앱이 계속 담당하고 편집기는 편집만 합니다 — 사용자의 토큰을 보지 않습니다.' cta: 라이브 데모 열기 → ctaHref: /embed-demo.html -ogDescription: 'iframe과 postMessage로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' +ogDescription: 'iframe 과 postMessage 로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' breadcrumb: Embed Document Editor howTo: 내 사이트에 문서 편집기를 임베드하는 방법 -appDescription: 'iframe과 postMessage로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' +appDescription: 'iframe 과 postMessage 로 편집기를 임베드하고 호스트가 파일 접근과 업로드를 관리합니다.' --- -기본 로컬 편집의 경우: 편집기는 OnlyOffice의 WebAssembly 엔진으로 브라우저 안에서만 동작하므로 문서는 클라이언트에서 렌더링되고 편집됩니다 — 문서 서버를 세울 필요가 없습니다. 권장 구성은 경계를 깔끔하게 유지합니다: **부모 앱이 인증·가져오기·저장을 담당하고, iframe은 편집만 담당합니다.** 토큰, 쿠키, 업무 API는 내 앱 안에 남습니다. +기본 로컬 편집의 경우: 편집기는 OnlyOffice 의 WebAssembly 엔진으로 브라우저 안에서만 동작하므로 문서는 클라이언트에서 렌더링되고 편집됩니다 — 문서 서버를 세울 필요가 없습니다. 권장 구성은 경계를 깔끔하게 유지합니다: **부모 앱이 인증·가져오기·저장을 담당하고, iframe 은 편집만 담당합니다.** 토큰, 쿠키, 업무 API 는 내 앱 안에 남습니다. ## iframe 하나로 추가하기 @@ -27,7 +27,7 @@ appDescription: 'iframe과 postMessage로 편집기를 임베드하고 호스트 그다음에는 `postMessage`로 통신합니다. 각 명령은 응답과 짝지을 `id`를 가지며, 편집기의 모든 이벤트는 `document:*` 메시지입니다: ```js -// 내 앱이 이미 가져온 파일 열기(인증은 내 앱에 남습니다) +// 내 앱이 이미 가져온 파일 열기 (인증은 내 앱에 남습니다) iframe.contentWindow.postMessage( { id, type: 'document:open-buffer', payload: { fileName: 'report.xlsx', buffer } }, 'https://edit.chaxus.com', @@ -40,23 +40,23 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## 무엇을 얻나요 -- iframe 하나와 작은 **postMessage** 명령/응답 API — 설치할 SDK가 없습니다 +- iframe 하나와 작은 **postMessage** 명령/응답 API — 설치할 SDK 가 없습니다 - 내 앱이 자체 자격 증명으로 가져온 **URL·File·ArrayBuffer**에서 열기 - **XLSX·DOCX·PPTX·CSV**로 저장하고 `File`로 돌려받아 내 앱이 업로드 -- 읽기 전용 모드, 메시지 단위 오리진 제한(`embedOrigin`), 상태 조회 +- 읽기 전용 모드, 메시지 단위 오리진 제한 (`embedOrigin`), 상태 조회 - 기본 로컬 편집의 경우: 운영할 문서 서버 없음 — 편집은 100% 클라이언트의 WebAssembly -- 오픈 소스(AGPL-3.0)이며 자체 호스팅 가능 — 내 도메인 아래에 임베드 +- 오픈 소스 (AGPL-3.0) 이며 자체 호스팅 가능 — 내 도메인 아래에 임베드 ## 동작 방식 -1. 레이아웃에 맞춘 크기로 `/editor?embed=1`을 가리키는 iframe을 추가합니다. +1. 레이아웃에 맞춘 크기로 `/editor?embed=1`을 가리키는 iframe 을 추가합니다. 2. `document:ready` 이벤트를 기다린 뒤 `document:open-url`, `open-file`, `open-buffer`를 보냅니다. 3. 사용자가 그 자리에서 편집합니다. 내 앱이 어딘가로 보내지 않는 한 파일은 브라우저를 벗어나지 않습니다. 4. `document:save`를 보내면 편집기가 `document:saved`로 편집된 파일을 돌려주고, 내 앱이 자체 인증으로 업로드합니다. ## 읽기 전용과 미리보기 모드 -뷰어, 검토 단계, 잠긴 레코드처럼 읽기 전용으로 열려면 open 명령에 `readonly: true`를 전달하고, 언제든 `document:set-readonly`로 전환하세요 — 다시 불러오지 않으며 사용자가 보던 위치도 유지됩니다. 읽기 전용 동안에는 편집이 비활성화되고 `document:save`는 `document:error`로 응답합니다. `document:get-state`는 현재 `readonly` 값을 알려 줍니다. +뷰어, 검토 단계, 잠긴 레코드처럼 읽기 전용으로 열려면 open 명령에 `readonly: true`를 전달하고, 언제든 `document:set-readonly`로 전환하세요 — 다시 불러오지 않으며 사용자가 보던 위치도 유지됩니다. 읽기 전용 동안에는 편집이 비활성화되지만, `document:save`는 `document:saved`로 현재 바이트를 반환합니다 (내보내기는 가능하고 잠기는 것은 편집뿐입니다). `document:get-state`는 현재 `readonly` 값을 알려 줍니다. ```js // 잠긴 상태로 열고 나중에 해제 @@ -68,15 +68,15 @@ send('document:set-readonly', { readonly: false }); ### 문서 편집기를 어떻게 임베드하나요? -`/editor?embed=1`을 가리키는 iframe을 하나 추가하고 postMessage API로 열기와 저장을 제어하세요. 동작하는 데모는 [/embed-demo.html](/embed-demo.html)에 있습니다. +`/editor?embed=1`을 가리키는 iframe 을 하나 추가하고 postMessage API 로 열기와 저장을 제어하세요. 동작하는 데모는 [/embed-demo.html](/embed-demo.html)에 있습니다. ### 편집기가 사용자의 인증 토큰을 보나요? -기본 로컬 편집의 경우: 아니요. 인증, 파일 가져오기, 업로드는 내 앱에 남습니다 — 내 앱이 자체 자격 증명으로 파일을 가져와 바이트를 편집기에 넘기므로 토큰과 쿠키는 iframe에 들어가지 않습니다. +기본 로컬 편집의 경우: 아니요. 인증, 파일 가져오기, 업로드는 내 앱에 남습니다 — 내 앱이 자체 자격 증명으로 파일을 가져와 바이트를 편집기에 넘기므로 토큰과 쿠키는 iframe 에 들어가지 않습니다. ### 임베드된 편집기는 어떤 형식을 다루나요? -DOCX, XLSX, PPTX, CSV입니다. OnlyOffice의 WebAssembly 엔진으로 클라이언트에서 편집합니다. save 명령은 XLSX, DOCX, PPTX, CSV로 내보냅니다. +DOCX, XLSX, PPTX, CSV 입니다. OnlyOffice 의 WebAssembly 엔진으로 클라이언트에서 편집합니다. save 명령은 XLSX, DOCX, PPTX, CSV 로 내보냅니다. ### 자체 호스팅이나 화이트라벨이 가능한가요? @@ -84,8 +84,8 @@ DOCX, XLSX, PPTX, CSV입니다. OnlyOffice의 WebAssembly 엔진으로 클라이 ### 어떤 사이트가 편집기와 통신할 수 있는지 제한하려면? -iframe URL에 `embedOrigin`을 추가해 메시지를 특정 오리진으로 제한하고, 내 메시지 핸들러에서도 `event.origin`을 검증하세요. +iframe URL 에 `embedOrigin`을 추가해 메시지를 특정 오리진으로 제한하고, 내 메시지 핸들러에서도 `event.origin`을 검증하세요. ### 문서를 읽기 전용으로 보여 주거나 도중에 잠글 수 있나요? -네. 열 때 `readonly: true`를 전달하거나 언제든 `document:set-readonly`를 보내세요 — 다시 불러오지 않고 즉시 전환되며, 잠긴 동안에는 저장이 거부됩니다. +네. 열 때 `readonly: true`를 전달하거나 언제든 `document:set-readonly`를 보내세요 — 다시 불러오지 않고 즉시 전환됩니다. 잠긴 동안에도 편집은 막히지만 `document:save`로 내보내기는 가능합니다. diff --git a/content/pt/embed-document-editor.md b/content/pt/embed-document-editor.md index e4aef6925..dc3c15163 100644 --- a/content/pt/embed-document-editor.md +++ b/content/pt/embed-document-editor.md @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## Somente leitura e modo prévia -Abra um documento somente leitura (um visualizador, uma etapa de revisão, um registro travado) passando `readonly: true` no comando de abertura, e alterne quando quiser com `document:set-readonly` — sem recarregar, e o documento continua onde a pessoa estava. No modo somente leitura a edição fica desativada e `document:save` responde com `document:error`; `document:get-state` informa o valor atual de `readonly`. +Abra um documento somente leitura (um visualizador, uma etapa de revisão, um registro travado) passando `readonly: true` no comando de abertura, e alterne quando quiser com `document:set-readonly` — sem recarregar, e o documento continua onde a pessoa estava. No modo somente leitura a edição fica desativada, mas `document:save` ainda devolve os bytes atuais via `document:saved` (exportar é permitido; só a edição fica bloqueada); `document:get-state` informa o valor atual de `readonly`. ```js // abrir travado, destravar depois @@ -88,4 +88,4 @@ Acrescente `embedOrigin` à URL do iframe para limitar as mensagens a uma origem ### Dá para mostrar um documento somente leitura, ou travá-lo depois? -Sim. Passe `readonly: true` ao abrir, ou envie `document:set-readonly` quando quiser — ele alterna o editor ao vivo sem recarregar, e os salvamentos são recusados enquanto está travado. +Sim. Passe `readonly: true` ao abrir, ou envie `document:set-readonly` quando quiser — ele alterna o editor ao vivo sem recarregar. A edição permanece bloqueada, mas a exportação via `document:save` continua a funcionar. diff --git a/content/zh-CN/embed-document-editor.md b/content/zh-CN/embed-document-editor.md index d7adc4fdd..58dd5a9b8 100644 --- a/content/zh-CN/embed-document-editor.md +++ b/content/zh-CN/embed-document-editor.md @@ -56,7 +56,7 @@ iframe.contentWindow.postMessage({ id, type: 'document:save', payload: { targetE ## 只读与预览模式 -需要"只看不改"(预览器、审阅环节、已归档记录)时,在打开命令里传 `readonly: true`;随时可以用 `document:set-readonly` 切换——不重新加载,文档停在用户原来的位置。只读期间禁止编辑, `document:save` 返回 `document:error`;`document:get-state` 会报告当前的 `readonly` 状态。 +需要"只看不改"(预览器、审阅环节、已归档记录)时,在打开命令里传 `readonly: true`;随时可以用 `document:set-readonly` 切换——不重新加载,文档停在用户原来的位置。只读期间禁止编辑,但 `document:save` 仍会通过 `document:saved` 返回当前字节(允许导出,只锁编辑);`document:get-state` 会报告当前的 `readonly` 状态。 ```js // 锁定打开,稍后解锁 @@ -88,4 +88,4 @@ DOCX、XLSX、PPTX 和 CSV,用 OnlyOffice WebAssembly 引擎在客户端编辑 ### 能只读展示文档,或者过一会儿再锁定吗? -能。打开时传 `readonly: true`,或随时发送 `document:set-readonly`——它直接切换正在运行的编辑器、不重新加载,锁定期间保存会被拒绝。 +能。打开时传 `readonly: true`,或随时发送 `document:set-readonly`——它直接切换正在运行的编辑器、不重新加载。锁定期间禁止编辑,但通过 `document:save` 导出仍然可用。 diff --git a/docs/embed-api.md b/docs/embed-api.md index fbde98ffd..4ac0d81d1 100644 --- a/docs/embed-api.md +++ b/docs/embed-api.md @@ -145,7 +145,10 @@ Set at open time via the `readonly` field, or toggle at any time: sendEditorCommand('document:set-readonly', { readonly: true }); ``` -In read-only mode, editing is disabled and `document:save` returns `document:error`. +In read-only mode, editing is disabled. Export still works: `document:save` +returns the current bytes via `document:saved` (read-only blocks edits, not +downloads). The File tab's download control inside an embed iframe also +triggers a local download when the host has not armed a `document:save`. --- diff --git a/docs/embed-api.zh.md b/docs/embed-api.zh.md index 5c68cb2a7..ab2ce7fc0 100644 --- a/docs/embed-api.zh.md +++ b/docs/embed-api.zh.md @@ -130,7 +130,9 @@ sendEditorCommand('document:open-buffer', { fileName: 'demo.xlsx', buffer, reado sendEditorCommand('document:set-readonly', { readonly: true }); ``` -只读模式下编辑权限关闭,`document:save` 会返回 `document:error`。 +只读模式下编辑权限关闭,但导出仍然可用:`document:save` 会通过 `document:saved` +返回当前字节(只读禁止的是编辑,不是下载)。embed iframe 内 File 页签的下载 +控件在父页没有发起 `document:save` 时,也会触发本地下载。 --- diff --git a/docs/explorations/2026-11-06-readonly-embed-export.md b/docs/explorations/2026-11-06-readonly-embed-export.md new file mode 100644 index 000000000..900904a35 --- /dev/null +++ b/docs/explorations/2026-11-06-readonly-embed-export.md @@ -0,0 +1,34 @@ +# Read-only embed export (#307) + +## Symptom + +Opening `/editor?src=…&readonly=1&embed=1` left File → Download clickable. The +console logged `[OO] file stream received`, but no download started. Calling +`document:save` from the parent failed with `Current document is readonly`. + +## Root cause + +Two independent policies collided: + +1. `routeSavedFile` dropped orphan streams in embed mode and told the host to + use `document:save`. +2. `requestSaveDocument` rejected every call while `getReadonlyMode()` was + true. + +Read-only chrome intentionally keeps the File tab (download / print / info), +so the control looked live and then did nothing. + +## Fix + +- Export is allowed on a locked document: `requestSaveDocument` no longer + checks read-only. Autosave still skips read-only sessions. +- Orphan File-tab streams in embed mode call `saveFileToDisk` without clearing + the host's dirty flag (the parent has not received `document:saved`). +- Pending `document:save` requests still take the stream first. + +## Reverse check + +With the read-only reject restored, the unit test +`allows export when the document is readonly (#307)` fails (no +`asc_DownloadAs`). With the embed early-return restored, File-tab downloads +inside an iframe produce the stream log and no file again. diff --git a/docs/test-matrix.md b/docs/test-matrix.md index df292ce4b..4d7503c0c 100644 --- a/docs/test-matrix.md +++ b/docs/test-matrix.md @@ -20,59 +20,62 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec ## 现在真正空着的格子 -按"补它需要一个决定还是一次动手"分组,而不是按表格顺序——十一处 ⬜ 里只有三件事(ODF 那一族是新点出来的,不是新出现的)。 +按"补它需要一个决定还是一次动手"分组,而不是按表格顺序。 1. **doc / xls / ppt 的插图与幂等**。导出 PDF 与只读已于 2026-09-12 经 `CORPUS_DEEP=1` 补上(复用已打开的文档,每篇几百毫秒);剩下这两个都要**再开一次 文档**,那是另一个量级的成本,等 deep 两步在夜间跑出实际耗时再决定。 -2. **一次动手就能补的**:pdf 的环境类失败自动重试、pptx 的裸 `document:save` 默认格式、 - ODF 三种的只读与幂等。 -3. **要外部条件的**:真实安卓设备、右键菜单(vendor 的上下文菜单在 canvas 里, +2. **要外部条件的**:真实安卓设备、右键菜单(vendor 的上下文菜单在 canvas 里, 没有可枚举的 DOM)、旅程本体(需要先把 `actions/` 的动作库补齐)。 +3. **已于 2026-11-06 补上**:pptx/pdf 裸 `document:save` 默认(`SD`)、ODF 只读与 + 再保存幂等(`OD`)。pdf 环境类打开重试标为 **—**:pdf 走 `localOpenFromBinary`, + 不经 `convertToBin`,OR 那套 boot-state 故障注入用不上。 ## A. 格式 × 操作(合成语料 = PR 档;真实语料 = 🌙) -| 操作 \ 格式 | docx | doc | xlsx | xls | pptx | ppt | csv | pdf | -| --------------------------- | -------------- | --- | ---------------------- | --------------- | ---------- | --------- | ------ | ---------- | -| 打开(合成) | ER, FN, SD, MT | ✱ | ER, FN, MT | HX(HTML 伪装) | FP, RI, VR | ✱ | ER | ER, PR | -| 打开(真实) | 🌙 | 🌙 | 🌙 | 🌙(POI) | 🌙 | 🌙(POI) | 🌙 | PR | -| 键盘编辑 | MT, VR 🌙 | 🌙 | MT, ER(Ctrl+S) 🌙 | 🌙 | VR 🌙 | 🌙 | 🌙 | PR(注释) | -| 保存往返(L1 结构) | ER, SD, FN, RI | 🌙 | ER, FN, HX, RI | 🌙 | RI, FP | 🌙 | ER | PR | -| 保存往返(L2 内容比对) | RI, DF, CM, VR | 🌙 | ER, FN, HX, XF, XP, CM | HX | RI, VR | 🌙 | ER, CE | — | -| 导出 PDF | FP, PC | 🌙 | ER | 🌙 | FP | 🌙 | 🌙 | — | -| 只读打开 / 运行时切换 | FP | 🌙 | ER | 🌙 | FP | 🌙 | FP | PR | -| 插图后保存 | ER | ✱ | II | ✱ | II | ✱ | — | — | -| 评论 | CM | ✱ | CM | ✱ | CM | ✱ | — | PR(注释) | -| 再打开→再保存(幂等) | RI | ✱ | RI | ✱ | RI | ✱ | RI | PR | -| 打开失败可见 + 保存快速拒绝 | — | — | OF | — | — | — | — | OF | -| 环境类打开失败自动重试 | — | — | OR(故障注入) | — | — | — | — | ⬜ | -| 裸 `document:save` 默认格式 | SD | ✱ | FN | HX(→xlsx) | ⬜ | ✱ | ER | ⬜ | +| 操作 \ 格式 | docx | doc | xlsx | xls | pptx | ppt | csv | pdf | +| --------------------------- | -------------- | --- | ---------------------- | --------------- | ---------- | --------- | ------ | -------------------------- | +| 打开(合成) | ER, FN, SD, MT | ✱ | ER, FN, MT | HX(HTML 伪装) | FP, RI, VR | ✱ | ER | ER, PR | +| 打开(真实) | 🌙 | 🌙 | 🌙 | 🌙(POI) | 🌙 | 🌙(POI) | 🌙 | PR | +| 键盘编辑 | MT, VR 🌙 | 🌙 | MT, ER(Ctrl+S) 🌙 | 🌙 | VR 🌙 | 🌙 | 🌙 | PR(注释) | +| 保存往返(L1 结构) | ER, SD, FN, RI | 🌙 | ER, FN, HX, RI | 🌙 | RI, FP | 🌙 | ER | PR | +| 保存往返(L2 内容比对) | RI, DF, CM, VR | 🌙 | ER, FN, HX, XF, XP, CM | HX | RI, VR | 🌙 | ER, CE | — | +| 导出 PDF | FP, PC | 🌙 | ER | 🌙 | FP | 🌙 | 🌙 | — | +| 只读打开 / 运行时切换 | FP | 🌙 | ER | 🌙 | FP | 🌙 | FP | PR | +| 插图后保存 | ER | ✱ | II | ✱ | II | ✱ | — | — | +| 评论 | CM | ✱ | CM | ✱ | CM | ✱ | — | PR(注释) | +| 再打开→再保存(幂等) | RI | ✱ | RI | ✱ | RI | ✱ | RI | PR | +| 打开失败可见 + 保存快速拒绝 | — | — | OF | — | — | — | — | OF | +| 环境类打开失败自动重试 | — | — | OR(故障注入) | — | — | — | — | —(pdf 不经 convertToBin) | +| 裸 `document:save` 默认格式 | SD | ✱ | FN | HX(→xlsx) | SD | ✱ | ER | SD | **✱ = 旧二进制格式,合成不出夹具,只能靠真实语料(🌙)。** 2026-09-12 起语料跑道 在 `CORPUS_DEEP=1`(夜间已开)下多走两步,都复用已经打开的那篇文档、不再开第二次: -**导出 PDF**(判 `%PDF-` magic)与**运行时只读**(上锁后保存必须被拒、解锁后必须还回来)。 +**导出 PDF**(判 `%PDF-` magic)与**运行时只读**(上锁后仍可导出、解锁后必须还回来;#307)。 所以 ✱ 里的这两个动作现在是覆盖的,剩下没覆盖的是**插图**与**幂等**——两者都要再开一次 文档,成本是另一回事,等这两步的实际耗时有数据了再决定。 -### A2. ODF(odt / ods / odp) +### A2. ODF (odt / ods / odp) 引擎读得了、文件选择器也提供,但上面那张表按 OOXML 的格式分列,把它们整族漏在外面 ——这正是台账自己会过期的样子。它们由 `OD` = odf-formats.spec 覆盖:三种各自打开 (页内手拼最小 ODF 包)、存回原格式、导出 PDF,另加一条"文件选择器提供引擎能打开的 每一种格式"。 -| 操作 | odt | ods | odp | -| -------------- | --- | --- | --- | -| 打开(合成) | OD | OD | OD | -| 保存往返(L1) | OD | OD | OD | -| 导出 PDF | OD | OD | OD | -| 其余动作 | ⬜ | ⬜ | ⬜ | +| 操作 | odt | ods | odp | +| --------------------- | --- | --- | ----------------------------------- | +| 打开(合成) | OD | OD | OD | +| 保存往返(L1) | OD | OD | OD | +| 导出 PDF | OD | OD | OD | +| 只读打开 / 仍可导出 | OD | OD | OD | +| 再打开→再保存(幂等) | OD | OD | —(最小 odp 存回后再开 x2t 签名错) | +| 其余动作 | ⬜ | ⬜ | ⬜ | ## B. 输入特征 | 特征 | 覆盖 | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| 文件名:ASCII / CJK / 空格括号 / 全角标点 / emoji / `&%'!` / 180 字符 | FN(xlsx 全部 7 种;docx CJK+空格;pptx CJK+`&%'!`+180 字符) | +| 文件名:ASCII / CJK / 空格括号 / 全角标点 / emoji / `&%'!` / 180 字符 | FN(xlsx 全部 7 种;docx CJK+ 空格;pptx CJK+`&%'!`+180 字符) | | 体积:KB 级 | 全部合成用例 | | 体积:MB~60MB | `LD` = large-document.spec(2 MB / 2 万段 docx,正文长度 + 首中末三点 + 90s 预算)+ 🌙(EMP deck 6.5MB 产物) | | 编码:GBK CSV / GBK HTML 表 | `CE`(GBK CSV 真编辑器往返)+ HX + 单测 | @@ -90,7 +93,7 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 手机视口(Pixel 5 模拟,桌面 vendor bundle) | `MS` = mobile-slide.spec(紧凑版式后的画布宽度与初始缩放 pptx/docx/xlsx、横屏挂载、缩放连打无错误、画布上下文丢失后重绘、窗口变窄时动态跟随);真实安卓设备 ⬜ | | 全新 profile 冷启动 | 每个 Playwright 用例即冷 profile | -| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开+保存往返);"SW 缓存旧构建"升级路径:策略改为新 SW 等待、无文档时才 SKIP_WAITING + 重载(`lib/sw-update.ts`,单测 `test/unit/sw-update.test.ts` + sw.js 契约);双构建端到端 `SU` = sw-silent-update.spec(改写被服务的 `dist/sw.js` 模拟一次部署,`@serial` 独占跑) | +| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开 + 保存往返);"SW 缓存旧构建"升级路径:策略改为新 SW 等待、无文档时才 SKIP_WAITING + 重载(`lib/sw-update.ts`,单测 `test/unit/sw-update.test.ts` + sw.js 契约);双构建端到端 `SU` = sw-silent-update.spec(改写被服务的 `dist/sw.js` 模拟一次部署,`@serial` 独占跑) | | standalone 主站 | AS(加载 / manifest)+ `MT` = main-site.spec(hero 打开本地 docx → 打字 → Ctrl+S 下载;hero 新建 xlsx → Ctrl+S) | | 入口路径:`?file=` / embed `document:open-url` / 落地页 `?open=local`(IndexedDB 交接) | `EP` = entry-paths.spec(另起本地 HTTP 源做跨域真实 fetch) | | embed(iframe) | ER / EA / 其余全部 | @@ -102,7 +105,7 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec | Chromium | 全部 | | WebKit / Firefox | `playwright.browsers.config.ts`(夜间 `nightly-corpus.yml` 的 cross-browser job,本仓库唯一的非 Chromium 覆盖;与 ci.yml 同样把 `@serial` 分成两趟) | | 视觉比对(L3) | `VR` = visual-roundtrip.spec(docx / xlsx / pptx:原始 vs 存回再打开逐像素)+ corpus `CORPUS_VISUAL=1`。**刻意不存基线图**:基线要跟着 vendor 升级和字体目录一起维护,而同一浏览器里的两次渲染不需要维护,也不会因为缺字形而假红 | -| 性能预算(L4) | corpus 报告按格式输出 open/save p50/p95;`SN` = slow-network.spec(`SLOW_NET=1`,CDP 节流 4 Mbps/150 ms、禁 SW:冷启动+首存 < 150 s,实测首存约 44 s;夜间 job `budgets`);xlsx-features 2 万行 60 s 预算 | +| 性能预算(L4) | corpus 报告按格式输出 open/save p50/p95;`SN` = slow-network.spec(`SLOW_NET=1`,CDP 节流 4 Mbps/150 ms、禁 SW:冷启动 + 首存 < 150 s,实测首存约 44 s;夜间 job `budgets`);xlsx-features 2 万行 60 s 预算 | ## D. 交互入口(策略第 9 节) diff --git a/lib/onlyoffice/save-stream.ts b/lib/onlyoffice/save-stream.ts index f7524c9db..80a6fe262 100644 --- a/lib/onlyoffice/save-stream.ts +++ b/lib/onlyoffice/save-stream.ts @@ -4,7 +4,6 @@ import { X2TConverter, saveFileToDisk } from '@ranuts/converter'; import { oAscFileType } from '../file-types'; import { getFileExtension, getNormalizedFile, getSavedFileMimeType } from './file-helpers'; import { getDocumentLifetimeSignal, getDocumentOpenError, waitForDocumentContentReady } from './open-state'; -import { getReadonlyMode } from './readonly'; import { isEmbedMode } from '../embed-mode'; import { markDocumentSaved } from '../unsaved-guard'; import type { SaveOutcome } from '../save-target'; @@ -220,8 +219,13 @@ function routeSavedFile(file: File, state: ReturnType notifyOperationFailed(error)); return; } @@ -412,9 +416,9 @@ export function requestSaveDocument( return Promise.reject(new Error('No document is open')); } - if (getReadonlyMode()) { - return Promise.reject(new Error('Current document is readonly')); - } + // Read-only blocks editing, not exporting. Embed hosts call document:save + // for the bytes, and the File tab's download uses the same stream — both + // must work on a locked document (#307). Autosave still skips read-only. const openError = getDocumentOpenError(); if (openError) { diff --git a/test/e2e/corpus.spec.ts b/test/e2e/corpus.spec.ts index 63e03aee3..1aa3d2784 100644 --- a/test/e2e/corpus.spec.ts +++ b/test/e2e/corpus.spec.ts @@ -107,7 +107,7 @@ type Row = { // saved output, both re-opened readonly. visual: string; // CORPUS_DEEP: PDF export of the pristine document, and whether a runtime - // readonly lock actually refuses a save. + // readonly lock still allows export while blocking edits (#307). exportPdf: string; readonly: string; ascErrors: unknown[]; @@ -472,25 +472,22 @@ test.describe('real-document corpus matrix', () => { } // ---- runtime readonly (CORPUS_DEEP) ---- - // Locking has to actually refuse a save, and unlocking has to give the - // document back. Both directions matter: a lock that does not hold loses - // the guarantee, and a lock that will not lift strands the document. + // Locking must keep the document read-only and still allow export (#307), + // and unlocking must give editing back. A lock that does not hold loses + // the guarantee; a lock that will not lift strands the document; a lock + // that blocks export leaves download controls looking broken. if (DEEP && !row.fatalDialog && load.loaded) { row.readonly = await page.evaluate(async () => { try { await post('document:set-readonly', { readonly: true }); const state = await post('document:get-state', {}); if (!state.readonly) return 'fail: set-readonly reported not readonly'; - let refused = false; - try { - await post('document:save', {}); - } catch { - refused = true; - } + const saved = await post('document:save', {}); + if (!saved?.file?.size) return 'fail: export while locked returned empty file'; await post('document:set-readonly', { readonly: false }); const back = await post('document:get-state', {}); if (back.readonly) return 'fail: still readonly after unlocking'; - return refused ? 'ok' : 'fail: save went through while locked'; + return 'ok'; } catch (e) { return `inconclusive: ${String((e as Error).message || e).slice(0, 80)}`; } diff --git a/test/e2e/embed-regression.spec.ts b/test/e2e/embed-regression.spec.ts index 45f64a3d3..5297382c1 100644 --- a/test/e2e/embed-regression.spec.ts +++ b/test/e2e/embed-regression.spec.ts @@ -107,7 +107,7 @@ test.describe('embed regression (real editor)', () => { expect(result.text.trim()).toBe(original); }); - test('readonly open reports readonly state and refuses to save (#25, #87)', async ({ page }) => { + test('readonly open reports readonly state and still allows export (#25, #87, #307)', async ({ page }) => { const result = await page.evaluate(async () => { const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([['view only']]), 'Sheet1'); @@ -119,18 +119,17 @@ test.describe('embed regression (real editor)', () => { readonly: true, }); const state = await post('document:get-state', {}); - let saveError = ''; - try { - await post('document:save', {}); - } catch (error) { - saveError = String((error as Error).message || error); - } - return { opened, state, saveError }; + // Read-only blocks editing, not exporting (#307). + const saved = await post('document:save', {}); + const parsed = XLSX.read(new Uint8Array(await saved.file.arrayBuffer()), { type: 'array' }); + const text = XLSX.utils.sheet_to_csv(parsed.Sheets[parsed.SheetNames[0]]).trim(); + return { opened, state, savedName: saved.file.name as string, text }; }); expect(result.opened.readonly).toBe(true); expect(result.state).toEqual({ readonly: true, hasDocument: true, dirty: false }); - expect(result.saveError).not.toBe(''); + expect(result.savedName).toBe('readonly.xlsx'); + expect(result.text).toBe('view only'); }); test('runtime readonly toggle locks and unlocks the live editor without a rebuild', async ({ page }) => { @@ -178,12 +177,8 @@ test.describe('embed regression (real editor)', () => { await post('document:set-readonly', { readonly: true }); await waitForRestriction(128); - let lockedSaveError = ''; - try { - await post('document:save', {}); - } catch (error) { - lockedSaveError = String((error as Error).message || error); - } + // Export still works while locked (#307). + const lockedSaved = await post('document:save', {}); await post('document:set-readonly', { readonly: false }); await waitForRestriction(0); @@ -191,10 +186,16 @@ test.describe('embed regression (real editor)', () => { const parsed = XLSX.read(new Uint8Array(await saved.file.arrayBuffer()), { type: 'array' }); const text = XLSX.utils.sheet_to_csv(parsed.Sheets[parsed.SheetNames[0]]).trim(); - return { lockedSaveError, savedFileName: saved.file.name as string, text }; + return { + lockedSavedName: lockedSaved.file.name as string, + lockedSize: lockedSaved.file.size as number, + savedFileName: saved.file.name as string, + text, + }; }); - expect(result.lockedSaveError).not.toBe(''); + expect(result.lockedSavedName).toBe('toggle.xlsx'); + expect(result.lockedSize).toBeGreaterThan(0); expect(result.savedFileName).toBe('toggle.xlsx'); expect(result.text).toBe('toggle,1'); }); diff --git a/test/e2e/format-parity.spec.ts b/test/e2e/format-parity.spec.ts index b9f006974..f49c61ca5 100644 --- a/test/e2e/format-parity.spec.ts +++ b/test/e2e/format-parity.spec.ts @@ -48,7 +48,7 @@ test.describe('format parity: docx / pptx / csv (real editor)', () => { expect(result.size).toBeGreaterThan(500); }); - test(`${doc.label}: readonly open reports readonly and refuses to save`, async ({ page }) => { + test(`${doc.label}: readonly open reports readonly and still allows export (#307)`, async ({ page }) => { const result = await page.evaluate( async ({ name, b64 }) => { const bin = atob(b64); @@ -56,19 +56,21 @@ test.describe('format parity: docx / pptx / csv (real editor)', () => { for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const opened = await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: true }); const state = await post('document:get-state', {}); - let saveError = ''; - try { - await post('document:save', {}); - } catch (e) { - saveError = String((e as Error).message || e); - } - return { opened, state, saveError }; + const saved = await post('document:save', {}); + const out = new Uint8Array(await saved.file.arrayBuffer()); + return { + opened, + state, + savedName: saved.file.name as string, + magic: Array.from(out.slice(0, 2)), + }; }, { name: doc.name, b64: doc.b64() }, ); expect(result.opened.readonly).toBe(true); expect(result.state).toEqual({ readonly: true, hasDocument: true, dirty: false }); - expect(result.saveError).not.toBe(''); + expect(result.savedName).toBe(doc.name); + expect(result.magic).toEqual([0x50, 0x4b]); }); } @@ -81,27 +83,29 @@ test.describe('format parity: docx / pptx / csv (real editor)', () => { return btoa(raw); }; - test('csv: readonly open reports readonly and refuses to save', async ({ page }) => { + test('csv: readonly open reports readonly and still allows export (#307)', async ({ page }) => { const result = await page.evaluate( - async ({ name, b64, source }) => { + async ({ name, b64 }) => { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); const opened = await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: true }); const state = await post('document:get-state', {}); - let saveError = ''; - try { - await post('document:save', {}); - } catch (e) { - saveError = String((e as Error).message || e); - } - return { opened, state, saveError, source }; + const saved = await post('document:save', {}); + const out = new Uint8Array(await saved.file.arrayBuffer()); + return { + opened, + state, + savedName: saved.file.name as string, + text: new TextDecoder('utf-8').decode(out).trim(), + }; }, - { name: CSV_NAME, b64: csvB64(), source: CSV_SOURCE }, + { name: CSV_NAME, b64: csvB64() }, ); expect(result.opened.readonly).toBe(true); expect(result.state).toEqual({ readonly: true, hasDocument: true, dirty: false }); - expect(result.saveError).not.toBe(''); + expect(result.savedName).toBe(CSV_NAME); + expect(result.text).toBe(CSV_SOURCE); }); test('csv: runtime readonly toggle locks and unlocks, and the unlocked save is still a CSV', async ({ page }) => { @@ -130,26 +134,25 @@ test.describe('format parity: docx / pptx / csv (real editor)', () => { await waitFor(0); await post('document:set-readonly', { readonly: true }); await waitFor(128); - let lockedSaveError = ''; - try { - await post('document:save', {}); - } catch (e) { - lockedSaveError = String((e as Error).message || e); - } + const lockedSaved = await post('document:save', {}); await post('document:set-readonly', { readonly: false }); await waitFor(0); const saved = await post('document:save', {}); const out = new Uint8Array(await saved.file.arrayBuffer()); + const lockedOut = new Uint8Array(await lockedSaved.file.arrayBuffer()); return { - lockedSaveError, + lockedName: lockedSaved.file.name as string, + lockedText: new TextDecoder('utf-8').decode(lockedOut).trim(), name: saved.file.name as string, text: new TextDecoder('utf-8').decode(out).trim(), }; }, { name: CSV_NAME, b64: csvB64() }, ); - expect(result.lockedSaveError).not.toBe(''); - // Still a CSV on the way out, not the workbook it was opened as. + // Still a CSV on the way out, not the workbook it was opened as — including + // while the editor was locked (#307). + expect(result.lockedName).toBe(CSV_NAME); + expect(result.lockedText).toBe(CSV_SOURCE); expect(result.name).toBe(CSV_NAME); expect(result.text).toBe(CSV_SOURCE); }); @@ -185,21 +188,23 @@ test.describe('format parity: docx / pptx / csv (real editor)', () => { await waitFor(0); await post('document:set-readonly', { readonly: true }); await waitFor(128); - let lockedSaveError = ''; - try { - await post('document:save', {}); - } catch (e) { - lockedSaveError = String((e as Error).message || e); - } + const lockedSaved = await post('document:save', {}); await post('document:set-readonly', { readonly: false }); await waitFor(0); const saved = await post('document:save', {}); const out = new Uint8Array(await saved.file.arrayBuffer()); - return { lockedSaveError, name: saved.file.name as string, magic: Array.from(out.slice(0, 2)) }; + const lockedOut = new Uint8Array(await lockedSaved.file.arrayBuffer()); + return { + lockedName: lockedSaved.file.name as string, + lockedMagic: Array.from(lockedOut.slice(0, 2)), + name: saved.file.name as string, + magic: Array.from(out.slice(0, 2)), + }; }, { name: doc.name, b64: doc.b64() }, ); - expect(result.lockedSaveError).not.toBe(''); + expect(result.lockedName).toBe(doc.name); + expect(result.lockedMagic).toEqual([0x50, 0x4b]); expect(result.name).toBe(doc.name); expect(result.magic).toEqual([0x50, 0x4b]); }); diff --git a/test/e2e/pdf-roundtrip.spec.ts b/test/e2e/pdf-roundtrip.spec.ts index e879c00ae..fa2ea8d33 100644 --- a/test/e2e/pdf-roundtrip.spec.ts +++ b/test/e2e/pdf-roundtrip.spec.ts @@ -159,7 +159,7 @@ test.describe('pdf open / annotate / save (real pdf editor)', () => { expect(result.second.freeText).toBe(result.first.freeText); }); - test('a PDF opened readonly refuses to save', async ({ page }) => { + test('a PDF opened readonly still allows export (#307)', async ({ page }) => { const result = await page.evaluate(async (docxB64) => { const bin = atob(docxB64); const docx = new Uint8Array(bin.length); @@ -168,15 +168,16 @@ test.describe('pdf open / annotate / save (real pdf editor)', () => { const exported = await post('document:save', { targetExt: 'PDF' }); const pdf = new Uint8Array(await exported.file.arrayBuffer()); const opened = await post('document:open-buffer', { fileName: 'ro.pdf', buffer: pdf.buffer, readonly: true }); - let saveError = ''; - try { - await post('document:save', {}); - } catch (e) { - saveError = String((e as Error).message || e); - } - return { opened, saveError }; + const saved = await post('document:save', {}); + const out = new Uint8Array(await saved.file.arrayBuffer()); + return { + opened, + savedName: saved.file.name as string, + magic: new TextDecoder('ascii').decode(out.slice(0, 5)), + }; }, DOCX_B64); expect(result.opened.readonly).toBe(true); - expect(result.saveError).not.toBe(''); + expect(result.savedName).toBe('ro.pdf'); + expect(result.magic).toBe('%PDF-'); }); }); diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index e3027452c..50cd63f71 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -127,11 +127,20 @@ describe('onlyoffice-editor', () => { await expect(requestSaveDocument()).rejects.toThrow('No document is open'); }); - it('rejects when the document is readonly', async () => { + it('allows export when the document is readonly (#307)', async () => { + // Read-only blocks editing, not exporting: embed hosts and File → Download + // both need the bytes of a locked document. + const { iframe, requestedFileTypes } = installEditorFrame(); (window as any).editor = makeEditor({ downloadAs: vi.fn() }); setReadonlyMode(true); - await expect(requestSaveDocument()).rejects.toThrow('readonly'); + const promise = requestSaveDocument('xlsx').catch(() => {}); + await vi.advanceTimersByTimeAsync(150_000); + + expect(requestedFileTypes).toEqual([257]); // oAscFileType.XLSX + iframe.remove(); + vi.runAllTimers(); + await promise; }); it('rejects when editor does not support downloadAs', async () => {