diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..583bad1
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["biomejs.biome"]
+}
diff --git a/README.md b/README.md
index abb32b2..983655b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,3 @@
-[中文文档](README_ZH.md) / [日本語](README_JA.md)
-

# sd-webui-bilingual-localization
@@ -7,6 +5,17 @@

+## Development
+
+```bash
+# format & lint
+bunx @biomejs/biome check --write --unsafe --config-path=./biome.json
+
+# build
+bun build ./src/main.ts --outfile ./javascript/bilingual_localization.js
+```
+
+
## Features
- Bilingual translation, no need to worry about how to find the original button.
- Compatible with language pack extensions, no need to re-import.
diff --git a/README_JA.md b/README_JA.md
deleted file mode 100644
index 12b5038..0000000
--- a/README_JA.md
+++ /dev/null
@@ -1,81 +0,0 @@
-[English Version](README.md)
-
-
-
-# sd-webui-bilingual-localization
-[Stable Diffusion web UI](https://github.com/AUTOMATIC1111/stable-diffusion-webui)のバイリンガル対応拡張機能
-
-
-
-## 特徴
-- バイリンガル対応により、元のボタンを探す必要がありません。
-- 日本語化拡張機能と互換性があり、ファイルを取り込み直す必要はありません。
-- ツールチップの動的翻訳をサポートします。
-- スコープと正規表現パターンによる柔軟な翻訳が可能です。
-
-## インストール
-
-以下の方法から選択します。
-拡張機能に対応したWebUI(2023年以降のバージョン)が必要です。
-
-#### 方法1
-
-WebUIの`Install from URL`でインストールを行います。
-
-Extensions - Install from URLを順にクリックします。
-
-1個目のテキストボックスに`https://github.com/journey-ad/sd-webui-bilingual-localization`を入力し、Installボタンをクリックします。
-
-
-
-その後、Installedパネルに切り替え、Apply and restart UIボタンをクリックします。
-
-
-
-
-#### 方法2
-
-拡張機能のディレクトリに手動でcloneします。
-
-```bash
-git clone https://github.com/journey-ad/sd-webui-bilingual-localization extensions/sd-webui-bilingual-localization
-```
-
-## 使用方法
-
-> **⚠️重要⚠️**
-> Settings - User interface - Localizationが`None`に設定されていることを確認してください。
-
-Settings - Bilingual Localizationパネルで、有効にしたい言語ファイル名を選択し、Apply settingsボタンとReload UIボタンを順にクリックします。
-
-
-
-## スコープ
-
-ローカリゼーションは、グローバルな影響を防ぐためにスコープを限定したサポートを提供します。構文規則は以下の通りです:
-- `####` スコープが指定された要素の祖先のIDと一致する場合にのみ、スコープ付きのテキストが適用されます。
-- `##@##` スコープが指定されたCSSセレクタと一致する場合にのみ、スコープ付きのテキストが適用されます。
-
-```json
- ...
- "##tab_ti##Normal": "正常", // id="tab_ti"の要素の下にある`Normal`のみが`正常`に変換されます
- "##tab_threedopenpose##Normal": "法線マップ", // id="tab_threedopenpose"の要素の下にある`Normal`のみが `法線マップ`に変換されます
- "##@.extra-networks .tab-nav button##Lora": "Loraモデル", // class=".extra-networks .tab-nav button"の要素の下にある`Lora`のみが`Loraモデル`に変換されます
- ...
-```
-
-## 正規表現パターン
-
-正規表現を使った日本語化が可能です。構文ルールは`@@`、キャプチャグループは`$n`です。ドキュメント:[String.prototype.replace()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/replace)。
-```json
-{
- ...
- "@@/^(\\d+) images in this directory, divided into (\\d+) pages$/": "このディレクトリには$1枚の画像、$2ページ",
- "@@/^Favorites path from settings: (.*)$/": "お気に入りのディレクトリパス:$1",
- ...
-}
-```
-
-## 日本語化ファイルの取得
-
-内蔵の日本語化ファイルは提供されなくなりました。サードパーティーの日本語化拡張機能をインストールし、当ページの[使用方法](#使用方法)に記載されている方法でセットアップしてください。
\ No newline at end of file
diff --git a/README_ZH.md b/README_ZH.md
deleted file mode 100644
index 87935d9..0000000
--- a/README_ZH.md
+++ /dev/null
@@ -1,81 +0,0 @@
-[English Version](README.md)
-
-
-
-# sd-webui-bilingual-localization
-[Stable Diffusion web UI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) 双语对照翻译插件
-
-
-
-## 功能
-- 全新实现的双语对照翻译功能,不必再担心切换翻译后找不到原始功能
-- 兼容原生语言包扩展,无需重新导入多语言语料
-- 支持动态title提示的翻译
-- 额外支持作用域和正则表达式替换,翻译更加灵活
-
-## 安装
-
-以下方式选择其一,需要使用支持扩展功能的 webui (2023年之后的版本)
-
-#### 方式1
-
-使用 webui 提供的`Install from URL`功能安装
-
-按下图所示,依次点击Extensions - Install from URL
-
-然后在第一个文本框内填入`https://github.com/journey-ad/sd-webui-bilingual-localization`,点击Install按钮
-
-
-之后切换到Installed面板,点击Apply and restart UI按钮
-
-
-
-#### 方式2
-
-手动克隆到你的扩展目录里
-
-```bash
-git clone https://github.com/journey-ad/sd-webui-bilingual-localization extensions/sd-webui-bilingual-localization
-```
-
-## 使用
-
-> **⚠️重要⚠️**
-> 确保Settings - User interface - Localization 已设置为了 `None`
-
-在Settings - Bilingual Localization中选择要启用的本地化文件,依次点击Apply settings和Reload UI按钮
-
-
-## 作用域支持
-
-本地化语料支持限定作用域,防止影响全局翻译,语法规则:
-- `####` 仅当节点祖先元素ID匹配指定的作用域时才会生效
-- `##@##` 仅当节点祖先元素匹配指定的CSS选择器时才会生效
-
-```json
-{
- ...
- "##tab_ti##Normal": "正态", // 仅id="tab_ti"元素下的`Normal`会被翻译为`正态`
- "##tab_threedopenpose##Normal": "法线图", // 仅id="tab_threedopenpose"元素下的`Normal`会被翻译为`法线图`
- "##@.extra-networks .tab-nav button##Lora": "Lora模型", // 仅class=".extra-networks .tab-nav button"元素下的`Lora`会被翻译为`Lora模型`
- ...
-}
-```
-
-## 正则表达式支持
-
-本地化语料支持正则表达式替换,语法规则`@@`,括号匹配变量`$n`,参考[String.prototype.replace()](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/replace)
-```json
-{
- ...
- "@@/^(\\d+) images in this directory, divided into (\\d+) pages$/": "目录中有$1张图片,共$2页",
- "@@/^Favorites path from settings: (.*)$/": "设置的收藏夹目录:$1",
- ...
-}
-```
-
-## 获取本地化文件
-
-本地化文件不再随插件提供,请安装第三方语言包并按照本文[使用](#使用)部分的方式设置使用
-
-*预览图片中的语言包可以在这里找到 https://gist.github.com/journey-ad/d98ed173321658be6e51f752d6e6163c*
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..c70281a
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "organizeImports": {
+ "enabled": true
+ },
+ "formatter": {
+ "indentStyle": "space",
+ "lineEnding": "crlf",
+ "ignore": ["javascript", "scripts"]
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ },
+ "ignore": ["javascript", "scripts"]
+ }
+}
diff --git a/javascript/bilingual_localization.js b/javascript/bilingual_localization.js
index f8c2f12..f9699c3 100644
--- a/javascript/bilingual_localization.js
+++ b/javascript/bilingual_localization.js
@@ -1,15 +1,482 @@
-(function () {
- const customCSS = `
+// src/config/opts.ts
+var opts = {
+ bilingual_localization_enabled: true,
+ bilingual_localization_logger: false,
+ bilingual_localization_file: "None",
+ bilingual_localization_dirs: "{}",
+ bilingual_localization_order: "Translation First"
+};
+
+// src/lib/create-logger.ts
+function createLogger() {
+ const loggerTimerMap = new Map;
+ const loggerConf = { badge: true, label: "Logger", enable: false };
+ return new Proxy(console, {
+ get: (target, propKey) => {
+ if (propKey === "init") {
+ return (label) => {
+ loggerConf.label = label;
+ loggerConf.enable = true;
+ };
+ }
+ if (!(propKey in target))
+ return;
+ return (...args) => {
+ if (!loggerConf.enable)
+ return;
+ let color = ["#39cfe1", "#006cab"];
+ let label;
+ let start;
+ switch (propKey) {
+ case "error":
+ color = ["#f70000", "#a70000"];
+ break;
+ case "warn":
+ color = ["#f7b500", "#b58400"];
+ break;
+ case "time":
+ label = args[0];
+ if (loggerTimerMap.has(label)) {
+ console.warn(`Timer '${label}' already exists`);
+ } else {
+ loggerTimerMap.set(label, performance.now());
+ }
+ return;
+ case "timeEnd":
+ label = args[0];
+ start = loggerTimerMap.get(label);
+ if (start === undefined) {
+ console.warn(`Timer '${label}' does not exist`);
+ } else {
+ loggerTimerMap.delete(label);
+ console.log(`${label}: ${performance.now() - start} ms`);
+ }
+ return;
+ case "groupEnd":
+ loggerConf.badge = true;
+ break;
+ }
+ const badge = loggerConf.badge ? [
+ `%c${loggerConf.label}`,
+ `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`
+ ] : [];
+ target[propKey](...badge, ...args);
+ if (propKey === "group" || propKey === "groupCollapsed") {
+ loggerConf.badge = false;
+ }
+ };
+ }
+ });
+}
+
+// src/lib/get-regax.ts
+function getRegex(regexString) {
+ try {
+ const trimmedRegexString = regexString.trim();
+ if (!trimmedRegexString.startsWith("/") || trimmedRegexString.split("/").length < 3) {
+ const escapedRegexString = trimmedRegexString.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&");
+ return new RegExp(escapedRegexString);
+ }
+ const lastSlashIndex = trimmedRegexString.lastIndexOf("/");
+ const regexPattern = trimmedRegexString.slice(1, lastSlashIndex);
+ const regexFlags = trimmedRegexString.slice(lastSlashIndex + 1);
+ return new RegExp(regexPattern, regexFlags);
+ } catch (e) {
+ return null;
+ }
+}
+
+// src/lib/delegate-event.ts
+function delegateEvent(parent, eventType, selector, handler) {
+ parent.addEventListener(eventType, (event) => {
+ let target = event.target;
+ while (target !== parent) {
+ if (target.matches(selector)) {
+ handler.call(target, event);
+ }
+ target = target.parentNode;
+ }
+ });
+}
+
+// src/lib/gradio-app.ts
+function gradioApp() {
+ const elems = document.getElementsByTagName("gradio-app");
+ const elem = elems.length === 0 ? document : elems[0];
+ if (elem !== document) {
+ elem.getElementById = (id) => document.getElementById(id);
+ }
+ return elem.shadowRoot ? elem.shadowRoot : elem;
+}
+function querySelectorAll(...args) {
+ const nodeList = gradioApp()?.querySelectorAll(...args);
+ return nodeList || new NodeList;
+}
+
+// src/lib/handle-dropdown.ts
+function handleDropdown() {
+ delegateEvent(gradioApp(), "mousedown", "ul.options .item", (event) => {
+ const { target } = event;
+ if (!target.classList.contains("item")) {
+ target.closest(".item").dispatchEvent(new Event("mousedown", { bubbles: true }));
+ return;
+ }
+ const source = target.dataset.value;
+ const $labelEl = target?.closest(".wrap")?.querySelector(".wrap-inner .single-select");
+ if (source && $labelEl) {
+ $labelEl.title = titles?.[source] || "";
+ $labelEl.textContent = "__biligual__will_be_replaced__";
+ doTranslate($labelEl, source, "element");
+ }
+ });
+}
+
+// src/lib/read-files.ts
+function readFile(filePath) {
+ const request = new XMLHttpRequest;
+ request.open("GET", `file=${filePath}`, false);
+ request.send(null);
+ return request.responseText;
+}
+
+// src/lib/translate-el.ts
+function translateEl(el, { deep = false, rich = false } = {}) {
+ if (!getI18n)
+ return;
+ if (el.matches?.(ignore_selector))
+ return;
+ if (el.title) {
+ doTranslate(el, el.title, "title");
+ }
+ if (el.placeholder && getConfig()?.enableTransPlaceHolder === true) {
+ doTranslate(el, el.placeholder, "placeholder");
+ }
+ if (el.tagName === "OPTION") {
+ doTranslate(el, el.textContent, "option");
+ }
+ if (deep || rich) {
+ Array.from(el.childNodes).forEach((node) => {
+ if (node.nodeName === "#text") {
+ if (rich) {
+ doTranslate(node, node.textContent, "text");
+ return;
+ }
+ if (deep) {
+ doTranslate(node, node.textContent, "element");
+ }
+ } else if (node.childNodes.length > 0) {
+ translateEl(node, { deep, rich });
+ }
+ });
+ } else {
+ doTranslate(el, el.textContent, "element");
+ }
+}
+var ignore_selector = [
+ ".bilingual__trans_wrapper",
+ ".resultsFlexContainer",
+ "#setting_sd_model_checkpoint select",
+ "#setting_sd_vae select",
+ "#txt2img_styles, #img2txt_styles",
+ ".extra-network-cards .card .actions .name",
+ "script, style, svg, g, path",
+ "svg *, canvas, canvas *",
+ "#txt2img_prompt_container, #img2img_prompt_container, .physton-prompt",
+ "#txt2img_prompt_container *, #img2img_prompt_container *, .physton-prompt *",
+ ".progressDiv, .progress, .progress-text",
+ ".progressDiv *, .progress *, .progress-text *"
+];
+
+// src/lib/tranlate-page.ts
+function translatePage() {
+ if (!getI18n())
+ return;
+ const logger = createLogger();
+ logger.time("Full Page");
+ const majorSelectors = [
+ "label span, fieldset span, button",
+ "textarea[placeholder], select, option",
+ ".transition > div > span:not([class])",
+ ".label-wrap > span",
+ ".gradio-image>div.float",
+ ".gradio-file>div.float",
+ ".gradio-code>div.float",
+ "#modelmerger_interp_description .output-html",
+ "#modelmerger_interp_description .gradio-html",
+ "#lightboxModal span"
+ ];
+ const minorSelectors = [
+ 'div[data-testid="image"] > div > div',
+ "#extras_image_batch > div",
+ ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown",
+ "#dynamic-prompting"
+ ];
+ majorSelectors.forEach((selector) => {
+ querySelectorAll(selector).forEach((el) => translateEl(el, { deep: true }));
+ });
+ minorSelectors.forEach((selector) => {
+ querySelectorAll(selector).forEach((el) => translateEl(el, { rich: true }));
+ });
+ logger.timeEnd("Full Page");
+}
+
+// src/setup.ts
+function setup3() {
+ config = {
+ enabled: opts.bilingual_localization_enabled,
+ file: opts.bilingual_localization_file,
+ dirs: opts.bilingual_localization_dirs,
+ order: opts.bilingual_localization_order,
+ enableLogger: opts.bilingual_localization_logger
+ };
+ const { enabled, file, dirs, enableLogger } = config;
+ if (!enabled || file === "None" || dirs === "None")
+ return;
+ const dirsParsed = JSON.parse(dirs);
+ const logger = createLogger();
+ if (enableLogger) {
+ logger.init("Bilingual");
+ }
+ logger.log("Bilingual Localization initialized.");
+ const regex_scope = /^##(?.+)##(?.+)$/;
+ i18n = JSON.parse(readFile(dirsParsed[file]), (key, value) => {
+ if (key.startsWith("@@")) {
+ const regex = getRegex(key.slice(2));
+ if (regex instanceof RegExp) {
+ i18nRegex.set(regex, value);
+ }
+ } else {
+ const match = key.match(regex_scope);
+ if (match?.groups) {
+ let { scope, skey } = match.groups;
+ if (scope.startsWith("@")) {
+ scope = scope.slice(1);
+ } else {
+ scope = `#${scope}`;
+ }
+ if (!scope.length) {
+ return value;
+ }
+ i18nScope[scope] ||= {};
+ i18nScope[scope][skey] = value;
+ scopedSource[skey] ||= [];
+ scopedSource[skey].push(scope);
+ } else {
+ return value;
+ }
+ }
+ });
+ translatePage();
+ handleDropdown();
+}
+function getI18n() {
+ return i18n;
+}
+function getI18nRegex() {
+ return i18nRegex;
+}
+function getI18nScope() {
+ return i18nScope;
+}
+function getScopedSource() {
+ return scopedSource;
+}
+function getConfig() {
+ return config;
+}
+var i18n = null;
+var i18nRegex = new Map;
+var i18nScope = {};
+var scopedSource = {};
+var config = null;
+
+// src/lib/check-regax.ts
+function checkRegex(source) {
+ const i18nRegex2 = getI18nRegex();
+ for (const [regex, value] of i18nRegex2.entries()) {
+ if (regex instanceof RegExp) {
+ if (regex.test(source)) {
+ const logger = createLogger();
+ logger.log("regex", regex, source, value);
+ return source.replace(regex, value);
+ }
+ } else {
+ console.warn("Expected regex to be an instance of RegExp, but it was a string.");
+ }
+ }
+ return source;
+}
+
+// src/lib/html-encode.ts
+function htmlEncode(htmlStr) {
+ return htmlStr.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
+}
+
+// src/lib/parse-html-string-to-element.ts
+function parseHtmlStringToElement(htmlStr) {
+ const template = document.createElement("template");
+ template.insertAdjacentHTML("afterbegin", htmlStr);
+ return template.firstElementChild;
+}
+
+// src/lib/do-translate.ts
+function doTranslate(el, source, type) {
+ if (!getI18n)
+ return;
+ let trimmedSource = source.trim();
+ if (!trimmedSource)
+ return;
+ if (re_num.test(trimmedSource))
+ return;
+ if (re_emoji.test(trimmedSource))
+ return;
+ let translation = getI18n[trimmedSource] || checkRegex(trimmedSource);
+ const scopes = getScopedSource[trimmedSource];
+ if (scopes) {
+ console.log("scope", el, trimmedSource, scopes);
+ for (const scope of scopes) {
+ if (el.parentElement.closest(scope)) {
+ translation = getI18nScope[scope][trimmedSource];
+ break;
+ }
+ }
+ }
+ if (!translation || trimmedSource === translation) {
+ if (el.textContent === "__biligual__will_be_replaced__")
+ el.textContent = trimmedSource;
+ if (el.nextSibling?.className === "bilingual__trans_wrapper")
+ el.nextSibling.remove();
+ return;
+ }
+ const config2 = getConfig();
+ if (config2?.order === "Original First") {
+ [trimmedSource, translation] = [translation, trimmedSource];
+ }
+ const isTranslationIncludeSource = translation.startsWith(source);
+ switch (type) {
+ case "text":
+ el.textContent = translation;
+ break;
+ case "element": {
+ if (isTranslationIncludeSource) {
+ if (el.nodeType === 3) {
+ el.nodeValue = translation;
+ } else if (htmlEncode(el.textContent) === el.innerHTML) {
+ el.innerHTML = htmlEncode(translation);
+ }
+ break;
+ }
+ const htmlEl = parseHtmlStringToElement(`${htmlEncode(translation)}${htmlEncode(source)}
`);
+ if (el.hasChildNodes()) {
+ const textNode = Array.from(el.childNodes).find((node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === trimmedSource || node.textContent?.trim() === "__bilingual__will_be_replaced__");
+ if (textNode) {
+ textNode.textContent = "";
+ if (textNode.nextSibling?.nodeType === Node.ELEMENT_NODE && textNode.nextSibling.className === "bilingual__trans_wrapper") {
+ textNode.nextSibling.remove();
+ }
+ if (textNode.parentNode && htmlEl) {
+ textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling);
+ }
+ }
+ } else {
+ el.textContent = "";
+ if (el.nextSibling?.nodeType === Node.ELEMENT_NODE && el.nextSibling.className === "bilingual__trans_wrapper") {
+ el.nextSibling.remove();
+ }
+ if (el.parentNode && htmlEl) {
+ el.parentNode.insertBefore(htmlEl, el.nextSibling);
+ }
+ }
+ break;
+ }
+ case "option":
+ el.textContent = isTranslationIncludeSource ? translation : `${translation} (${trimmedSource})`;
+ break;
+ case "title":
+ el.title = isTranslationIncludeSource ? translation : `${translation}\n${trimmedSource}`;
+ break;
+ case "placeholder":
+ el.placeholder = isTranslationIncludeSource ? translation : `${translation}\n\n${trimmedSource}`;
+ break;
+ default:
+ return translation;
+ }
+}
+var re_num = /^[\.\d]+$/;
+var re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u;
+
+// src/init.ts
+function init() {
+ const styleEl = document.createElement("style");
+ if (styleEl.textContent) {
+ styleEl.textContent = customCSS;
+ } else {
+ styleEl.appendChild(document.createTextNode(customCSS));
+ }
+ gradioApp().appendChild(styleEl);
+ let loaded = false;
+ let _count = 0;
+ const observer = new MutationObserver((mutations) => {
+ if (window.localization && Object.keys(window.localization).length)
+ return;
+ if (Object.keys(opts).length === 0)
+ return;
+ let _nodesCount = 0;
+ const _now = performance.now();
+ for (const mutation of mutations) {
+ if (mutation.type === "characterData") {
+ if (mutation.target?.parentElement?.parentElement?.tagName === "LABEL") {
+ translateEl(mutation.target);
+ }
+ } else if (mutation.type === "attributes") {
+ _nodesCount++;
+ translateEl(mutation.target);
+ } else {
+ mutation.addedNodes.forEach((node) => {
+ if (node instanceof Element && node.className === "bilingual__trans_wrapper")
+ return;
+ _nodesCount++;
+ if (node.nodeType === 1 && node instanceof Element && /(output|gradio)-(html|markdown)/.test(node.className)) {
+ translateEl(node, { rich: true });
+ } else if (node.nodeType === 3) {
+ doTranslate(node, node.textContent, "text");
+ } else {
+ translateEl(node, { deep: true });
+ }
+ });
+ }
+ }
+ if (_nodesCount > 0) {
+ const logger = createLogger();
+ logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations);
+ }
+ if (loaded)
+ return;
+ const i18n2 = getI18n();
+ if (i18n2)
+ return;
+ loaded = true;
+ setup3();
+ });
+ observer.observe(gradioApp(), {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["title", "placeholder"]
+ });
+}
+var customCSS = `
.bilingual__trans_wrapper {
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- font-size: 13px;
- line-height: 1;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: var(--section-header-text-size);
+ line-height: 1;
}
-
+
.bilingual__trans_wrapper em {
- font-style: normal;
+ font-style: normal;
}
#txtimg_hr_finalres .bilingual__trans_wrapper em,
@@ -20,9 +487,9 @@
#available_extensions .date_added .bilingual__trans_wrapper em,
#available_extensions+p>.bilingual__trans_wrapper em,
.gradio-image div[data-testid="image"] .bilingual__trans_wrapper em {
- display: none;
+ display: none;
}
-
+
#settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper),
label>span>.bilingual__trans_wrapper,
fieldset>span>.bilingual__trans_wrapper,
@@ -40,497 +507,58 @@
.posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */
#dynamic-prompting .bilingual__trans_wrapper
{
- font-size: 12px;
- align-items: flex-start;
+ align-items: flex-start;
}
#extensions label .bilingual__trans_wrapper,
#available_extensions td .bilingual__trans_wrapper,
.label-wrap>span>.bilingual__trans_wrapper {
- font-size: inherit;
- line-height: inherit;
+ font-size: inherit;
+ line-height: inherit;
}
.label-wrap>span:first-of-type {
- font-size: 13px;
- line-height: 1;
+ font-size: 13px;
+ line-height: 1;
}
#txt2img_script_container > div {
- margin-top: var(--layout-gap, 12px);
+ margin-top: var(--layout-gap, 12px);
}
-
+
textarea::placeholder {
- line-height: 1;
- padding: 4px 0;
+ line-height: 1;
+ padding: 4px 0;
}
-
+
label>span {
- line-height: 1;
+ line-height: 1;
}
-
+
div[data-testid="image"] .start-prompt {
- background-color: rgba(255, 255, 255, .6);
- color: #222;
- transition: opacity .2s ease-in-out;
+ background-color: rgba(255, 255, 255, .6);
+ color: #222;
+ transition: opacity .2s ease-in-out;
}
div[data-testid="image"]:hover .start-prompt {
- opacity: 0;
+ opacity: 0;
}
.label-wrap > span.icon {
- width: 1em;
- height: 1em;
- transform-origin: center center;
+ width: 1em;
+ height: 1em;
+ transform-origin: center center;
}
.gradio-dropdown ul.options li.item {
- padding: 0.3em 0.4em !important;
+ padding: 0.3em 0.4em !important;
}
-
+
/* Posex extension */
.posex_bg {
- white-space: nowrap;
- }
- `
-
- let i18n = null, i18nRegex = new Map(), i18nScope = {}, scopedSource = {}, config = null;
-
- // First load
- function setup() {
- config = {
- enabled: opts["bilingual_localization_enabled"],
- file: opts["bilingual_localization_file"],
- dirs: opts["bilingual_localization_dirs"],
- order: opts["bilingual_localization_order"],
- enableLogger: opts["bilingual_localization_logger"]
- }
-
- let { enabled, file, dirs, enableLogger } = config
-
- if (!enabled || file === "None" || dirs === "None") return
-
- dirs = JSON.parse(dirs)
-
- enableLogger && logger.init('Bilingual')
- logger.log('Bilingual Localization initialized.')
-
- // Load localization file
- const regex_scope = /^##(?.+)##(?.+)$/ // ##scope##.skey
- i18n = JSON.parse(readFile(dirs[file]), (key, value) => {
- // parse regex translations
- if (key.startsWith('@@')) {
- const regex = getRegex(key.slice(2))
- if (regex instanceof RegExp) {
- i18nRegex.set(regex, value)
- }
- } else if (regex_scope.test(key)) {
- // parse scope translations
- let { scope, skey } = key.match(regex_scope).groups
-
- if (scope.startsWith('@')) {
- scope = scope.slice(1)
- } else {
- scope = '#' + scope
- }
-
- if (!scope.length) {
- return value
- }
-
- i18nScope[scope] ||= {}
- i18nScope[scope][skey] = value
-
- scopedSource[skey] ||= []
- scopedSource[skey].push(scope)
- } else {
- return value
- }
- })
-
- logger.group('Localization file loaded.')
- logger.log('i18n', i18n)
- logger.log('i18nRegex', i18nRegex)
- logger.log('i18nScope', i18nScope)
- logger.groupEnd()
-
- translatePage()
- handleDropdown()
- }
-
- function handleDropdown() {
- // process gradio dropdown menu
- delegateEvent(gradioApp(), 'mousedown', 'ul.options .item', function (event) {
- const { target } = event
-
- if (!target.classList.contains('item')) {
- // simulate click menu item
- target.closest('.item').dispatchEvent(new Event('mousedown', { bubbles: true }))
- return
- }
-
- const source = target.dataset.value
- const $labelEl = target?.closest('.wrap')?.querySelector('.wrap-inner .single-select') // the label element
-
- if (source && $labelEl) {
- $labelEl.title = titles?.[source] || '' // set title from hints.js
- $labelEl.textContent = "__biligual__will_be_replaced__" // marked as will be replaced
- doTranslate($labelEl, source, 'element') // translate the label element
- }
- });
- }
-
- // Translate page
- function translatePage() {
- if (!i18n) return
-
- logger.time('Full Page')
- querySelectorAll([
- "label span, fieldset span, button", // major label and button description text
- "textarea[placeholder], select, option", // text box placeholder and select element
- ".transition > div > span:not([class])", ".label-wrap > span", // collapse panel added by extension
- ".gradio-image>div.float", // image upload description
- ".gradio-file>div.float", // file upload description
- ".gradio-code>div.float", // code editor description
- "#modelmerger_interp_description .output-html", // model merger description
- "#modelmerger_interp_description .gradio-html", // model merger description
- "#lightboxModal span" // image preview lightbox
- ])
- .forEach(el => translateEl(el, { deep: true }))
-
- querySelectorAll([
- 'div[data-testid="image"] > div > div', // description of image upload panel
- '#extras_image_batch > div', // description of extras image batch file upload panel
- ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer
- '#dynamic-prompting' // dynamic-prompting extension
- ])
- .forEach(el => translateEl(el, { rich: true }))
-
- logger.timeEnd('Full Page')
- }
-
- const ignore_selector = [
- '.bilingual__trans_wrapper', // self
- '.resultsFlexContainer', // tag-autocomplete
- '#setting_sd_model_checkpoint select', // model checkpoint
- '#setting_sd_vae select', // vae model
- '#txt2img_styles, #img2txt_styles', // styles select
- '.extra-network-cards .card .actions .name', // extra network cards name
- 'script, style, svg, g, path', // script / style / svg elements
- ]
- // Translate element
- function translateEl(el, { deep = false, rich = false } = {}) {
- if (!i18n) return // translation not ready.
- if (el.matches?.(ignore_selector)) return // ignore some elements.
-
- if (el.title) {
- doTranslate(el, el.title, 'title')
- }
-
- if (el.placeholder) {
- doTranslate(el, el.placeholder, 'placeholder')
- }
-
- if (el.tagName === 'OPTION') {
- doTranslate(el, el.textContent, 'option')
- }
-
- if (deep || rich) {
- Array.from(el.childNodes).forEach(node => {
- if (node.nodeName === '#text') {
- if (rich) {
- doTranslate(node, node.textContent, 'text')
- return
- }
-
- if (deep) {
- doTranslate(node, node.textContent, 'element')
- }
- } else if (node.childNodes.length > 0) {
- translateEl(node, { deep, rich })
- }
- })
- } else {
- doTranslate(el, el.textContent, 'element')
+ white-space: nowrap;
}
- }
-
- function checkRegex(source) {
- for (const [regex, value] of i18nRegex.entries()) {
- if (regex.test(source)) {
- logger.log('regex', regex, source, value)
- return source.replace(regex, value)
- }
- }
- }
-
- const re_num = /^[\.\d]+$/,
- re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u
-
- function doTranslate(el, source, type) {
- if (!i18n) return // translation not ready.
- source = source.trim()
- if (!source) return
- if (re_num.test(source)) return
- // if (re_emoji.test(source)) return
-
- let translation = i18n[source] || checkRegex(source),
- scopes = scopedSource[source]
-
- if (scopes) {
- console.log('scope', el, source, scopes);
- for (let scope of scopes) {
- if (el.parentElement.closest(scope)) {
- translation = i18nScope[scope][source]
- break
- }
- }
- }
-
- if (!translation || source === translation) {
- if (el.textContent === '__biligual__will_be_replaced__') el.textContent = source // restore original text if translation not exist
- if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() // remove exist translation if translation not exist
- return
- }
-
- if (config.order === "Original First") {
- [source, translation] = [translation, source]
- }
-
- switch (type) {
- case 'text':
- el.textContent = translation
- break;
-
- case 'element':
- const htmlStr = `${htmlEncode(translation)}${htmlEncode(source)}
`
- const htmlEl = parseHtmlStringToElement(htmlStr)
- if (el.hasChildNodes()) {
- const textNode = Array.from(el.childNodes).find(node =>
- node.nodeName === '#text' &&
- (node.textContent.trim() === source || node.textContent.trim() === '__biligual__will_be_replaced__')
- )
-
- if (textNode) {
- textNode.textContent = ''
- if (textNode.nextSibling?.className === 'bilingual__trans_wrapper') textNode.nextSibling.remove()
- textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling)
- }
- } else {
- el.textContent = ''
- if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove()
- el.parentNode.insertBefore(htmlEl, el.nextSibling)
- }
- break;
-
- case 'option':
- el.textContent = `${translation} (${source})`
- break;
-
- case 'title':
- el.title = `${translation}\n${source}`
- break;
-
- case 'placeholder':
- el.placeholder = `${translation}\n\n${source}`
- break;
-
- default:
- return translation
- }
- }
-
- function gradioApp() {
- const elems = document.getElementsByTagName('gradio-app')
- const elem = elems.length == 0 ? document : elems[0]
-
- if (elem !== document) elem.getElementById = function (id) { return document.getElementById(id) }
- return elem.shadowRoot ? elem.shadowRoot : elem
- }
-
- function querySelector(...args) {
- return gradioApp()?.querySelector(...args)
- }
-
- function querySelectorAll(...args) {
- return gradioApp()?.querySelectorAll(...args)
- }
-
- function delegateEvent(parent, eventType, selector, handler) {
- parent.addEventListener(eventType, function (event) {
- var target = event.target;
- while (target !== parent) {
- if (target.matches(selector)) {
- handler.call(target, event);
- }
- target = target.parentNode;
- }
- });
- }
-
- function parseHtmlStringToElement(htmlStr) {
- const template = document.createElement('template')
- template.insertAdjacentHTML('afterbegin', htmlStr)
- return template.firstElementChild
- }
-
- function htmlEncode(htmlStr) {
- return htmlStr.replace(/&/g, '&').replace(//g, '>')
- .replace(/"/g, '"').replace(/'/g, ''')
- }
-
- // get regex object from string
- function getRegex(regex) {
- try {
- regex = regex.trim();
- let parts = regex.split('/');
- if (regex[0] !== '/' || parts.length < 3) {
- regex = regex.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); //escap common string
- return new RegExp(regex);
- }
-
- const option = parts[parts.length - 1];
- const lastIndex = regex.lastIndexOf('/');
- regex = regex.substring(1, lastIndex);
- return new RegExp(regex, option);
- } catch (e) {
- return null
- }
- }
-
- // Load file
- function readFile(filePath) {
- let request = new XMLHttpRequest();
- request.open("GET", `file=${filePath}`, false);
- request.send(null);
- return request.responseText;
- }
-
- const logger = (function () {
- const loggerTimerMap = new Map()
- const loggerConf = { badge: true, label: 'Logger', enable: false }
- return new Proxy(console, {
- get: (target, propKey) => {
- if (propKey === 'init') {
- return (label) => {
- loggerConf.label = label
- loggerConf.enable = true
- }
- }
-
- if (!(propKey in target)) return undefined
-
- return (...args) => {
- if (!loggerConf.enable) return
-
- let color = ['#39cfe1', '#006cab']
-
- let label, start
- switch (propKey) {
- case 'error':
- color = ['#f70000', '#a70000']
- break;
- case 'warn':
- color = ['#f7b500', '#b58400']
- break;
- case 'time':
- label = args[0]
- if (loggerTimerMap.has(label)) {
- logger.warn(`Timer '${label}' already exisits`)
- } else {
- loggerTimerMap.set(label, performance.now())
- }
- return
- case 'timeEnd':
- label = args[0], start = loggerTimerMap.get(label)
- if (start === undefined) {
- logger.warn(`Timer '${label}' does not exist`)
- } else {
- loggerTimerMap.delete(label)
- logger.log(`${label}: ${performance.now() - start} ms`)
- }
- return
- case 'groupEnd':
- loggerConf.badge = true
- break
- }
-
- const badge = loggerConf.badge ? [`%c${loggerConf.label}`, `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`] : []
-
- target[propKey](...badge, ...args)
-
- if (propKey === 'group' || propKey === 'groupCollapsed') {
- loggerConf.badge = false
- }
- }
- }
- })
- }())
-
- function init() {
- // Add style to dom
- let $styleEL = document.createElement('style');
-
- if ($styleEL.styleSheet) {
- $styleEL.styleSheet.cssText = customCSS;
- } else {
- $styleEL.appendChild(document.createTextNode(customCSS));
- }
- gradioApp().appendChild($styleEL);
-
- let loaded = false
- let _count = 0
-
- const observer = new MutationObserver(mutations => {
- if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled
- if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded
-
- let _nodesCount = 0, _now = performance.now()
-
- for (const mutation of mutations) {
- if (mutation.type === 'characterData') {
- if (mutation.target?.parentElement?.parentElement?.tagName === 'LABEL') {
- translateEl(mutation.target)
- }
- } else if (mutation.type === 'attributes') {
- _nodesCount++
- translateEl(mutation.target)
- } else {
- mutation.addedNodes.forEach(node => {
- if (node.className === 'bilingual__trans_wrapper') return
-
- _nodesCount++
- if (node.nodeType === 1 && /(output|gradio)-(html|markdown)/.test(node.className)) {
- translateEl(node, { rich: true })
- } else if (node.nodeType === 3) {
- doTranslate(node, node.textContent, 'text')
- } else {
- translateEl(node, { deep: true })
- }
- })
- }
- }
-
- if (_nodesCount > 0) {
- logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations)
- }
-
- if (loaded) return;
- if (i18n) return;
-
- loaded = true
- setup()
- })
-
- observer.observe(gradioApp(), {
- characterData: true,
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: ['title', 'placeholder']
- })
- }
+ `;
- // Init after page loaded
- document.addEventListener('DOMContentLoaded', init)
-})();
+// src/main.ts
+document.addEventListener("DOMContentLoaded", init);
diff --git a/javascript/original_bilingual_localization.bak b/javascript/original_bilingual_localization.bak
new file mode 100644
index 0000000..f8c2f12
--- /dev/null
+++ b/javascript/original_bilingual_localization.bak
@@ -0,0 +1,536 @@
+(function () {
+ const customCSS = `
+ .bilingual__trans_wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: 13px;
+ line-height: 1;
+ }
+
+ .bilingual__trans_wrapper em {
+ font-style: normal;
+ }
+
+ #txtimg_hr_finalres .bilingual__trans_wrapper em,
+ #tab_ti .output-html .bilingual__trans_wrapper em,
+ #tab_ti .gradio-html .bilingual__trans_wrapper em,
+ #sddp-dynamic-prompting .gradio-html .bilingual__trans_wrapper em,
+ #available_extensions .extension-tag .bilingual__trans_wrapper em,
+ #available_extensions .date_added .bilingual__trans_wrapper em,
+ #available_extensions+p>.bilingual__trans_wrapper em,
+ .gradio-image div[data-testid="image"] .bilingual__trans_wrapper em {
+ display: none;
+ }
+
+ #settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper),
+ label>span>.bilingual__trans_wrapper,
+ fieldset>span>.bilingual__trans_wrapper,
+ .label-wrap>span>.bilingual__trans_wrapper,
+ .w-full>span>.bilingual__trans_wrapper,
+ .context-menu-items .bilingual__trans_wrapper,
+ .single-select .bilingual__trans_wrapper, ul.options .inner-item + .bilingual__trans_wrapper,
+ .output-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper),
+ .gradio-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper, .posex_cont .bilingual__trans_wrapper),
+ .output-markdown .bilingual__trans_wrapper,
+ .gradio-markdown .bilingual__trans_wrapper,
+ .gradio-image>div.float .bilingual__trans_wrapper,
+ .gradio-file>div.float .bilingual__trans_wrapper,
+ .gradio-code>div.float .bilingual__trans_wrapper,
+ .posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */
+ #dynamic-prompting .bilingual__trans_wrapper
+ {
+ font-size: 12px;
+ align-items: flex-start;
+ }
+
+ #extensions label .bilingual__trans_wrapper,
+ #available_extensions td .bilingual__trans_wrapper,
+ .label-wrap>span>.bilingual__trans_wrapper {
+ font-size: inherit;
+ line-height: inherit;
+ }
+
+ .label-wrap>span:first-of-type {
+ font-size: 13px;
+ line-height: 1;
+ }
+
+ #txt2img_script_container > div {
+ margin-top: var(--layout-gap, 12px);
+ }
+
+ textarea::placeholder {
+ line-height: 1;
+ padding: 4px 0;
+ }
+
+ label>span {
+ line-height: 1;
+ }
+
+ div[data-testid="image"] .start-prompt {
+ background-color: rgba(255, 255, 255, .6);
+ color: #222;
+ transition: opacity .2s ease-in-out;
+ }
+ div[data-testid="image"]:hover .start-prompt {
+ opacity: 0;
+ }
+
+ .label-wrap > span.icon {
+ width: 1em;
+ height: 1em;
+ transform-origin: center center;
+ }
+
+ .gradio-dropdown ul.options li.item {
+ padding: 0.3em 0.4em !important;
+ }
+
+ /* Posex extension */
+ .posex_bg {
+ white-space: nowrap;
+ }
+ `
+
+ let i18n = null, i18nRegex = new Map(), i18nScope = {}, scopedSource = {}, config = null;
+
+ // First load
+ function setup() {
+ config = {
+ enabled: opts["bilingual_localization_enabled"],
+ file: opts["bilingual_localization_file"],
+ dirs: opts["bilingual_localization_dirs"],
+ order: opts["bilingual_localization_order"],
+ enableLogger: opts["bilingual_localization_logger"]
+ }
+
+ let { enabled, file, dirs, enableLogger } = config
+
+ if (!enabled || file === "None" || dirs === "None") return
+
+ dirs = JSON.parse(dirs)
+
+ enableLogger && logger.init('Bilingual')
+ logger.log('Bilingual Localization initialized.')
+
+ // Load localization file
+ const regex_scope = /^##(?.+)##(?.+)$/ // ##scope##.skey
+ i18n = JSON.parse(readFile(dirs[file]), (key, value) => {
+ // parse regex translations
+ if (key.startsWith('@@')) {
+ const regex = getRegex(key.slice(2))
+ if (regex instanceof RegExp) {
+ i18nRegex.set(regex, value)
+ }
+ } else if (regex_scope.test(key)) {
+ // parse scope translations
+ let { scope, skey } = key.match(regex_scope).groups
+
+ if (scope.startsWith('@')) {
+ scope = scope.slice(1)
+ } else {
+ scope = '#' + scope
+ }
+
+ if (!scope.length) {
+ return value
+ }
+
+ i18nScope[scope] ||= {}
+ i18nScope[scope][skey] = value
+
+ scopedSource[skey] ||= []
+ scopedSource[skey].push(scope)
+ } else {
+ return value
+ }
+ })
+
+ logger.group('Localization file loaded.')
+ logger.log('i18n', i18n)
+ logger.log('i18nRegex', i18nRegex)
+ logger.log('i18nScope', i18nScope)
+ logger.groupEnd()
+
+ translatePage()
+ handleDropdown()
+ }
+
+ function handleDropdown() {
+ // process gradio dropdown menu
+ delegateEvent(gradioApp(), 'mousedown', 'ul.options .item', function (event) {
+ const { target } = event
+
+ if (!target.classList.contains('item')) {
+ // simulate click menu item
+ target.closest('.item').dispatchEvent(new Event('mousedown', { bubbles: true }))
+ return
+ }
+
+ const source = target.dataset.value
+ const $labelEl = target?.closest('.wrap')?.querySelector('.wrap-inner .single-select') // the label element
+
+ if (source && $labelEl) {
+ $labelEl.title = titles?.[source] || '' // set title from hints.js
+ $labelEl.textContent = "__biligual__will_be_replaced__" // marked as will be replaced
+ doTranslate($labelEl, source, 'element') // translate the label element
+ }
+ });
+ }
+
+ // Translate page
+ function translatePage() {
+ if (!i18n) return
+
+ logger.time('Full Page')
+ querySelectorAll([
+ "label span, fieldset span, button", // major label and button description text
+ "textarea[placeholder], select, option", // text box placeholder and select element
+ ".transition > div > span:not([class])", ".label-wrap > span", // collapse panel added by extension
+ ".gradio-image>div.float", // image upload description
+ ".gradio-file>div.float", // file upload description
+ ".gradio-code>div.float", // code editor description
+ "#modelmerger_interp_description .output-html", // model merger description
+ "#modelmerger_interp_description .gradio-html", // model merger description
+ "#lightboxModal span" // image preview lightbox
+ ])
+ .forEach(el => translateEl(el, { deep: true }))
+
+ querySelectorAll([
+ 'div[data-testid="image"] > div > div', // description of image upload panel
+ '#extras_image_batch > div', // description of extras image batch file upload panel
+ ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer
+ '#dynamic-prompting' // dynamic-prompting extension
+ ])
+ .forEach(el => translateEl(el, { rich: true }))
+
+ logger.timeEnd('Full Page')
+ }
+
+ const ignore_selector = [
+ '.bilingual__trans_wrapper', // self
+ '.resultsFlexContainer', // tag-autocomplete
+ '#setting_sd_model_checkpoint select', // model checkpoint
+ '#setting_sd_vae select', // vae model
+ '#txt2img_styles, #img2txt_styles', // styles select
+ '.extra-network-cards .card .actions .name', // extra network cards name
+ 'script, style, svg, g, path', // script / style / svg elements
+ ]
+ // Translate element
+ function translateEl(el, { deep = false, rich = false } = {}) {
+ if (!i18n) return // translation not ready.
+ if (el.matches?.(ignore_selector)) return // ignore some elements.
+
+ if (el.title) {
+ doTranslate(el, el.title, 'title')
+ }
+
+ if (el.placeholder) {
+ doTranslate(el, el.placeholder, 'placeholder')
+ }
+
+ if (el.tagName === 'OPTION') {
+ doTranslate(el, el.textContent, 'option')
+ }
+
+ if (deep || rich) {
+ Array.from(el.childNodes).forEach(node => {
+ if (node.nodeName === '#text') {
+ if (rich) {
+ doTranslate(node, node.textContent, 'text')
+ return
+ }
+
+ if (deep) {
+ doTranslate(node, node.textContent, 'element')
+ }
+ } else if (node.childNodes.length > 0) {
+ translateEl(node, { deep, rich })
+ }
+ })
+ } else {
+ doTranslate(el, el.textContent, 'element')
+ }
+ }
+
+ function checkRegex(source) {
+ for (const [regex, value] of i18nRegex.entries()) {
+ if (regex.test(source)) {
+ logger.log('regex', regex, source, value)
+ return source.replace(regex, value)
+ }
+ }
+ }
+
+ const re_num = /^[\.\d]+$/,
+ re_emoji = /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u
+
+ function doTranslate(el, source, type) {
+ if (!i18n) return // translation not ready.
+ source = source.trim()
+ if (!source) return
+ if (re_num.test(source)) return
+ // if (re_emoji.test(source)) return
+
+ let translation = i18n[source] || checkRegex(source),
+ scopes = scopedSource[source]
+
+ if (scopes) {
+ console.log('scope', el, source, scopes);
+ for (let scope of scopes) {
+ if (el.parentElement.closest(scope)) {
+ translation = i18nScope[scope][source]
+ break
+ }
+ }
+ }
+
+ if (!translation || source === translation) {
+ if (el.textContent === '__biligual__will_be_replaced__') el.textContent = source // restore original text if translation not exist
+ if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove() // remove exist translation if translation not exist
+ return
+ }
+
+ if (config.order === "Original First") {
+ [source, translation] = [translation, source]
+ }
+
+ switch (type) {
+ case 'text':
+ el.textContent = translation
+ break;
+
+ case 'element':
+ const htmlStr = `${htmlEncode(translation)}${htmlEncode(source)}
`
+ const htmlEl = parseHtmlStringToElement(htmlStr)
+ if (el.hasChildNodes()) {
+ const textNode = Array.from(el.childNodes).find(node =>
+ node.nodeName === '#text' &&
+ (node.textContent.trim() === source || node.textContent.trim() === '__biligual__will_be_replaced__')
+ )
+
+ if (textNode) {
+ textNode.textContent = ''
+ if (textNode.nextSibling?.className === 'bilingual__trans_wrapper') textNode.nextSibling.remove()
+ textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling)
+ }
+ } else {
+ el.textContent = ''
+ if (el.nextSibling?.className === 'bilingual__trans_wrapper') el.nextSibling.remove()
+ el.parentNode.insertBefore(htmlEl, el.nextSibling)
+ }
+ break;
+
+ case 'option':
+ el.textContent = `${translation} (${source})`
+ break;
+
+ case 'title':
+ el.title = `${translation}\n${source}`
+ break;
+
+ case 'placeholder':
+ el.placeholder = `${translation}\n\n${source}`
+ break;
+
+ default:
+ return translation
+ }
+ }
+
+ function gradioApp() {
+ const elems = document.getElementsByTagName('gradio-app')
+ const elem = elems.length == 0 ? document : elems[0]
+
+ if (elem !== document) elem.getElementById = function (id) { return document.getElementById(id) }
+ return elem.shadowRoot ? elem.shadowRoot : elem
+ }
+
+ function querySelector(...args) {
+ return gradioApp()?.querySelector(...args)
+ }
+
+ function querySelectorAll(...args) {
+ return gradioApp()?.querySelectorAll(...args)
+ }
+
+ function delegateEvent(parent, eventType, selector, handler) {
+ parent.addEventListener(eventType, function (event) {
+ var target = event.target;
+ while (target !== parent) {
+ if (target.matches(selector)) {
+ handler.call(target, event);
+ }
+ target = target.parentNode;
+ }
+ });
+ }
+
+ function parseHtmlStringToElement(htmlStr) {
+ const template = document.createElement('template')
+ template.insertAdjacentHTML('afterbegin', htmlStr)
+ return template.firstElementChild
+ }
+
+ function htmlEncode(htmlStr) {
+ return htmlStr.replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''')
+ }
+
+ // get regex object from string
+ function getRegex(regex) {
+ try {
+ regex = regex.trim();
+ let parts = regex.split('/');
+ if (regex[0] !== '/' || parts.length < 3) {
+ regex = regex.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); //escap common string
+ return new RegExp(regex);
+ }
+
+ const option = parts[parts.length - 1];
+ const lastIndex = regex.lastIndexOf('/');
+ regex = regex.substring(1, lastIndex);
+ return new RegExp(regex, option);
+ } catch (e) {
+ return null
+ }
+ }
+
+ // Load file
+ function readFile(filePath) {
+ let request = new XMLHttpRequest();
+ request.open("GET", `file=${filePath}`, false);
+ request.send(null);
+ return request.responseText;
+ }
+
+ const logger = (function () {
+ const loggerTimerMap = new Map()
+ const loggerConf = { badge: true, label: 'Logger', enable: false }
+ return new Proxy(console, {
+ get: (target, propKey) => {
+ if (propKey === 'init') {
+ return (label) => {
+ loggerConf.label = label
+ loggerConf.enable = true
+ }
+ }
+
+ if (!(propKey in target)) return undefined
+
+ return (...args) => {
+ if (!loggerConf.enable) return
+
+ let color = ['#39cfe1', '#006cab']
+
+ let label, start
+ switch (propKey) {
+ case 'error':
+ color = ['#f70000', '#a70000']
+ break;
+ case 'warn':
+ color = ['#f7b500', '#b58400']
+ break;
+ case 'time':
+ label = args[0]
+ if (loggerTimerMap.has(label)) {
+ logger.warn(`Timer '${label}' already exisits`)
+ } else {
+ loggerTimerMap.set(label, performance.now())
+ }
+ return
+ case 'timeEnd':
+ label = args[0], start = loggerTimerMap.get(label)
+ if (start === undefined) {
+ logger.warn(`Timer '${label}' does not exist`)
+ } else {
+ loggerTimerMap.delete(label)
+ logger.log(`${label}: ${performance.now() - start} ms`)
+ }
+ return
+ case 'groupEnd':
+ loggerConf.badge = true
+ break
+ }
+
+ const badge = loggerConf.badge ? [`%c${loggerConf.label}`, `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`] : []
+
+ target[propKey](...badge, ...args)
+
+ if (propKey === 'group' || propKey === 'groupCollapsed') {
+ loggerConf.badge = false
+ }
+ }
+ }
+ })
+ }())
+
+ function init() {
+ // Add style to dom
+ let $styleEL = document.createElement('style');
+
+ if ($styleEL.styleSheet) {
+ $styleEL.styleSheet.cssText = customCSS;
+ } else {
+ $styleEL.appendChild(document.createTextNode(customCSS));
+ }
+ gradioApp().appendChild($styleEL);
+
+ let loaded = false
+ let _count = 0
+
+ const observer = new MutationObserver(mutations => {
+ if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled
+ if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded
+
+ let _nodesCount = 0, _now = performance.now()
+
+ for (const mutation of mutations) {
+ if (mutation.type === 'characterData') {
+ if (mutation.target?.parentElement?.parentElement?.tagName === 'LABEL') {
+ translateEl(mutation.target)
+ }
+ } else if (mutation.type === 'attributes') {
+ _nodesCount++
+ translateEl(mutation.target)
+ } else {
+ mutation.addedNodes.forEach(node => {
+ if (node.className === 'bilingual__trans_wrapper') return
+
+ _nodesCount++
+ if (node.nodeType === 1 && /(output|gradio)-(html|markdown)/.test(node.className)) {
+ translateEl(node, { rich: true })
+ } else if (node.nodeType === 3) {
+ doTranslate(node, node.textContent, 'text')
+ } else {
+ translateEl(node, { deep: true })
+ }
+ })
+ }
+ }
+
+ if (_nodesCount > 0) {
+ logger.info(`UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`, mutations)
+ }
+
+ if (loaded) return;
+ if (i18n) return;
+
+ loaded = true
+ setup()
+ })
+
+ observer.observe(gradioApp(), {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['title', 'placeholder']
+ })
+ }
+
+ // Init after page loaded
+ document.addEventListener('DOMContentLoaded', init)
+})();
diff --git a/scripts/bilingual_localization_helper.py b/scripts/bilingual_localization_helper.py
index 3a7c570..b9051a6 100644
--- a/scripts/bilingual_localization_helper.py
+++ b/scripts/bilingual_localization_helper.py
@@ -53,7 +53,11 @@ def on_ui_settings():
# translation order
shared.opts.add_option("bilingual_localization_order", shared.OptionInfo("Translation First", "Translation display order", gr.Radio, {"choices": ["Translation First", "Original First"]}, section=BL_SECTION))
+ # translate the inputbox's placeholder
+ shared.opts.add_option("bilingual_translate_placeholder", shared.OptionInfo(False, "Enable translate the inputbox's placeholder", section=BL_SECTION))
+
# all localization files path in hidden option
shared.opts.add_option("bilingual_localization_dirs", shared.OptionInfo(json.dumps(I18N_DIRS), "Localization dirs", section=BL_SECTION, component_args={"visible": False}))
+ shared.opts.data["bilingual_localization_dirs"] = json.dumps(I18N_DIRS)
script_callbacks.on_ui_settings(on_ui_settings)
diff --git a/src/config/opts.ts b/src/config/opts.ts
new file mode 100644
index 0000000..b3502fe
--- /dev/null
+++ b/src/config/opts.ts
@@ -0,0 +1,9 @@
+import type { Opts } from "../types/opts";
+
+export const opts: Opts = {
+ bilingual_localization_enabled: true,
+ bilingual_localization_logger: false,
+ bilingual_localization_file: "None",
+ bilingual_localization_dirs: "{}",
+ bilingual_localization_order: "Translation First",
+};
diff --git a/src/init.ts b/src/init.ts
new file mode 100644
index 0000000..954b70e
--- /dev/null
+++ b/src/init.ts
@@ -0,0 +1,183 @@
+import { opts } from "./config/opts";
+import { createLogger } from "./lib/create-logger";
+import { doTranslate } from "./lib/do-translate";
+import { gradioApp } from "./lib/gradio-app";
+import { translateEl } from "./lib/translate-el";
+import { getI18n, setup } from "./setup";
+
+const customCSS = `
+ .bilingual__trans_wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ font-size: var(--section-header-text-size);
+ line-height: 1;
+ }
+
+ .bilingual__trans_wrapper em {
+ font-style: normal;
+ }
+
+ #txtimg_hr_finalres .bilingual__trans_wrapper em,
+ #tab_ti .output-html .bilingual__trans_wrapper em,
+ #tab_ti .gradio-html .bilingual__trans_wrapper em,
+ #sddp-dynamic-prompting .gradio-html .bilingual__trans_wrapper em,
+ #available_extensions .extension-tag .bilingual__trans_wrapper em,
+ #available_extensions .date_added .bilingual__trans_wrapper em,
+ #available_extensions+p>.bilingual__trans_wrapper em,
+ .gradio-image div[data-testid="image"] .bilingual__trans_wrapper em {
+ display: none;
+ }
+
+ #settings .bilingual__trans_wrapper:not(#settings .tabitem .bilingual__trans_wrapper),
+ label>span>.bilingual__trans_wrapper,
+ fieldset>span>.bilingual__trans_wrapper,
+ .label-wrap>span>.bilingual__trans_wrapper,
+ .w-full>span>.bilingual__trans_wrapper,
+ .context-menu-items .bilingual__trans_wrapper,
+ .single-select .bilingual__trans_wrapper, ul.options .inner-item + .bilingual__trans_wrapper,
+ .output-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper),
+ .gradio-html .bilingual__trans_wrapper:not(th .bilingual__trans_wrapper, .posex_cont .bilingual__trans_wrapper),
+ .output-markdown .bilingual__trans_wrapper,
+ .gradio-markdown .bilingual__trans_wrapper,
+ .gradio-image>div.float .bilingual__trans_wrapper,
+ .gradio-file>div.float .bilingual__trans_wrapper,
+ .gradio-code>div.float .bilingual__trans_wrapper,
+ .posex_setting_cont .bilingual__trans_wrapper:not(.posex_bg .bilingual__trans_wrapper), /* Posex extension */
+ #dynamic-prompting .bilingual__trans_wrapper
+ {
+ align-items: flex-start;
+ }
+
+ #extensions label .bilingual__trans_wrapper,
+ #available_extensions td .bilingual__trans_wrapper,
+ .label-wrap>span>.bilingual__trans_wrapper {
+ font-size: inherit;
+ line-height: inherit;
+ }
+
+ .label-wrap>span:first-of-type {
+ font-size: 13px;
+ line-height: 1;
+ }
+
+ #txt2img_script_container > div {
+ margin-top: var(--layout-gap, 12px);
+ }
+
+ textarea::placeholder {
+ line-height: 1;
+ padding: 4px 0;
+ }
+
+ label>span {
+ line-height: 1;
+ }
+
+ div[data-testid="image"] .start-prompt {
+ background-color: rgba(255, 255, 255, .6);
+ color: #222;
+ transition: opacity .2s ease-in-out;
+ }
+ div[data-testid="image"]:hover .start-prompt {
+ opacity: 0;
+ }
+
+ .label-wrap > span.icon {
+ width: 1em;
+ height: 1em;
+ transform-origin: center center;
+ }
+
+ .gradio-dropdown ul.options li.item {
+ padding: 0.3em 0.4em !important;
+ }
+
+ /* Posex extension */
+ .posex_bg {
+ white-space: nowrap;
+ }
+ `;
+
+export function init() {
+ // Add style to dom
+ const styleEl = document.createElement("style");
+
+ if (styleEl.textContent) {
+ styleEl.textContent = customCSS;
+ } else {
+ styleEl.appendChild(document.createTextNode(customCSS));
+ }
+ gradioApp().appendChild(styleEl);
+
+ let loaded = false;
+ let _count = 0;
+
+ const observer = new MutationObserver((mutations) => {
+ // @ts-ignore
+ if (window.localization && Object.keys(window.localization).length) return; // disabled if original translation enabled
+ if (Object.keys(opts).length === 0) return; // does nothing if opts is not loaded
+
+ let _nodesCount = 0;
+ const _now = performance.now();
+
+ for (const mutation of mutations) {
+ if (mutation.type === "characterData") {
+ if (
+ mutation.target?.parentElement?.parentElement?.tagName === "LABEL"
+ ) {
+ translateEl(mutation.target);
+ }
+ } else if (mutation.type === "attributes") {
+ _nodesCount++;
+ translateEl(mutation.target);
+ } else {
+ // biome-ignore lint/complexity/noForEach:
+ mutation.addedNodes.forEach((node) => {
+ if (
+ node instanceof Element &&
+ node.className === "bilingual__trans_wrapper"
+ )
+ return; // NodeがElement型であることを確認
+
+ _nodesCount++;
+ if (
+ node.nodeType === 1 &&
+ node instanceof Element &&
+ /(output|gradio)-(html|markdown)/.test(node.className)
+ ) {
+ // nodeがElement型であることを確認
+ translateEl(node, { rich: true });
+ } else if (node.nodeType === 3) {
+ doTranslate(node, node.textContent, "text");
+ } else {
+ translateEl(node, { deep: true });
+ }
+ });
+ }
+ }
+
+ if (_nodesCount > 0) {
+ const logger = createLogger();
+ logger.info(
+ `UI Update #${_count++}: ${performance.now() - _now} ms, ${_nodesCount} nodes`,
+ mutations,
+ );
+ }
+
+ if (loaded) return;
+ const i18n = getI18n();
+ if (i18n) return;
+
+ loaded = true;
+ setup();
+ });
+
+ observer.observe(gradioApp(), {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["title", "placeholder"],
+ });
+}
diff --git a/src/lib/check-regax.ts b/src/lib/check-regax.ts
new file mode 100644
index 0000000..e58dd0e
--- /dev/null
+++ b/src/lib/check-regax.ts
@@ -0,0 +1,20 @@
+import { getI18nRegex } from "../setup";
+import { createLogger } from "./create-logger";
+
+export function checkRegex(source: string) {
+ const i18nRegex = getI18nRegex();
+ for (const [regex, value] of i18nRegex.entries()) {
+ if (regex instanceof RegExp) {
+ if (regex.test(source)) {
+ const logger = createLogger();
+ logger.log("regex", regex, source, value);
+ return source.replace(regex, value);
+ }
+ } else {
+ console.warn(
+ "Expected regex to be an instance of RegExp, but it was a string.",
+ );
+ }
+ }
+ return source;
+}
diff --git a/src/lib/create-logger.ts b/src/lib/create-logger.ts
new file mode 100644
index 0000000..74e9d7c
--- /dev/null
+++ b/src/lib/create-logger.ts
@@ -0,0 +1,73 @@
+interface CustomConsole extends Console {
+ init: (label: string) => void;
+}
+
+export function createLogger(): CustomConsole {
+ const loggerTimerMap = new Map();
+ const loggerConf = { badge: true, label: "Logger", enable: false };
+
+ return new Proxy(console, {
+ get: (target, propKey) => {
+ if (propKey === "init") {
+ return (label: string) => {
+ loggerConf.label = label;
+ loggerConf.enable = true;
+ };
+ }
+
+ if (!(propKey in target)) return undefined;
+
+ // biome-ignore lint/suspicious/noExplicitAny:
+ return (...args: any[]) => {
+ if (!loggerConf.enable) return;
+
+ let color = ["#39cfe1", "#006cab"];
+
+ let label: string;
+ let start: number | undefined;
+ switch (propKey) {
+ case "error":
+ color = ["#f70000", "#a70000"];
+ break;
+ case "warn":
+ color = ["#f7b500", "#b58400"];
+ break;
+ case "time":
+ label = args[0];
+ if (loggerTimerMap.has(label)) {
+ console.warn(`Timer '${label}' already exists`);
+ } else {
+ loggerTimerMap.set(label, performance.now());
+ }
+ return;
+ case "timeEnd":
+ label = args[0];
+ start = loggerTimerMap.get(label);
+ if (start === undefined) {
+ console.warn(`Timer '${label}' does not exist`);
+ } else {
+ loggerTimerMap.delete(label);
+ console.log(`${label}: ${performance.now() - start} ms`);
+ }
+ return;
+ case "groupEnd":
+ loggerConf.badge = true;
+ break;
+ }
+
+ const badge = loggerConf.badge
+ ? [
+ `%c${loggerConf.label}`,
+ `color: #fff; background: linear-gradient(180deg, ${color[0]}, ${color[1]}); text-shadow: 0px 0px 1px #0003; padding: 3px 5px; border-radius: 4px;`,
+ ]
+ : [];
+
+ target[propKey](...badge, ...args);
+
+ if (propKey === "group" || propKey === "groupCollapsed") {
+ loggerConf.badge = false;
+ }
+ };
+ },
+ }) as CustomConsole;
+}
diff --git a/src/lib/delegate-event.ts b/src/lib/delegate-event.ts
new file mode 100644
index 0000000..803676c
--- /dev/null
+++ b/src/lib/delegate-event.ts
@@ -0,0 +1,11 @@
+export function delegateEvent(parent, eventType, selector, handler) {
+ parent.addEventListener(eventType, (event) => {
+ let target = event.target;
+ while (target !== parent) {
+ if (target.matches(selector)) {
+ handler.call(target, event);
+ }
+ target = target.parentNode;
+ }
+ });
+}
diff --git a/src/lib/do-translate.ts b/src/lib/do-translate.ts
new file mode 100644
index 0000000..098bd56
--- /dev/null
+++ b/src/lib/do-translate.ts
@@ -0,0 +1,127 @@
+import { getConfig, getI18n, getI18nScope, getScopedSource } from "../setup";
+import { checkRegex } from "./check-regax";
+import { htmlEncode } from "./html-encode";
+import { parseHtmlStringToElement } from "./parse-html-string-to-element";
+
+const re_num = /^[\.\d]+$/;
+const re_emoji =
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass:
+ /[\p{Extended_Pictographic}\u{1F3FB}-\u{1F3FF}\u{1F9B0}-\u{1F9B3}]/u;
+
+export function doTranslate(el, source, type) {
+ if (!getI18n) return; // translation not ready.
+ let trimmedSource = source.trim();
+ if (!trimmedSource) return;
+ if (re_num.test(trimmedSource)) return;
+ if (re_emoji.test(trimmedSource)) return;
+
+ let translation = getI18n[trimmedSource] || checkRegex(trimmedSource);
+ const scopes = getScopedSource[trimmedSource];
+
+ if (scopes) {
+ console.log("scope", el, trimmedSource, scopes);
+ for (const scope of scopes) {
+ if (el.parentElement.closest(scope)) {
+ translation = getI18nScope[scope][trimmedSource];
+ break;
+ }
+ }
+ }
+
+ if (!translation || trimmedSource === translation) {
+ if (el.textContent === "__biligual__will_be_replaced__")
+ el.textContent = trimmedSource; // restore original text if translation not exist
+ if (el.nextSibling?.className === "bilingual__trans_wrapper")
+ el.nextSibling.remove(); // remove exist translation if translation not exist
+ return;
+ }
+
+ const config = getConfig();
+
+ if (config?.order === "Original First") {
+ [trimmedSource, translation] = [translation, trimmedSource];
+ }
+
+ const isTranslationIncludeSource = translation.startsWith(source);
+
+ switch (type) {
+ case "text":
+ el.textContent = translation;
+ break;
+
+ case "element": {
+ if (isTranslationIncludeSource) {
+ if (el.nodeType === 3) {
+ el.nodeValue = translation;
+ } else if (htmlEncode(el.textContent) === el.innerHTML) {
+ el.innerHTML = htmlEncode(translation);
+ }
+ break;
+ }
+
+ const htmlEl = parseHtmlStringToElement(
+ `${htmlEncode(translation)}${htmlEncode(source)}
`,
+ );
+
+ if (el.hasChildNodes()) {
+ const textNode = Array.from(el.childNodes).find(
+ (node) =>
+ ((node as Text).nodeType === Node.TEXT_NODE && // Ensure it's a text node
+ (node as Text).textContent?.trim() === trimmedSource) ||
+ (node as Text).textContent?.trim() ===
+ "__bilingual__will_be_replaced__",
+ ) as Text | undefined;
+
+ if (textNode) {
+ textNode.textContent = "";
+ if (
+ textNode.nextSibling?.nodeType === Node.ELEMENT_NODE &&
+ (textNode.nextSibling as HTMLElement).className ===
+ "bilingual__trans_wrapper"
+ ) {
+ textNode.nextSibling.remove();
+ }
+ if (textNode.parentNode && htmlEl) {
+ // Ensure htmlEl is not null
+ textNode.parentNode.insertBefore(htmlEl, textNode.nextSibling);
+ }
+ }
+ } else {
+ el.textContent = "";
+ if (
+ el.nextSibling?.nodeType === Node.ELEMENT_NODE &&
+ (el.nextSibling as HTMLElement).className ===
+ "bilingual__trans_wrapper"
+ ) {
+ el.nextSibling.remove();
+ }
+ if (el.parentNode && htmlEl) {
+ // Ensure htmlEl is not null
+ el.parentNode.insertBefore(htmlEl, el.nextSibling);
+ }
+ }
+ break;
+ }
+
+ case "option":
+ el.textContent = isTranslationIncludeSource
+ ? translation
+ : `${translation} (${trimmedSource})`;
+ break;
+
+ case "title":
+ el.title = isTranslationIncludeSource
+ ? translation
+ : `${translation}\n${trimmedSource}`;
+ break;
+
+ case "placeholder":
+ el.placeholder = isTranslationIncludeSource
+ ? translation
+ : `${translation}\n\n${trimmedSource}`;
+ break;
+
+ default:
+ return translation;
+ }
+}
diff --git a/src/lib/get-regax.ts b/src/lib/get-regax.ts
new file mode 100644
index 0000000..673b0de
--- /dev/null
+++ b/src/lib/get-regax.ts
@@ -0,0 +1,24 @@
+// get regex object from string
+export function getRegex(regexString: string): RegExp | null {
+ try {
+ const trimmedRegexString = regexString.trim();
+
+ if (
+ !trimmedRegexString.startsWith("/") ||
+ trimmedRegexString.split("/").length < 3
+ ) {
+ const escapedRegexString = trimmedRegexString.replace(
+ /[.*+\-?^${}()|[\]\\]/g,
+ "\\$&",
+ );
+ return new RegExp(escapedRegexString);
+ }
+
+ const lastSlashIndex = trimmedRegexString.lastIndexOf("/");
+ const regexPattern = trimmedRegexString.slice(1, lastSlashIndex);
+ const regexFlags = trimmedRegexString.slice(lastSlashIndex + 1);
+ return new RegExp(regexPattern, regexFlags);
+ } catch (e) {
+ return null;
+ }
+}
diff --git a/src/lib/gradio-app.ts b/src/lib/gradio-app.ts
new file mode 100644
index 0000000..c5c8fac
--- /dev/null
+++ b/src/lib/gradio-app.ts
@@ -0,0 +1,27 @@
+export function gradioApp(): Document | ShadowRoot {
+ const elems = document.getElementsByTagName("gradio-app");
+ // @ts-ignore
+ const elem: Document | HTMLElement = elems.length === 0 ? document : elems[0];
+
+ if (elem !== document) {
+ // biome-ignore lint/suspicious/noExplicitAny:
+ (elem as any).getElementById = (id: string): HTMLElement | null =>
+ document.getElementById(id);
+ }
+
+ // biome-ignore lint/suspicious/noExplicitAny:
+ return (elem as any).shadowRoot ? (elem as any).shadowRoot : elem;
+}
+
+export function querySelector(
+ ...args: Parameters
+): ReturnType | null {
+ return gradioApp()?.querySelector(...args) ?? null;
+}
+
+export function querySelectorAll(
+ ...args: Parameters
+): NodeListOf {
+ const nodeList = gradioApp()?.querySelectorAll(...args);
+ return nodeList || new NodeList();
+}
diff --git a/src/lib/handle-dropdown.ts b/src/lib/handle-dropdown.ts
new file mode 100644
index 0000000..629b9a3
--- /dev/null
+++ b/src/lib/handle-dropdown.ts
@@ -0,0 +1,30 @@
+import { delegateEvent } from "./delegate-event";
+import { doTranslate } from "./do-translate";
+import { gradioApp } from "./gradio-app";
+
+export function handleDropdown() {
+ // process gradio dropdown menu
+ delegateEvent(gradioApp(), "mousedown", "ul.options .item", (event) => {
+ const { target } = event;
+
+ if (!target.classList.contains("item")) {
+ // simulate click menu item
+ target
+ .closest(".item")
+ .dispatchEvent(new Event("mousedown", { bubbles: true }));
+ return;
+ }
+
+ const source = target.dataset.value;
+ const $labelEl = target
+ ?.closest(".wrap")
+ ?.querySelector(".wrap-inner .single-select"); // the label element
+
+ if (source && $labelEl) {
+ // @ts-ignore
+ $labelEl.title = titles?.[source] || ""; // set title from hints.js
+ $labelEl.textContent = "__biligual__will_be_replaced__"; // marked as will be replaced
+ doTranslate($labelEl, source, "element"); // translate the label element
+ }
+ });
+}
diff --git a/src/lib/html-encode.ts b/src/lib/html-encode.ts
new file mode 100644
index 0000000..9aa02ae
--- /dev/null
+++ b/src/lib/html-encode.ts
@@ -0,0 +1,8 @@
+export function htmlEncode(htmlStr) {
+ return htmlStr
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
diff --git a/src/lib/parse-html-string-to-element.ts b/src/lib/parse-html-string-to-element.ts
new file mode 100644
index 0000000..73412ef
--- /dev/null
+++ b/src/lib/parse-html-string-to-element.ts
@@ -0,0 +1,5 @@
+export function parseHtmlStringToElement(htmlStr) {
+ const template = document.createElement("template");
+ template.insertAdjacentHTML("afterbegin", htmlStr);
+ return template.firstElementChild;
+}
diff --git a/src/lib/read-files.ts b/src/lib/read-files.ts
new file mode 100644
index 0000000..a2b5787
--- /dev/null
+++ b/src/lib/read-files.ts
@@ -0,0 +1,7 @@
+// Load file
+export function readFile(filePath) {
+ const request = new XMLHttpRequest();
+ request.open("GET", `file=${filePath}`, false);
+ request.send(null);
+ return request.responseText;
+}
diff --git a/src/lib/tranlate-page.ts b/src/lib/tranlate-page.ts
new file mode 100644
index 0000000..b8e10cc
--- /dev/null
+++ b/src/lib/tranlate-page.ts
@@ -0,0 +1,48 @@
+import { getI18n } from "../setup";
+import { createLogger } from "./create-logger";
+import { querySelectorAll } from "./gradio-app";
+import { translateEl } from "./translate-el";
+
+export function translatePage() {
+ if (!getI18n()) return;
+
+ const logger = createLogger();
+ logger.time("Full Page");
+
+ // Define arrays of selectors
+ const majorSelectors = [
+ "label span, fieldset span, button", // major label and button description text
+ "textarea[placeholder], select, option", // text box placeholder and select element
+ ".transition > div > span:not([class])",
+ ".label-wrap > span", // collapse panel added by extension
+ ".gradio-image>div.float", // image upload description
+ ".gradio-file>div.float", // file upload description
+ ".gradio-code>div.float", // code editor description
+ "#modelmerger_interp_description .output-html", // model merger description
+ "#modelmerger_interp_description .gradio-html", // model merger description
+ "#lightboxModal span", // image preview lightbox
+ ];
+
+ const minorSelectors = [
+ 'div[data-testid="image"] > div > div', // description of image upload panel
+ "#extras_image_batch > div", // description of extras image batch file upload panel
+ ".output-html:not(#footer), .gradio-html:not(#footer), .output-markdown, .gradio-markdown", // output html exclude footer
+ "#dynamic-prompting", // dynamic-prompting extension
+ ];
+
+ // Process major selectors
+ // biome-ignore lint/complexity/noForEach:
+ majorSelectors.forEach((selector) => {
+ // biome-ignore lint/complexity/noForEach:
+ querySelectorAll(selector).forEach((el) => translateEl(el, { deep: true }));
+ });
+
+ // Process minor selectors
+ // biome-ignore lint/complexity/noForEach:
+ minorSelectors.forEach((selector) => {
+ // biome-ignore lint/complexity/noForEach:
+ querySelectorAll(selector).forEach((el) => translateEl(el, { rich: true }));
+ });
+
+ logger.timeEnd("Full Page");
+}
diff --git a/src/lib/translate-el.ts b/src/lib/translate-el.ts
new file mode 100644
index 0000000..569f589
--- /dev/null
+++ b/src/lib/translate-el.ts
@@ -0,0 +1,54 @@
+import { getConfig, getI18n } from "../setup";
+import { doTranslate } from "./do-translate";
+
+const ignore_selector = [
+ ".bilingual__trans_wrapper", // self
+ ".resultsFlexContainer", // tag-autocomplete
+ "#setting_sd_model_checkpoint select", // model checkpoint
+ "#setting_sd_vae select", // vae model
+ "#txt2img_styles, #img2txt_styles", // styles select
+ ".extra-network-cards .card .actions .name", // extra network cards name
+ "script, style, svg, g, path", // script / style / svg elements
+ "svg *, canvas, canvas *",
+ "#txt2img_prompt_container, #img2img_prompt_container, .physton-prompt",
+ "#txt2img_prompt_container *, #img2img_prompt_container *, .physton-prompt *",
+ ".progressDiv, .progress, .progress-text",
+ ".progressDiv *, .progress *, .progress-text *",
+];
+
+export function translateEl(el, { deep = false, rich = false } = {}) {
+ if (!getI18n) return; // translation not ready.
+ if (el.matches?.(ignore_selector)) return; // ignore some elements.
+
+ if (el.title) {
+ doTranslate(el, el.title, "title");
+ }
+
+ if (el.placeholder && getConfig()?.enableTransPlaceHolder === true) {
+ doTranslate(el, el.placeholder, "placeholder");
+ }
+
+ if (el.tagName === "OPTION") {
+ doTranslate(el, el.textContent, "option");
+ }
+
+ if (deep || rich) {
+ // biome-ignore lint/complexity/noForEach:
+ Array.from(el.childNodes).forEach((node) => {
+ if ((node as Text).nodeName === "#text") {
+ if (rich) {
+ doTranslate(node, (node as Text).textContent, "text");
+ return;
+ }
+
+ if (deep) {
+ doTranslate(node, (node as Text).textContent, "element");
+ }
+ } else if ((node as Text).childNodes.length > 0) {
+ translateEl(node, { deep, rich });
+ }
+ });
+ } else {
+ doTranslate(el, el.textContent, "element");
+ }
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..4d8afbd
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,3 @@
+import { init } from "./init";
+
+document.addEventListener("DOMContentLoaded", init);
diff --git a/src/setup.ts b/src/setup.ts
new file mode 100644
index 0000000..3aa01d2
--- /dev/null
+++ b/src/setup.ts
@@ -0,0 +1,100 @@
+import { opts } from "./config/opts";
+import { createLogger } from "./lib/create-logger";
+import { getRegex } from "./lib/get-regax";
+import { handleDropdown } from "./lib/handle-dropdown";
+import { readFile } from "./lib/read-files";
+import { translatePage } from "./lib/tranlate-page";
+
+interface Config {
+ enabled: boolean;
+ file: string;
+ dirs: string;
+ order: string;
+ enableLogger: boolean;
+}
+
+let i18n = null;
+// biome-ignore lint/suspicious/noExplicitAny:
+const i18nRegex = new Map();
+const i18nScope = {};
+const scopedSource = {};
+let config: Config | null = null;
+
+export function setup() {
+ config = {
+ enabled: opts.bilingual_localization_enabled,
+ file: opts.bilingual_localization_file,
+ dirs: opts.bilingual_localization_dirs,
+ order: opts.bilingual_localization_order,
+ enableLogger: opts.bilingual_localization_logger,
+ };
+
+ const { enabled, file, dirs, enableLogger } = config;
+
+ if (!enabled || file === "None" || dirs === "None") return;
+ const dirsParsed = JSON.parse(dirs);
+
+ const logger = createLogger();
+ if (enableLogger) {
+ logger.init("Bilingual");
+ }
+ logger.log("Bilingual Localization initialized.");
+
+ // Load localization file
+ const regex_scope = /^##(?.+)##(?.+)$/; // ##scope##.skey
+ i18n = JSON.parse(readFile(dirsParsed[file]), (key, value) => {
+ // parse regex translations
+ if (key.startsWith("@@")) {
+ const regex = getRegex(key.slice(2));
+ if (regex instanceof RegExp) {
+ i18nRegex.set(regex, value);
+ }
+ } else {
+ const match = key.match(regex_scope);
+ if (match?.groups) {
+ // parse scope translations
+ let { scope, skey } = match.groups;
+
+ if (scope.startsWith("@")) {
+ scope = scope.slice(1);
+ } else {
+ scope = `#${scope}`;
+ }
+
+ if (!scope.length) {
+ return value;
+ }
+
+ i18nScope[scope] ||= {};
+ i18nScope[scope][skey] = value;
+
+ scopedSource[skey] ||= [];
+ scopedSource[skey].push(scope);
+ } else {
+ return value;
+ }
+ }
+ });
+ translatePage();
+ handleDropdown();
+}
+
+export function getI18n() {
+ return i18n;
+}
+
+export function getI18nRegex() {
+ return i18nRegex;
+}
+
+export function getI18nScope() {
+ return i18nScope;
+}
+
+export function getScopedSource() {
+ return scopedSource;
+}
+
+export function getConfig() {
+ return config;
+}
diff --git a/src/types/opts.d.ts b/src/types/opts.d.ts
new file mode 100644
index 0000000..b9ad0e1
--- /dev/null
+++ b/src/types/opts.d.ts
@@ -0,0 +1,8 @@
+export interface Opts {
+ bilingual_translate_placeholder: boolean;
+ bilingual_localization_enabled: boolean;
+ bilingual_localization_logger: boolean;
+ bilingual_localization_file: string;
+ bilingual_localization_dirs: string;
+ bilingual_localization_order: string;
+}