Skip to content

Commit 7b744ee

Browse files
chaxusclaude
andcommitted
fix(i18n): an image URL that fails must not report it in Chinese
errorBadImageUrl is an ordinary vendor locale key -- all 45 locale files carry a translation, and the editor reads it when the engine raises UplImageUrl, which is what an inserted image URL that 404s or fails CORS produces. The offline build's Offline controller then overwrites it on the controller instance inside loadDocument with a hardcoded Chinese sentence, so every user of this site saw Chinese there in all seven languages -- and the sentence advises configuring editorConfig.imageProxy, which our users cannot reach and which we deliberately do not set. Guard 13 captures whatever the locale merge left on the prototype and replaces the property with an accessor whose setter swallows exactly that literal; every other write goes through, so a vendor upgrade that reworded the message legitimately still wins. If loadDocument already ran, the instance's own copy is deleted so reads fall through. If the locale fetch has not resolved the guard reports "not yet" rather than pinning an empty string. Reverse-validated: with the guard commented out of iframe-guards.ts both E2E cases fail on the Chinese literal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKNHFQ1kek1cHeqhbH37Pk
1 parent a817d22 commit 7b744ee

7 files changed

Lines changed: 409 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ notes. Entries describe what users experience, not internal refactors.
110110

111111
### Fixed
112112

113+
- **An image that will not load now says so in your language.** When a picture
114+
inserted by URL could not be fetched, the editor reported it in Chinese --
115+
whatever language you were using -- and suggested a setting that does not
116+
exist on this site. The message the editor already carries in 45 languages is
117+
shown instead.
113118
- **The top bar is one row again.** The language button was 8px shorter than
114119
the links beside it, so hovering GitHub and hovering the language name lit up
115120
two differently sized boxes; on a phone it was the other way round. The

‎CLAUDE.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ lib/ # 应用层(纯 TypeScript,只在本站点用)
6363
loading.ts # 加载状态 UI
6464
onlyoffice-editor.ts # 编辑器生命周期门面:挂载/重建/loadEditorApi,并对外统一导出下面这些模块
6565
onlyoffice/ # 编辑器周边(2026-08-19 从 1975 行的单文件拆出,公开导出面不变)
66-
iframe-guards.ts # 12 条运行时守卫的编排;每条守卫一个文件在 guards/
66+
iframe-guards.ts # 13 条运行时守卫的编排;每条守卫一个文件在 guards/
6767
guards/ # chrome / shared-worker / fetch-fonts / image-pipeline /
6868
# serverless-save / long-action / series-settings /
6969
# font-loading / comment-selection / canvas-loss /
7070
# wasm-binary-release / unload-prompt / hint-fallback /
71-
# about-source
71+
# about-source / bad-image-url
7272
open-state.ts # 就绪、打开失败、frame 首个错误(三处共用的单一状态源)
7373
open-failure.ts # 失败分类、-82 guard、环境类失败重开一次(经 setOpenRunner 注入避免环)
7474
font-system.ts # 字体系统就绪判定 + awaitFontSystem(#144)
@@ -829,6 +829,11 @@ v7 代码分支(OO_VARIANT、页面级 x2t 打开转换、empty_bin 模板、v
829829
(`?new=docx`)要到几分钟后的首次保存才加载 x2t,那时定时器早停、
830830
onDocumentReady 也早过了——所以它给 frame 的 `Module` 与 `calledRun` 装
831831
accessor 订阅,装上即算就位)、字体加载加速、
832+
守卫 13(`guards/bad-image-url.ts`:离线补丁在 `loadDocument` 里用实例属性
833+
把 `errorBadImageUrl` 盖成一句硬编码中文,而它本是 vendor 的标准 locale 键、45 种
834+
语言都有译文——七种语言的用户插图失败时都看到中文,且文案让人去配我们**故意不设**的
835+
`editorConfig.imageProxy`。守卫在原型上装 accessor 只吞这一个字面量,别的写入照过;
836+
`test/unit/vendor-bad-image-url.test.ts` 钉住字面量与回退译文)、
832837
`installOpenFailureGuard`(打开转换失败 → asc_onError -82 + toast + 遮罩终止 +
833838
保存快速拒绝)——其中 image pipeline 修的是"文档含图片
834839
时保存令主线程永久卡死":无服务器时 sendImgUrls 注册不了图片,DOCY
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# The one string the offline patch hardcodes in Chinese
2+
3+
2026-09-12
4+
5+
## What users saw
6+
7+
Insert a picture by URL, have the fetch fail -- a 404, a host that does not send
8+
CORS headers -- and the editor reports:
9+
10+
> 无法加载图片:地址无效或目标站不允许跨域访问(可通过 editorConfig.imageProxy 配置图片代理)
11+
12+
In every one of the seven languages this site ships. On a Japanese or German or
13+
Korean UI, that sentence. And it advises configuring `editorConfig.imageProxy`,
14+
which is not a knob our users can reach -- it is a DocEditor config value, and
15+
we deliberately do not set it, because routing a user's image URLs through a
16+
third-party proxy is the one thing this site promises not to do.
17+
18+
Found while reading the vendor for
19+
[2026-09-12-server-mock-already-in-vendor.md](2026-09-12-server-mock-already-in-vendor.md);
20+
it was not what we were looking for.
21+
22+
## Why it happens
23+
24+
`errorBadImageUrl` is an ordinary vendor locale key. All 45 locale files carry
25+
a translation:
26+
27+
| locale | value |
28+
| ------ | ---------------------------- |
29+
| en | Image URL is incorrect |
30+
| zh | 图片URL地址不正确 |
31+
| ja | 画像のURLが正しくありません |
32+
| ko | 이미지 URL이 잘못되었습니다. |
33+
34+
`Common.Locale._applyLocalization` walks each dotted key and writes it onto
35+
`window.<NS>.Controllers.Main`, and the controller definition then folds that
36+
object into the prototype with the usual idiom:
37+
38+
```js
39+
DE.Controllers.Main = Backbone.Controller.extend(_.extend({ ...definition }, DE.Controllers.Main || {}));
40+
```
41+
42+
The editor reads it as `this.errorBadImageUrl` when the engine raises
43+
`Asc.c_oAscError.ID.UplImageUrl`.
44+
45+
The offline build's `Offline` controller -- the same patch that provides the
46+
in-process server responder -- then overwrites it, on the **instance**, inside
47+
`loadDocument`:
48+
49+
```js
50+
DE.Controllers.Main.prototype.loadDocument = async function (e) {
51+
original.call(this, e);
52+
if (window.isOffline) {
53+
this.errorBadImageUrl = '无法加载图片:地址无效或目标站不允许跨域访问(可通过 editorConfig.imageProxy 配置图片代理)';
54+
this.editorConfig?.imageProxy && (window.offlineImageProxy = this.editorConfig.imageProxy);
55+
...
56+
}
57+
};
58+
```
59+
60+
An own property on the instance shadows the translated one on the prototype, so
61+
the locale never gets a say.
62+
63+
`documenteditor`, `spreadsheeteditor` and `presentationeditor` all carry the
64+
assignment (in `main/app.js` and its `main/ie/app.js` twin). `pdfeditor` has the
65+
locale key but no override.
66+
67+
## The fix
68+
69+
Not to translate the sentence -- the vendor already translated the original into
70+
more languages than we ship. To keep the assignment from landing.
71+
72+
`lib/onlyoffice/guards/bad-image-url.ts` (guard 13) captures whatever the locale
73+
merge left on the prototype and replaces the property with an accessor whose
74+
setter swallows exactly that one literal:
75+
76+
```ts
77+
Object.defineProperty(proto, 'errorBadImageUrl', {
78+
get() {
79+
return this.__ooBadImageUrl ?? localized;
80+
},
81+
set(value) {
82+
if (value !== OFFLINE_BAD_IMAGE_URL) this.__ooBadImageUrl = value;
83+
},
84+
});
85+
```
86+
87+
Every other write goes through, so a vendor upgrade that reworded the message
88+
legitimately still wins. If `loadDocument` already ran, the instance carries its
89+
own copy, and the guard deletes it -- reads then fall through to the accessor.
90+
91+
If the locale fetch has not resolved yet there is nothing worth protecting, so
92+
the guard reports "not yet" and the guard timer re-applies. It can never leave
93+
the property emptier than it found it.
94+
95+
Deliberately **not** done: setting `editorConfig.imageProxy`. The patch reads it
96+
into `window.offlineImageProxy` and falls back to `proxy + encodeURIComponent(url)`
97+
when a direct image fetch fails, which would make cross-origin image insertion
98+
work -- by sending the URL, and the user's interest in it, to whoever runs the
99+
proxy. That is a product decision, not a bug fix.
100+
101+
## Tests
102+
103+
`test/e2e/bad-image-url-locale.spec.ts` opens a real docx and a real xlsx and
104+
reads the property the error handler reads, off the live controller: it must not
105+
be the Chinese literal, and must be the locale's string.
106+
107+
`test/unit/vendor-bad-image-url.test.ts` pins the vendor side -- that the three
108+
apps still assign exactly the literal the guard matches, and that the translated
109+
original is still present in `en.json` and in every language the site ships. A
110+
vendor upgrade that reworded or dropped the override turns it red, which is the
111+
signal to re-examine the guard rather than re-pin it.
112+
113+
Reverse validation (convention 5): with `installBadImageUrlGuard` commented out
114+
of `iframe-guards.ts`, both E2E cases fail with
115+
116+
```
117+
Expected: not "无法加载图片:地址无效或目标站不允许跨域访问(可通过 editorConfig.imageProxy 配置图片代理)"
118+
```
119+
120+
so the defect is real and the guard is what fixes it.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Guard 13: the offline patch must not replace a translated string with a
3+
* hardcoded Chinese one.
4+
*
5+
* `errorBadImageUrl` is an ordinary vendor locale key -- every one of the 45
6+
* locale files carries a translation of it ("Image URL is incorrect",
7+
* "画像のURLが正しくありません", "이미지 URL이 잘못되었습니다."), and the usual
8+
* l10n idiom merges those onto `<NS>.Controllers.Main.prototype` when the
9+
* controller is defined. The editor reads it as `this.errorBadImageUrl` when
10+
* the engine reports `Asc.c_oAscError.ID.UplImageUrl`, which is what an
11+
* inserted image URL that 404s or fails CORS produces.
12+
*
13+
* The offline build's `Offline` controller then overwrites it, on the
14+
* instance, inside `loadDocument`:
15+
*
16+
* this.errorBadImageUrl = "无法加载图片:地址无效或目标站不允许跨域访问" +
17+
* "(可通过 editorConfig.imageProxy 配置图片代理)"
18+
*
19+
* So every user of this site, in all seven languages, gets that sentence in
20+
* Chinese when an image URL fails -- and it advises configuring
21+
* `editorConfig.imageProxy`, a knob our users have no way to reach and which
22+
* we deliberately do not set (it would route their image URLs through a third
23+
* party, which is the one thing this site promises not to do).
24+
*
25+
* The fix is not to translate the sentence: the vendor already translated the
26+
* original into more languages than we ship. It is to keep the assignment from
27+
* landing. An accessor on the prototype swallows exactly that one string and
28+
* lets every other write through, so a vendor upgrade that changes the message
29+
* legitimately still wins.
30+
*
31+
* Only word / cell / slide carry the assignment; pdfeditor has the locale key
32+
* but no override. Installing on all four is harmless and keeps the guard
33+
* honest if a future build adds it.
34+
*/
35+
36+
/** The literal the offline patch assigns. Pinned by test/unit/vendor-bad-image-url.test.ts. */
37+
export const OFFLINE_BAD_IMAGE_URL =
38+
'无法加载图片:地址无效或目标站不允许跨域访问(可通过 editorConfig.imageProxy 配置图片代理)';
39+
40+
/** Editor namespaces, one per app; exactly one exists in any given frame. */
41+
const APP_NAMESPACES = ['DE', 'SSE', 'PE', 'PDFE'] as const;
42+
43+
type MainController = {
44+
errorBadImageUrl?: string;
45+
__ooBadImageUrl?: string;
46+
};
47+
48+
type MainConstructor = { prototype?: MainController & { __ooBadImageGuarded?: boolean } };
49+
50+
type EditorNamespace = {
51+
Controllers?: { Main?: MainConstructor };
52+
getController?: (name: string) => MainController | undefined;
53+
getApplication?: () => { getController?: (name: string) => MainController | undefined } | undefined;
54+
};
55+
56+
function mainController(ns: EditorNamespace): MainController | undefined {
57+
try {
58+
return ns.getController?.('Main') ?? ns.getApplication?.()?.getController?.('Main');
59+
} catch {
60+
return undefined;
61+
}
62+
}
63+
64+
export function installBadImageUrlGuard(win: Window): boolean {
65+
const frame = win as Window & { __ooBadImageGuarded?: boolean };
66+
const namespaces = APP_NAMESPACES.map((name) => (win as unknown as Record<string, EditorNamespace>)[name]).filter(
67+
(ns): ns is EditorNamespace => Boolean(ns?.Controllers?.Main?.prototype),
68+
);
69+
// The app namespace lands during boot; report "not yet" so the caller keeps
70+
// re-applying.
71+
if (!namespaces.length) return Boolean(frame.__ooBadImageGuarded);
72+
73+
for (const ns of namespaces) {
74+
const proto = ns.Controllers!.Main!.prototype!;
75+
if (proto.__ooBadImageGuarded) continue;
76+
77+
// Whatever the locale merge left on the prototype. If the locale fetch has
78+
// not resolved yet there is nothing worth protecting, and installing now
79+
// would pin an empty string -- wait for the next pass instead.
80+
const localized = proto.errorBadImageUrl;
81+
if (!localized) continue;
82+
83+
Object.defineProperty(proto, 'errorBadImageUrl', {
84+
configurable: true,
85+
get(this: MainController) {
86+
return this.__ooBadImageUrl ?? localized;
87+
},
88+
set(this: MainController, value: string) {
89+
if (value !== OFFLINE_BAD_IMAGE_URL) this.__ooBadImageUrl = value;
90+
},
91+
});
92+
proto.__ooBadImageGuarded = true;
93+
94+
// `loadDocument` may already have run, in which case the instance carries
95+
// its own copy that shadows the accessor we just installed.
96+
const controller = mainController(ns);
97+
if (controller && Object.prototype.hasOwnProperty.call(controller, 'errorBadImageUrl')) {
98+
const own = (controller as Record<string, unknown>).errorBadImageUrl;
99+
if (own === OFFLINE_BAD_IMAGE_URL) delete (controller as Record<string, unknown>).errorBadImageUrl;
100+
}
101+
}
102+
103+
frame.__ooBadImageGuarded = true;
104+
return true;
105+
}

‎lib/onlyoffice/iframe-guards.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { releaseWasmBinary } from './guards/wasm-binary-release';
1313
import { installSingleUnloadPrompt } from './guards/unload-prompt';
1414
import { installHintFallbackGuard } from './guards/hint-fallback';
1515
import { installAboutSourceNotice } from './guards/about-source';
16+
import { installBadImageUrlGuard } from './guards/bad-image-url';
1617

1718
/**
1819
* Same-origin preparation of the editor iframe, applied from onAppReady and
@@ -55,6 +56,7 @@ export function prepareEditorIframe(): boolean {
5556
installCanvasLossGuard(win, doc);
5657
installSingleUnloadPrompt(win);
5758
installHintFallbackGuard(win);
59+
installBadImageUrlGuard(win);
5860
installAboutSourceNotice(doc);
5961
const wasmBinaryHandled = releaseWasmBinary(win);
6062

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { expect, test } from './lib/l0';
2+
import { buildDocx, buildXlsx, toBase64 } from './lib/ooxml';
3+
4+
/**
5+
* Guard 13: an image URL that fails must report it in the user's language.
6+
*
7+
* The offline patch overwrites `errorBadImageUrl` -- an ordinary vendor locale
8+
* key, translated in all 45 locale files -- with a hardcoded Chinese sentence,
9+
* on the controller instance, inside `loadDocument`. The editor reads that
10+
* property when the engine raises `Asc.c_oAscError.ID.UplImageUrl`, which is
11+
* what an inserted image URL that 404s or fails CORS produces. Before the
12+
* guard, every user of this site saw Chinese there, in all seven languages.
13+
*
14+
* This reads the property the error handler reads, on the live controller,
15+
* after a real document is open -- the value, not the mechanism, because the
16+
* mechanism is the vendor's and can move. `test/unit/vendor-bad-image-url.test.ts`
17+
* pins the literal the guard matches and the translations it falls back to.
18+
*/
19+
const OFFLINE_BAD_IMAGE_URL =
20+
'无法加载图片:地址无效或目标站不允许跨域访问(可通过 editorConfig.imageProxy 配置图片代理)';
21+
22+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
23+
24+
/** Reads `errorBadImageUrl` off the live Main controller, whichever app it is. */
25+
const readBadImageUrl = () => {
26+
type Ctl = { errorBadImageUrl?: string };
27+
type Ns = {
28+
Controllers?: { Main?: { prototype?: Ctl } };
29+
getController?: (name: string) => Ctl | undefined;
30+
getApplication?: () => { getController?: (name: string) => Ctl | undefined } | undefined;
31+
};
32+
33+
const visit = (win: Window): { app: string; fromInstance: string | null; fromPrototype: string | null } | null => {
34+
try {
35+
const scope = win as unknown as Record<string, Ns | undefined>;
36+
for (const app of ['DE', 'SSE', 'PE', 'PDFE']) {
37+
const ns = scope[app];
38+
if (!ns?.Controllers?.Main?.prototype) continue;
39+
let instance: Ctl | undefined;
40+
try {
41+
instance = ns.getController?.('Main') ?? ns.getApplication?.()?.getController?.('Main');
42+
} catch {
43+
instance = undefined;
44+
}
45+
return {
46+
app,
47+
fromInstance: instance ? (instance.errorBadImageUrl ?? null) : null,
48+
fromPrototype: ns.Controllers.Main.prototype.errorBadImageUrl ?? null,
49+
};
50+
}
51+
} catch {
52+
/* cross-origin frame, skip */
53+
}
54+
for (let i = 0; i < win.frames.length; i++) {
55+
const found = visit(win.frames[i]);
56+
if (found) return found;
57+
}
58+
return null;
59+
};
60+
return visit(window);
61+
};
62+
63+
const openBuffer = async ({ base64, fileName }: { base64: string; fileName: string }) => {
64+
const binary = atob(base64);
65+
const bytes = new Uint8Array(binary.length);
66+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
67+
await post('document:open-buffer', { fileName, buffer: bytes.buffer, readonly: false });
68+
};
69+
70+
test.describe('the bad-image-url message stays in the editor language', () => {
71+
test.describe.configure({ timeout: 240_000 });
72+
73+
for (const doc of [
74+
{ name: 'bad-image.docx', app: 'DE', bytes: () => buildDocx('Image URL message check.') },
75+
{
76+
name: 'bad-image.xlsx',
77+
app: 'SSE',
78+
bytes: () => buildXlsx({ rows: [['a', 'b']] }),
79+
},
80+
]) {
81+
test(`${doc.app}: the message the error handler reads is the translated one`, async ({ page }) => {
82+
await page.goto('/embed-demo.html');
83+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
84+
await page.evaluate(openBuffer, { base64: toBase64(doc.bytes()), fileName: doc.name });
85+
86+
await expect
87+
.poll(async () => (await page.evaluate(readBadImageUrl))?.app ?? null, { timeout: 60_000 })
88+
.toBe(doc.app);
89+
90+
const state = (await page.evaluate(readBadImageUrl))!;
91+
92+
// The controller instance is what the error handler reads through `this`.
93+
const effective = state.fromInstance ?? state.fromPrototype;
94+
expect(effective, 'the message exists at all').toBeTruthy();
95+
expect(effective).not.toBe(OFFLINE_BAD_IMAGE_URL);
96+
expect(effective).toBe('Image URL is incorrect');
97+
});
98+
}
99+
});

0 commit comments

Comments
 (0)