fix: MT模式翻译残留、docx格式丢失、语言切换器及provider域名修复

- provider.py: 域名匹配改为包含匹配,覆盖dashscope-intl国际站
- segments_agent.py: MT模式改用<<<SEG:n>>>纯文本标记替代JSON,避免qwen-mt模型原文残留
- docx_translator.py: _apply_translation改为按字符比例分配译文到各Run,保留原始格式
- i18nData.json: vi(越南语)替换为id(印尼语),含完整175键翻译
- index.html: 语言切换器移至顶部标题栏,新增浏览器语言自动检测

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
LeonandClaude Opus 4.7 committed 2026-06-08 14:07:13 +08:00
1 parent 9d8eacf0b4
commit 8a5f62342a
5 files changed
+377 -331

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ ProviderType: TypeAlias = Literal["ollama", "bigmodel", "aliyuncs", "volces", "g
def get_provider_by_domain(domain:str)->ProviderType: def get_provider_by_domain(domain:str)->ProviderType:
if domain == "open.bigmodel.cn": if domain == "open.bigmodel.cn":
return "bigmodel" return "bigmodel"
elif domain == "dashscope.aliyuncs.com": elif "dashscope.aliyuncs.com" in domain:
return "aliyuncs" return "aliyuncs"
elif domain == "ark.cn-beijing.volces.com": elif domain == "ark.cn-beijing.volces.com":
return "volces" return "volces"
+120 -90
View File
@@ -15,10 +15,13 @@ from docutranslate.agents.agent import PartialAgentResultError, AgentResultError
from docutranslate.glossary.glossary import Glossary from docutranslate.glossary.glossary import Glossary
from docutranslate.utils.json_utils import segments2json_chunks, fix_json_string from docutranslate.utils.json_utils import segments2json_chunks, fix_json_string
# MT mode plain-text segment marker — designed to survive machine translation unchanged
MT_SEG_MARKER_RE = re.compile(r'<<<SEG:(\d+)>>>\s*\n(.*?)(?=<<<SEG:\d+>>>|\Z)', re.DOTALL)
def generate_prompt(json_segments: str, to_lang: str): def generate_prompt(json_segments: str, to_lang: str):
return f""" return f"""
You will receive a sequence of original text segments to be translated, represented in JSON format. The keys are segment IDs, and the values are the text content to be translated. You will receive a sequence of original text segments to be translated, represented in JSON format. The keys are segment IDs, and the values are the text content to be translated.
Here is the input: Here is the input:
<input> <input>
@@ -58,8 +61,8 @@ Below is an example of how merging should be done when necessary:
input: input:
```json ```json
{{ {{
"EXAMPLE_KEY_1":"汤姆说:“杰克你", "EXAMPLE_KEY_1":"汤姆说:\"杰克你",
"EXAMPLE_KEY_2":"好”。" "EXAMPLE_KEY_2":"好\"。"
}} }}
``` ```
output: output:
@@ -92,6 +95,44 @@ def get_target_segments(result: str):
return result return result
def _chunk_to_mt_prompt(chunk: dict) -> str:
"""Convert a JSON chunk like {'0': 'text1', '1': 'text2'} to MT-friendly plain text."""
parts = []
for key in sorted(chunk.keys(), key=int):
parts.append(f"<<<SEG:{key}>>>\n{chunk[key]}")
return "\n".join(parts)
def _parse_mt_prompt_to_dict(mt_prompt: str) -> dict:
"""Parse an MT prompt string back to the original segment dict."""
result = {}
for match in MT_SEG_MARKER_RE.finditer(mt_prompt):
key = match.group(1)
value = match.group(2).strip()
result[key] = value
if not result:
# MT format parsing failed — wrap entire prompt as single segment
result = {"0": mt_prompt}
return result
def _parse_mt_response(text: str, original_chunk: dict) -> dict:
"""Parse MT plain-text response using <<<SEG:n>>> markers back to dict."""
result = {}
for match in MT_SEG_MARKER_RE.finditer(text):
key = match.group(1)
value = match.group(2).strip()
if key in original_chunk:
result[key] = value
# Fill missing keys from original
for key in original_chunk:
if key not in result:
result[key] = ""
return result
@dataclass(kw_only=True) @dataclass(kw_only=True)
class SegmentsTranslateAgentConfig(AgentConfig): class SegmentsTranslateAgentConfig(AgentConfig):
to_lang: str to_lang: str
@@ -123,20 +164,16 @@ class SegmentsTranslateAgent(Agent):
def _result_handler(self, result: str, origin_prompt: str, logger: Logger): def _result_handler(self, result: str, origin_prompt: str, logger: Logger):
""" """
处理成功的API响应。 处理成功的API响应。
- 如果键完全匹配,返回翻译结果。 MT模式下使用 <<<SEG:n>>> 标记解析纯文本响应,避免JSON格式不兼容问题。
- 如果键不匹配,构造一个部分成功的结果,并通过 PartialTranslationError 异常抛出,以触发重试。
- 其他错误(如JSON解析失败、模型偷懒)则抛出普通 ValueError 触发重试。
- MT模式下,如果返回的是纯文本而非JSON,将其按行分割并映射到原始键。
""" """
# MT模式下直接解析origin_prompt为JSON(纯净JSON,没有<input>包装)
if self.is_mt_mode: if self.is_mt_mode:
original_segments = origin_prompt return self._result_handler_mt(result, origin_prompt, logger)
else:
original_segments = get_original_segments(origin_prompt) # --- Non-MT mode (JSON-based) ---
original_segments = get_original_segments(origin_prompt)
result = get_target_segments(result) result = get_target_segments(result)
if result == "": if result == "":
if original_segments.strip() != "": if original_segments.strip() != "":
# print(f"【测试】origin_prompt:\n{origin_prompt}\nresult:\n{result}")
raise AgentResultError("result为空值但原文不为空") raise AgentResultError("result为空值但原文不为空")
return {} return {}
try: try:
@@ -144,37 +181,6 @@ class SegmentsTranslateAgent(Agent):
original_chunk = json_repair.loads(original_segments) original_chunk = json_repair.loads(original_segments)
repaired_result = json_repair.loads(result) repaired_result = json_repair.loads(result)
# MT模式兼容:处理各种非标准返回格式
if self.is_mt_mode:
# 如果是列表,尝试合并所有字典
if isinstance(repaired_result, list):
logger.debug(f"[MT模式] 返回结果是列表,包含 {len(repaired_result)} 个元素")
merged_result = {}
for item in repaired_result:
if isinstance(item, dict):
merged_result.update(item)
repaired_result = merged_result
# 如果返回的是纯文本(字符串),尝试将其映射到原始键
if isinstance(repaired_result, str):
original_keys = list(original_chunk.keys())
# 按行分割结果,去除空行
result_lines = [line.strip() for line in repaired_result.split('\n') if line.strip()]
# 如果只有一行结果但多个键,将整个结果分配给第一个键,其余为空
if len(result_lines) == 1 and len(original_keys) > 1:
repaired_result = {original_keys[0]: result_lines[0]}
for key in original_keys[1:]:
repaired_result[key] = ""
# 如果结果行数与键数匹配,逐行对应
elif len(result_lines) == len(original_keys):
repaired_result = {original_keys[i]: result_lines[i] for i in range(len(original_keys))}
# 如果结果行数不匹配,将所有结果合并给第一个键
else:
repaired_result = {original_keys[0]: repaired_result}
for key in original_keys[1:]:
repaired_result[key] = ""
if not isinstance(repaired_result, dict): if not isinstance(repaired_result, dict):
raise AgentResultError(f"Agent返回结果不是dict的json形式, result: {result}") raise AgentResultError(f"Agent返回结果不是dict的json形式, result: {result}")
@@ -184,9 +190,7 @@ class SegmentsTranslateAgent(Agent):
original_keys = set(original_chunk.keys()) original_keys = set(original_chunk.keys())
result_keys = set(repaired_result.keys()) result_keys = set(repaired_result.keys())
# 如果键不完全匹配
if original_keys != result_keys: if original_keys != result_keys:
# 仍然先构造一个最完整的“部分结果”
final_chunk = {} final_chunk = {}
common_keys = original_keys.intersection(result_keys) common_keys = original_keys.intersection(result_keys)
missing_keys = original_keys - result_keys missing_keys = original_keys - result_keys
@@ -201,74 +205,104 @@ class SegmentsTranslateAgent(Agent):
for key in missing_keys: for key in missing_keys:
final_chunk[key] = str(original_chunk[key]) final_chunk[key] = str(original_chunk[key])
raise PartialAgentResultError("键不匹配,触发重试", partial_result=final_chunk,
append_prompt=f"\nBe careful not to omit any keys from the input; do not combine sentences when translating.\n")
# 抛出自定义异常,将部分结果和错误信息一起传递出去
raise PartialAgentResultError("键不匹配,触发重试", partial_result=final_chunk,append_prompt=f"\nBe careful not to omit any keys from the input; do not combine sentences when translating.\n")
# 如果键完全匹配(理想情况),正常返回
for key, value in repaired_result.items(): for key, value in repaired_result.items():
repaired_result[key] = str(value) repaired_result[key] = str(value)
return repaired_result return repaired_result
except (RuntimeError, JSONDecodeError) as e: except (RuntimeError, JSONDecodeError) as e:
# MT模式兼容:如果JSON解析失败,尝试将结果作为纯文本处理
if self.is_mt_mode:
try:
original_chunk = json_repair.loads(original_segments)
original_keys = list(original_chunk.keys())
result_lines = [line.strip() for line in result.split('\n') if line.strip()]
if len(result_lines) == 1 and len(original_keys) > 1:
repaired_result = {original_keys[0]: result_lines[0]}
for key in original_keys[1:]:
repaired_result[key] = ""
elif len(result_lines) == len(original_keys):
repaired_result = {original_keys[i]: result_lines[i] for i in range(len(original_keys))}
else:
repaired_result = {original_keys[0]: result}
for key in original_keys[1:]:
repaired_result[key] = ""
# 验证结果
if set(repaired_result.keys()) != set(original_chunk.keys()):
raise AgentResultError(f"MT模式解析后键不匹配")
return repaired_result
except Exception as mt_e:
raise AgentResultError(f"MT模式纯文本处理失败: {mt_e.__repr__()}")
# 对于JSON解析等硬性错误,继续抛出普通ValueError
raise AgentResultError(f"结果处理失败: {e.__repr__()}") raise AgentResultError(f"结果处理失败: {e.__repr__()}")
def _result_handler_mt(self, result: str, origin_prompt: str, logger: Logger) -> dict:
"""MT模式专用结果处理器:解析 <<<SEG:n>>> 标记格式的纯文本响应。"""
result_clean = result.strip()
if result_clean == "":
if origin_prompt.strip() != "":
raise AgentResultError("result为空值但原文不为空")
return {}
original_chunk = _parse_mt_prompt_to_dict(origin_prompt)
original_keys = set(original_chunk.keys())
# Try parsing with <<<SEG:n>>> markers
parsed = _parse_mt_response(result_clean, original_chunk)
if parsed and any(v.strip() for v in parsed.values()):
result_keys = set(parsed.keys())
if result_keys == original_keys:
# Check if result is identical to original (no translation happened)
all_same = all(
parsed.get(k, "").strip() == str(original_chunk.get(k, "")).strip()
for k in original_keys
)
if all_same:
raise AgentResultError("翻译结果与原文完全相同,疑似翻译失败,将进行重试。")
return parsed
# If key mismatch, try as Partial result
if result_keys and result_keys != original_keys:
final_chunk = {}
for key in original_keys:
final_chunk[key] = parsed.get(key, str(original_chunk.get(key, "")))
raise PartialAgentResultError(
"MT模式键不匹配,触发重试",
partial_result=final_chunk,
append_prompt="\nPreserve all <<<SEG:n>>> markers exactly as they appear.\n"
)
# Fallback: Try line-by-line mapping (MT model might have removed markers)
result_lines = [line.strip() for line in result_clean.split('\n') if line.strip()]
original_seg_list = [str(original_chunk.get(str(i), "")) for i in range(len(original_chunk))]
non_empty_lines = [l for l in result_lines if l]
if len(non_empty_lines) == len(original_chunk):
repaired = {str(i): non_empty_lines[i] for i in range(len(non_empty_lines))}
all_same = all(
repaired.get(k, "").strip() == str(original_chunk.get(k, "")).strip()
for k in original_keys
)
if all_same:
raise AgentResultError("翻译结果与原文完全相同(逐行),疑似翻译失败,将进行重试。")
return repaired
# Last fallback: assign all result text to first key
if non_empty_lines:
repaired = {str(i): "" for i in range(len(original_chunk))}
repaired["0"] = "\n".join(non_empty_lines)
return repaired
raise AgentResultError("MT模式无法解析响应")
def _error_result_handler(self, origin_prompt: str, logger: Logger): def _error_result_handler(self, origin_prompt: str, logger: Logger):
""" """
处理在所有重试后仍然失败的请求。 处理在所有重试后仍然失败的请求。
作为备用方案,返回原文内容,并将所有值转换为字符串。 作为备用方案,返回原文内容。
""" """
# MT模式下直接解析origin_prompt为JSON(纯净JSON,没有<input>包装)
if self.is_mt_mode: if self.is_mt_mode:
original_segments = origin_prompt original_chunk = _parse_mt_prompt_to_dict(origin_prompt)
else: for key in list(original_chunk.keys()):
original_segments = get_original_segments(origin_prompt) original_chunk[key] = f"{original_chunk[key]}"
return original_chunk
original_segments = get_original_segments(origin_prompt)
if original_segments == "": if original_segments == "":
return {} return {}
try: try:
original_chunk = json_repair.loads(original_segments) original_chunk = json_repair.loads(original_segments)
# 此处逻辑保留,作为最终的兜底方案
for key, value in original_chunk.items(): for key, value in original_chunk.items():
original_chunk[key] = f"{value}" original_chunk[key] = f"{value}"
return original_chunk return original_chunk
except (RuntimeError, JSONDecodeError): except (RuntimeError, JSONDecodeError):
logger.error(f"原始prompt也不是有效的json格式: {original_segments}") logger.error(f"原始prompt也不是有效的json格式: {original_segments}")
# 如果原始prompt本身也无效,返回一个清晰的错误对象
return {"error": f"{original_segments}"} return {"error": f"{original_segments}"}
def send_segments(self, segments: list[str], chunk_size: int) -> list[str]: def send_segments(self, segments: list[str], chunk_size: int) -> list[str]:
indexed_originals, chunks, merged_indices_list = segments2json_chunks(segments, chunk_size) indexed_originals, chunks, merged_indices_list = segments2json_chunks(segments, chunk_size)
# MT模式下直接发送纯净JSON,不添加额外提示词
if self.is_mt_mode: if self.is_mt_mode:
prompts = [json.dumps(chunk, ensure_ascii=False, indent=0) for chunk in chunks] prompts = [_chunk_to_mt_prompt(chunk) for chunk in chunks]
else: else:
prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks] prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks]
translated_chunks = super().send_prompts(prompts=prompts, json_format=self.force_json, translated_chunks = super().send_prompts(prompts=prompts, json_format=self.force_json,
@@ -292,7 +326,6 @@ class SegmentsTranslateAgent(Agent):
except Exception as e: except Exception as e:
self.logger.error(f"处理chunk时发生未知错误: {e.__repr__()}") self.logger.error(f"处理chunk时发生未知错误: {e.__repr__()}")
# 重建最终列表
result = [] result = []
last_end = 0 last_end = 0
ls = list(indexed_translated.values()) ls = list(indexed_translated.values())
@@ -308,9 +341,8 @@ class SegmentsTranslateAgent(Agent):
async def send_segments_async(self, segments: list[str], chunk_size: int) -> list[str]: async def send_segments_async(self, segments: list[str], chunk_size: int) -> list[str]:
indexed_originals, chunks, merged_indices_list = await asyncio.to_thread(segments2json_chunks, segments, indexed_originals, chunks, merged_indices_list = await asyncio.to_thread(segments2json_chunks, segments,
chunk_size) chunk_size)
# MT模式下直接发送纯净JSON,不添加额外提示词
if self.is_mt_mode: if self.is_mt_mode:
prompts = [json.dumps(chunk, ensure_ascii=False, indent=0) for chunk in chunks] prompts = [_chunk_to_mt_prompt(chunk) for chunk in chunks]
else: else:
prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks] prompts = [generate_prompt(json.dumps(chunk, ensure_ascii=False, indent=0), self.to_lang) for chunk in chunks]
@@ -326,7 +358,6 @@ class SegmentsTranslateAgent(Agent):
continue continue
for key, val in chunk.items(): for key, val in chunk.items():
if key in indexed_translated: if key in indexed_translated:
# 此处不再需要 str(val),因为 _result_handler 已经处理好了
indexed_translated[key] = val indexed_translated[key] = val
else: else:
self.logger.warning(f"在结果chunk中发现未知键 '{key}',已忽略。") self.logger.warning(f"在结果chunk中发现未知键 '{key}',已忽略。")
@@ -335,7 +366,6 @@ class SegmentsTranslateAgent(Agent):
except Exception as e: except Exception as e:
self.logger.error(f"处理chunk时发生未知错误: {e.__repr__()}") self.logger.error(f"处理chunk时发生未知错误: {e.__repr__()}")
# 重建最终列表
result = [] result = []
last_end = 0 last_end = 0
ls = list(indexed_translated.values()) ls = list(indexed_translated.values())
+173 -173
View File
@@ -353,181 +353,181 @@
"configImportError": "Failed to parse config file, please check the file format.", "configImportError": "Failed to parse config file, please check the file format.",
"providerLabel": "Provider" "providerLabel": "Provider"
}, },
"vi": { "id": {
"pageTitle": "DocuTranslate - Dịch tài liệu tương tác", "pageTitle": "DocuTranslate - Terjemahan Dokumen Interaktif",
"tutorialBtn": "Hướng dẫn", "tutorialBtn": "Tutorial",
"projectContributeBtn": "Hợp tác dự án", "projectContributeBtn": "Kolaborasi Proyek",
"workflowTitle": "Chọn quy trình làm việc", "workflowTitle": "Pilih Alur Kerja",
"workflowOptionMarkdown": "Chuyển sang Markdown rồi dịch (.pdf/.md/.png, v.v.)", "workflowOptionMarkdown": "Konversi ke Markdown lalu Terjemahkan (.pdf/.md/.png, dll.)",
"workflowOptionTxt": "Dịch văn bản thuần (.txt)", "workflowOptionTxt": "Terjemahan Teks Biasa (.txt)",
"workflowOptionEpub": "Dịch EPUB (.epub)", "workflowOptionEpub": "Terjemahan EPUB (.epub)",
"workflowOptionDocx": "Dịch DOCX (.docx)", "workflowOptionDocx": "Terjemahan DOCX (.docx)",
"workflowOptionXlsx": "Dịch XLSX (.xlsx/.csv)", "workflowOptionXlsx": "Terjemahan XLSX (.xlsx/.csv)",
"workflowOptionSrt": "Dịch phụ đề SRT (.srt)", "workflowOptionSrt": "Terjemahan Subtitle SRT (.srt)",
"workflowOptionAss": "Dịch phụ đề ASS (.ass)", "workflowOptionAss": "Terjemahan Subtitle ASS (.ass)",
"workflowOptionJson": "Dịch JSON (.json)", "workflowOptionJson": "Terjemahan JSON (.json)",
"workflowOptionHtml": "Dịch HTML (.html)", "workflowOptionHtml": "Terjemahan HTML (.html)",
"workflowOptionPptx": "Thuyết trình PPTX (.pptx)", "workflowOptionPptx": "Presentasi PPTX (.pptx)",
"autoWorkflowLabel": "Tự động chọn quy trình", "autoWorkflowLabel": "Pilih Alur Kerja Otomatis",
"txtSettingsTitleText": "Tùy chọn dịch TXT", "txtSettingsTitleText": "Opsi Terjemahan TXT",
"insertModeLabel": "Chế độ chèn", "insertModeLabel": "Mode Sisip",
"insertModeReplace": "Thay thế bản gốc (Replace)", "insertModeReplace": "Ganti Asli (Replace)",
"insertModeAppend": "Thêm vào sau bản gốc (Append)", "insertModeAppend": "Tambahkan ke Asli (Append)",
"insertModePrepend": "Thêm vào trước bản gốc (Prepend)", "insertModePrepend": "Sisipkan ke Depan (Prepend)",
"insertModeHelpTxt": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpTxt": "Pilih cara menyisipkan teks terjemahan.",
"separatorLabel": "Dấu phân cách", "separatorLabel": "Pemisah",
"separatorPlaceholderSimple": "Ví dụ: \\n---\\n", "separatorPlaceholderSimple": "contoh: \\n---\\n",
"separatorHelp": "Ký tự dùng để phân cách văn bản gốc và văn bản dịch trong chế độ Append hoặc Prepend. <code>\\n</code> đại diện cho xuống dòng.", "separatorHelp": "Karakter pemisah antara teks asli dan terjemahan. <code>\\n</code> untuk baris baru.",
"segmentModeLabel": "Chế độ phân đoạn", "segmentModeLabel": "Mode Segmen",
"segmentModeLine": "Theo dòng (Mỗi dòng là một đoạn)", "segmentModeLine": "Per Baris",
"segmentModeParagraph": "Theo đoạn văn (Gộp các dòng không trống liên tiếp)", "segmentModeParagraph": "Per Paragraf",
"segmentModeNone": "Không phân đoạn (Toàn bộ văn bản là một đoạn)", "segmentModeNone": "Tanpa Segmentasi",
"segmentModeHelp": "Chọn cách phân chia văn bản để dịch.", "segmentModeHelp": "Pilih cara membagi teks untuk terjemahan.",
"docxSettingsTitleText": "Tùy chọn dịch DOCX", "docxSettingsTitleText": "Opsi Terjemahan DOCX",
"insertModeHelpDocx": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpDocx": "Pilih cara menyisipkan teks terjemahan.",
"xlsxSettingsTitleText": "Tùy chọn dịch XLSX", "xlsxSettingsTitleText": "Opsi Terjemahan XLSX",
"insertModeHelpXlsx": "Chọn cách chèn văn bản đã dịch vào các ô.", "insertModeHelpXlsx": "Pilih cara menyisipkan teks terjemahan ke dalam sel.",
"xlsxTranslateRegionsLabel": "Vùng dịch (Tùy chọn)", "xlsxTranslateRegionsLabel": "Area Terjemahan (Opsional)",
"xlsxTranslateRegionsPlaceholder": "Mỗi dòng một vùng, ví dụ: Sheet1!A1:B10 (áp dụng cho tất cả các sheet nếu bỏ qua tên sheet).", "xlsxTranslateRegionsPlaceholder": "Satu area per baris, contoh: Sheet1!A1:B10",
"srtSettingsTitleText": "Tùy chọn dịch SRT", "srtSettingsTitleText": "Opsi Terjemahan SRT",
"insertModeHelpSrt": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpSrt": "Pilih cara menyisipkan teks terjemahan.",
"epubSettingsTitleText": "Tùy chọn dịch EPUB", "epubSettingsTitleText": "Opsi Terjemahan EPUB",
"insertModeHelpEpub": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpEpub": "Pilih cara menyisipkan teks terjemahan.",
"htmlSettingsTitleText": "Tùy chọn dịch HTML", "htmlSettingsTitleText": "Opsi Terjemahan HTML",
"insertModeHelpHtml": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpHtml": "Pilih cara menyisipkan teks terjemahan.",
"assSettingsTitleText": "Tùy chọn dịch ASS", "assSettingsTitleText": "Opsi Terjemahan ASS",
"insertModeHelpAss": "Chọn cách chèn văn bản đã dịch.", "insertModeHelpAss": "Pilih cara menyisipkan teks terjemahan.",
"separatorPlaceholderAss": "Ví dụ: \\N (ký tự xuống dòng)", "separatorPlaceholderAss": "contoh: \\N (karakter baris baru)",
"separatorHelpAss": "Ký tự dùng để phân cách văn bản gốc và văn bản dịch trong chế độ Append hoặc Prepend. <code>\\N</code> là ký tự xuống dòng cho định dạng ASS.", "separatorHelpAss": "Karakter pemisah. <code>\\N</code> untuk format ASS.",
"pptxSettingsTitleText": "Tùy chọn dịch PPTX", "pptxSettingsTitleText": "Opsi Terjemahan PPTX",
"insertModeHelpPptx": "Chọn cách chèn văn bản đã dịch vào hộp văn bản.", "insertModeHelpPptx": "Pilih cara menyisipkan teks terjemahan ke kotak teks.",
"jsonSettingsTitleText": "Cấu hình đường dẫn JSON", "jsonSettingsTitleText": "Konfigurasi JSON Path",
"jsonPathLabel": "Đường dẫn JSON cần dịch", "jsonPathLabel": "JSON Path untuk Diterjemahkan",
"jsonPathPlaceholder": "Mỗi dòng một đường dẫn, ví dụ:\n$.name\n$.*", "jsonPathPlaceholder": "Satu path per baris, contoh:\n$.name\n$.*",
"jsonPathHelp": "Sử dụng cú pháp <code>jsonpath-ng</code>. Mỗi dòng đại diện cho một đường dẫn JSON. Tất cả các chuỗi trong các đối tượng khớp sẽ được dịch.", "jsonPathHelp": "Menggunakan sintaks <code>jsonpath-ng</code>. Setiap baris adalah path JSON.",
"parsingSettingsTitleText": "Cấu hình phân tích", "parsingSettingsTitleText": "Konfigurasi Parsing",
"parsingEngineLabel": "Công cụ phân tích", "parsingEngineLabel": "Mesin Parsing",
"parsingEngineHelp": "Nếu tệp tải lên đã ở định dạng .md, mục này có thể bỏ qua.", "parsingEngineHelp": "Jika file sudah dalam format .md, ini bisa dilewati.",
"mineruTokenPlaceholder": "Bắt buộc khi sử dụng công cụ Mineru", "mineruTokenPlaceholder": "Diperlukan saat menggunakan mesin Mineru",
"modelVersionLabel": "Phiên bản mô hình Mineru", "modelVersionLabel": "Versi Model Mineru",
"modelVersionVlm": "VLM", "modelVersionVlm": "VLM",
"modelVersionPipline": "Pipeline", "modelVersionPipline": "Pipeline",
"modelVersionHelp": "Mineru VLM là mô hình thử nghiệm nội bộ mới hơn.", "modelVersionHelp": "Mineru VLM adalah model uji internal yang lebih baru.",
"mineruDeployBaseUrlLabel": "URL dịch vụ (Base URL)", "mineruDeployBaseUrlLabel": "URL Layanan (Base URL)",
"mineruDeployBaseUrlPlaceholder": "Ví dụ: http://127.0.0.1:8000", "mineruDeployBaseUrlPlaceholder": "contoh: http://127.0.0.1:8000",
"mineruDeployBackendLabel": "Loại Backend", "mineruDeployBackendLabel": "Tipe Backend",
"mineruDeployLangListLabel": "Danh sách ngôn ngữ (Chế độ Pipeline)", "mineruDeployLangListLabel": "Daftar Bahasa (Mode Pipeline)",
"mineruDeployServerUrlLabel": "URL máy chủ", "mineruDeployServerUrlLabel": "URL Server",
"mineruDeployServerUrlPlaceholder": "Ví dụ: http://127.0.0.1:30000", "mineruDeployServerUrlPlaceholder": "contoh: http://127.0.0.1:30000",
"mineruDeployParseMethodLabel": "Phương pháp phân tích", "mineruDeployParseMethodLabel": "Metode Parse",
"mineruDeployTableEnableLabel": "Nhận dạng bảng", "mineruDeployTableEnableLabel": "Pengenalan Tabel",
"mineruDeployStartPageLabel": "Trang bắt đầu", "mineruDeployStartPageLabel": "Halaman Awal",
"mineruDeployEndPageLabel": "Trang kết thúc", "mineruDeployEndPageLabel": "Halaman Akhir",
"mineruDeployFormulaEnableLabel": "Bật phân tích công thức", "mineruDeployFormulaEnableLabel": "Aktifkan Parsing Formula",
"formulaOcrLabel": "Nhận dạng công thức", "formulaOcrLabel": "Pengenalan Formula",
"codeOcrLabel": "Nhận dạng mã (Code)", "codeOcrLabel": "Pengenalan Kode",
"aiSettingsTitleText": "Mô hình dịch", "aiSettingsTitleText": "Model Terjemahan",
"skipTranslationLabel": "Bỏ qua dịch thuật", "skipTranslationLabel": "Lewati Terjemahan",
"modelPresetLabel": "Mẫu mô hình", "modelPresetLabel": "Preset Model",
"modelPresetPlaceholder": "Chọn mẫu mô hình", "modelPresetPlaceholder": "Pilih preset model",
"modelPresetEmpty": "Hãy cấu hình sẵn mẫu mô hình trong biến môi trường phía máy chủ", "modelPresetEmpty": "Konfigurasikan preset model di server terlebih dahulu",
"modelPresetRuntimeHint": "Nhà cung cấp, endpoint và API Key sẽ được đọc từ biến môi trường phía máy chủ khi chạy.", "modelPresetRuntimeHint": "Provider, endpoint, dan API Key akan dibaca dari environment server.",
"platformLabel": "Chọn nền tảng", "platformLabel": "Pilih Platform",
"platformCustom": "API tùy chỉnh", "platformCustom": "API Kustom",
"baseUrlLabel": "Địa chỉ API (Base URL)", "baseUrlLabel": "Alamat API (Base URL)",
"baseUrlPlaceholder": "Địa chỉ tương thích OpenAI", "baseUrlPlaceholder": "Alamat kompatibel OpenAI",
"apiKeyPlaceholder": "Vui lòng nhập API Key của bạn", "apiKeyPlaceholder": "Masukkan API Key Anda",
"modelIdLabel": "Model ID", "modelIdLabel": "Model ID",
"modelIdPlaceholder": "Ví dụ: gpt-4o, glm-4", "modelIdPlaceholder": "contoh: gpt-4o, glm-4",
"systemProxyLabel": "Bật proxy hệ thống", "systemProxyLabel": "Aktifkan Proxy Sistem",
"forceJson": "Bắt buộc xuất JSON", "forceJson": "Paksa output JSON",
"forceJsonTooltip": "Bắt buộc mô hình xuất định dạng JSON khi được yêu cầu. Có thể làm giảm chất lượng dịch; khuyên dùng nên tắt đối với các mô hình tuân thủ hướng dẫn tốt.", "forceJsonTooltip": "Paksa model untuk output format JSON.",
"translationSettingsTitleText": "Cấu hình dịch thuật", "translationSettingsTitleText": "Konfigurasi Terjemahan",
"targetLanguageLabel": "Ngôn ngữ đích", "targetLanguageLabel": "Bahasa Target",
"targetLanguageCustom": "Khác (Tùy chỉnh)", "targetLanguageCustom": "Lainnya (Kustom)",
"customLangPlaceholder": "Vui lòng nhập ngôn ngữ đích, ví dụ: Vietnamese", "customLangPlaceholder": "Masukkan bahasa target, contoh: Italian",
"thinkingModeLabel": "Chế độ tư duy (Thinking Mode)", "thinkingModeLabel": "Mode Berpikir",
"thinkingModeTooltip": "Thiết lập xem mô hình suy luận hỗn hợp có thực hiện tư duy hay không. Hiện được hỗ trợ bởi dòng glm4.5 của Zhipu, dòng seed1.6 của Volcengine, SiliconFlow, dòng Gemini của Google và 302AI (một phần). Khuyên dùng tùy chọn 'Tắt'.", "thinkingModeTooltip": "Disarankan: Nonaktifkan.",
"thinkingModeEnable": "Bật", "thinkingModeEnable": "Aktifkan",
"thinkingModeDisable": "Tắt (Khuyên dùng)", "thinkingModeDisable": "Nonaktifkan (Disarankan)",
"thinkingModeDefault": "Mặc định", "thinkingModeDefault": "Default",
"customPromptLabel": "Prompt tùy chỉnh", "customPromptLabel": "Prompt Kustom",
"customPromptPlaceholder": "Tùy chọn, ví dụ: \"Không dịch tên người, giữ nguyên ngôn ngữ gốc\"", "customPromptPlaceholder": "Opsional, contoh: \"Jangan terjemahkan nama orang\"",
"chunkSizeLabel": "Kích thước phân khối (Chunk Size)", "chunkSizeLabel": "Ukuran Potongan",
"resetBtn": "Đặt lại", "resetBtn": "Reset",
"concurrentLabel": "Số lượng đồng thời", "concurrentLabel": "Konkurensi",
"retryLabel": "Thử lại", "retryLabel": "Percobaan Ulang",
"rpmLabel": "Số yêu cầu mỗi phút (RPM)", "rpmLabel": "Permintaan per Menit (RPM)",
"tpmLabel": "Số token mỗi phút (TPM - Ước tính)", "tpmLabel": "Token per Menit (TPM - Estimasi)",
"unlimitedPlaceholder": "Để trống nếu không giới hạn", "unlimitedPlaceholder": "Kosongkan untuk tanpa batas",
"glossaryGenTitle": "Thuật ngữ", "glossaryGenTitle": "Glosarium",
"glossaryLabel": "Bảng thuật ngữ (Tùy chọn)", "glossaryLabel": "Glosarium (Opsional)",
"glossaryHelp": "Chọn một hoặc nhiều tệp CSV. Các tệp phải chứa tiêu đề 'src' và 'dst', đại diện cho thuật ngữ nguồn và đích.", "glossaryHelp": "Pilih file CSV dengan kolom src dan dst.",
"viewGlossaryBtn": "Xem bảng thuật ngữ", "viewGlossaryBtn": "Lihat Glosarium",
"clearGlossaryBtn": "Xóa", "clearGlossaryBtn": "Hapus",
"glossaryGenEnableLabel": "Tự động tạo bảng thuật ngữ", "glossaryGenEnableLabel": "Buat Glosarium Otomatis",
"glossaryCustomPromptLabel": "Prompt tùy chỉnh", "glossaryCustomPromptLabel": "Prompt Kustom",
"glossaryCustomPromptPlaceholder": "Gợi ý tạo bảng thuật ngữ", "glossaryCustomPromptPlaceholder": "Prompt pembuatan glosarium",
"glossaryGenConfigLabel": "Cấu hình tạo thuật ngữ", "glossaryGenConfigLabel": "Konfigurasi Pembuatan Glosarium",
"glossaryGenConfigSame": "Giống cấu hình dịch", "glossaryGenConfigSame": "Sama dengan Konfigurasi Terjemahan",
"glossaryGenConfigCustom": "Tùy chỉnh", "glossaryGenConfigCustom": "Kustom",
"importConfigBtn": "Nhập cấu hình", "importConfigBtn": "Impor Konfigurasi",
"exportConfigBtn": "Xuất cấu hình", "exportConfigBtn": "Ekspor Konfigurasi",
"taskListTitle": "Danh sách tác vụ", "taskListTitle": "Daftar Tugas",
"newTaskBtn": "Tác vụ mới", "newTaskBtn": "Tugas Baru",
"noTaskPlaceholder": "Chưa có tác vụ nào. Nhấn \"Tác vụ mới\" để bắt đầu!", "noTaskPlaceholder": "Belum ada tugas. Klik \"Tugas Baru\" untuk memulai!",
"taskCardIdLabel": "ID Tác vụ", "taskCardIdLabel": "ID Tugas",
"taskCardIdPlaceholder": "Đang chờ gửi...", "taskCardIdPlaceholder": "Menunggu...",
"taskCardFileDrop": "Nhấp hoặc kéo tệp vào đây", "taskCardFileDrop": "Klik atau seret file ke sini",
"taskCardFileSelected": "Đã chọn tệp", "taskCardFileSelected": "File Dipilih",
"taskCardFilenameLabel": "Tên tệp: ", "taskCardFilenameLabel": "Nama File: ",
"taskCardLogLabel": "Nhật ký (Logs)", "taskCardLogLabel": "Log",
"copyLogsTooltip": "Sao chép nhật ký", "copyLogsTooltip": "Salin log",
"taskCardStatusWaiting": "Đang chờ tải tệp lên...", "taskCardStatusWaiting": "Menunggu unggahan file...",
"taskCardPreviewBtn": "Xem trước", "taskCardPreviewBtn": "Pratinjau",
"taskCardDownloadBtn": "Tải xuống", "taskCardDownloadBtn": "Unduh",
"taskCardAttachmentBtn": "Tệp đính kèm", "taskCardAttachmentBtn": "Lampiran",
"taskCardStartBtn": "Bắt đầu dịch", "taskCardStartBtn": "Mulai Terjemahan",
"downloadMdEmbedded": "Markdown (Nhúng ảnh)", "downloadMdEmbedded": "Markdown (Gambar Disematkan)",
"downloadMdZip": "Markdown nén (Zip)", "downloadMdZip": "Markdown Zip",
"previewBilingualBtn": "Song ngữ", "previewBilingualBtn": "Dwi Bahasa",
"previewTranslatedOnlyBtn": "Chỉ bản dịch", "previewTranslatedOnlyBtn": "Hanya Terjemahan",
"syncScrollTooltip": "Cuộn đồng bộ", "syncScrollTooltip": "Sinkronisasi Gulir",
"previewOriginal": "Bản gốc", "previewOriginal": "Asli",
"previewTranslated": "Bản dịch", "previewTranslated": "Terjemahan",
"closeBtn": "Đóng", "closeBtn": "Tutup",
"tutorialModalTitle": "Hướng dẫn sử dụng", "tutorialModalTitle": "Tutorial",
"tutorialModalBody": "<p><i class=\"bi bi-camera-video me-2\"></i>Video hướng dẫn có sẵn trên Bilibili bằng cách tìm kiếm <a href=\"https://search.bilibili.com/all?keyword=docutranslate\" target=\"_blank\">docutranslate</a>.</p><p>Chào mừng bạn đến với DocuTranslate! Vui lòng làm theo các bước sau để dịch tài liệu của bạn:</p><ol><li><strong><i class=\"bi bi-diagram-3 me-2\"></i>Bước 1: Chọn quy trình làm việc</strong><p class=\"mt-2\">Ở đầu bảng cấu hình bên trái, trước tiên hãy chọn quy trình xử lý phù hợp nhất với loại tệp của bạn.</p><div class=\"alert alert-info mt-2\" role=\"alert\"><i class=\"bi bi-lightbulb-fill me-2\"></i>Mẹo: \"Tự động chọn quy trình\" được bật theo mặc định. Chỉ cần tải tệp lên, hệ thống sẽ tự động khớp với quy trình phù hợp để đơn giản hóa thao tác.</div><ul><li><b>Chuyển sang Markdown rồi Dịch</b>: Thích hợp để dịch PDF, Markdown, hình ảnh, v.v. Đây là chế độ đa năng và mạnh mẽ nhất.</li><li><b>Dịch văn bản thuần</b>: Dùng để dịch các tệp văn bản thuần <code>.txt</code>.</li><li><b>Dịch EPUB</b>: Dùng để dịch các tệp sách điện tử <code>.epub</code>.</li><li><b>Dịch DOCX</b>: Dùng để dịch các tài liệu Word <code>.docx</code>.</li><li><b>Dịch XLSX</b>: Dùng để dịch các tệp bảng tính <code>.xlsx</code> hoặc <code>.csv</code>.</li><li><b>Dịch PPTX</b>: Dùng để dịch các tệp trình chiếu <code>.pptx</code>.</li><li><b>Dịch phụ đề SRT</b>: Dùng để dịch các tệp phụ đề <code>.srt</code>.</li><li><b>Dịch phụ đề ASS</b>: Dùng để dịch các tệp phụ đề nâng cao <code>.ass</code>.</li><li><b>Dịch JSON</b>: Dùng để dịch các trường cụ thể trong tệp <code>.json</code>.</li><li><b>Dịch HTML</b>: Dùng để dịch các tệp trang web <code>.html</code>.</li></ul></li><li><strong><i class=\"bi bi-gear-fill me-2\"></i>Bước 2: Cấu hình tham số</strong><p class=\"mt-2\">Sau khi chọn quy trình, các tùy chọn cấu hình liên quan sẽ xuất hiện bên dưới. Vui lòng hoàn tất cài đặt theo thứ tự (tất cả cấu hình được tự động lưu trong trình duyệt của bạn):</p><p class=\"mb-2\"><strong>A. Tùy chọn riêng theo quy trình</strong> (Xuất hiện dựa trên lựa chọn ở Bước 1):</p><ul class=\"ms-4\"><li><u>Nếu chọn \"Chuyển sang Markdown rồi Dịch\"</u>, hãy cấu hình <strong>Cấu hình phân tích</strong>:<ul><li><strong>Công cụ phân tích</strong>: Chọn công cụ để chuyển đổi tệp của bạn (như PDF) sang định dạng Markdown phù hợp để dịch. Không cần chọn nếu tệp đã ở định dạng Markdown.</li><li><strong>Mineru Token</strong>: Nếu chọn công cụ <code>minerU</code>, bạn cần nhập token vào đây.</li></ul></li><li><u>Nếu chọn \"Văn bản thuần/DOCX/XLSX/PPTX/SRT/ASS/EPUB/HTML\"</u>, hãy cấu hình <strong>Tùy chọn dịch</strong> tương ứng:<ul><li><strong>Chế độ chèn</strong>: Xác định cách đặt kết quả dịch vào tài liệu. Bạn có thể chọn \"Thay thế\" bản gốc, \"Thêm vào sau\" (Append), hoặc \"Thêm vào trước\" (Prepend).</li><li><strong>Dấu phân cách</strong>: Khi chọn chế độ \"Thêm vào sau\" hoặc \"Thêm vào trước\", ký tự này được dùng để ngăn cách giữa bản gốc và bản dịch (ví dụ: <code>\\\\N</code> thường dùng cho định dạng ASS, <code>&lt;br /&gt;</code> cho định dạng EPUB để xuống dòng).</li></ul></li><li><u>Nếu chọn \"Dịch JSON\"</u>, hãy cấu hình <strong>Đường dẫn JSON</strong>:<ul><li><strong>Đường dẫn JSON cần dịch</strong>: Nhập mỗi dòng một biểu thức <a href=\"https://goessner.net/articles/JsonPath/\" target=\"_blank\">JSONPath</a> để dịch tất cả các chuỗi trong các đối tượng khớp. Ví dụ: <code>$.*</code> (dịch tất cả chuỗi), <code>$..description</code> (dịch tất cả giá trị có khóa là <code>description</code>).</li></ul></li></ul><p class=\"mb-2 mt-3\"><strong>B. Tùy chọn chung</strong> (Áp dụng cho mọi quy trình):</p><ul class=\"ms-4\"><li><strong>Mô hình dịch</strong>:<ul><li><strong>Chọn nền tảng/Địa chỉ API/API Key/Model ID</strong>: Cấu hình dịch vụ dịch thuật AI bạn muốn sử dụng. Mô hình tuân thủ hướng dẫn càng tốt thì xác suất <strong>lỗi</strong> và <strong>bỏ sót</strong> càng thấp.</li><li><strong>Bỏ qua dịch thuật</strong>: Nếu chọn, hệ thống chỉ thực hiện phân tích tài liệu và chuyển đổi định dạng mà không gọi AI để dịch.</li></ul></li><li><strong>Cấu hình dịch thuật</strong>:<ul><li><strong>NgônLine truncated "tutorialModalBody": "<p><i class=\"bi bi-camera-video me-2\"></i>Video tutorials are available on Bilibili by searching for <a href=\"https://search.bilibili.com/all?keyword=docutranslate\" target=\"_blank\">docutranslate</a>.</p><p>Welcome to DocuTranslate! Please follow these steps to translate your documents:</p><ol><li><strong><i class=\"bi bi-diagram-3 me-2\"></i>Step 1: Select Workflow</strong><p class=\"mt-2\">At the top of the left-side configuration panel, first choose the processing flow that best suits your file type.</p><div class=\"alert alert-info mt-2\" role=\"alert\"><i class=\"bi bi-lightbulb-fill me-2\"></i>Tip: \"Auto-select Workflow\" is enabled by default. Simply upload your file, and the system will automatically match it with the appropriate workflow to simplify the process.</div><ul><li><b>Convert to Markdown then Translate</b>: Suitable for translating PDF, Markdown, images, etc. This is the most versatile and powerful mode.</li><li><b>Plain Text Translation</b>: For translating <code>.txt</code> plain text files.</li><li><b>EPUB Translation</b>: For translating <code>.epub</code> e-book files.</li><li><b>DOCX Translation</b>: For translating <code>.docx</code> Word documents.</li><li><b>XLSX Translation</b>: For translating <code>.xlsx</code> or <code>.csv</code> spreadsheet files.</li><li><b>PPTX Translation</b>: For translating <code>.pptx</code> slide files.</li><li><b>SRT Subtitle Translation</b>: For translating <code>.srt</code> subtitle files.</li><li><b>ASS Subtitle Translation</b>: For translating <code>.ass</code> advanced subtitle files.</li><li><b>JSON Translation</b>: For translating specific fields within <code>.json</code> files.</li><li><b>HTML Translation</b>: For translating <code>.html</code> web page files.</li></ul></li><li><strong><i class=\"bi bi-gear-fill me-2\"></i>Step 2: Configure Parameters</strong><p class=\"mt-2\">After selecting a workflow, the relevant configuration options will appear below. Please complete the settings in order (all configurations are automatically saved in your browser):</p><p class=\"mb-2\"><strong>A. Workflow-Specific Options</strong> (Appears based on your choice in Step 1):</p><ul class=\"ms-4\"><li><u>If \"Convert to Markdown then Translate\" is selected</u>, configure <strong>Parsing Configuration</strong>:<ul><li><strong>Parsing Engine</strong>: Choose an engine to convert your file (like a PDF) into a translation-friendly Markdown format. No selection is needed if your file is already in Markdown format.</li><li><strong>Mineru Token</strong>: If you choose the <code>minerU</code> engine, you need to enter your token here.</li></ul></li><li><u>If \"Plain Text/DOCX/XLSX/PPTX/SRT/ASS/EPUB/HTML\" is selected</u>, configure its <strong>Translation Options</strong>:<ul><li><strong>Insert Mode</strong>: Defines how the translation result is placed in the document. You can choose to \"Replace\" the original, \"Append\" it after the original, or \"Prepend\" it before the original.</li><li><strong>Separator</strong>: When \"Append\" or \"Prepend\" mode is selected, this is used to insert a separator between the original and translated text (e.g., <code>\\\\N</code> is common for ASS format, <code>&lt;br /&gt;</code> for EPUB format as a line break).</li></ul></li><li><u>If \"JSON Translation\" is selected</u>, configure <strong>JSON Paths</strong>:<ul><li><strong>JSON Paths to Translate</strong>: Enter one <a href=\"https://goessner.net/articles/JsonPath/\" target=\"_blank\">JSONPath</a> expression per line to translate all strings within the matched objects. For example: <code>$.*</code> (translate all strings), <code>$..description</code> (translate all values with the key <code>description</code>).</li></ul></li></ul><p class=\"mb-2 mt-3\"><strong>B. General Options</strong> (Applicable to all workflows):</p><ul class=\"ms-4\"><li><strong>Translation Model</strong>:<ul><li><strong>Select Platform/API Address/API Key/Model ID</strong>: Configure the AI translation service you wish to use. The better the model follows instructions, the lower the probability of <strong>errors</strong> and <strong>missed translations</strong>.</li><li><strong>Skip Translation</strong>: If checked, only document parsing and format conversion will be performed, without calling the AI for translation.</li></ul></li><li><strong>Translation Configuration</strong>:<ul><li><strong>Target Language</strong>: Specify the target language for the translation.</li><li><strong>Custom Prompt</strong>: Optional, add extra instructions, like \"Do not translate personal names.\"</li><li><strong>Thinking Mode</strong>: A setting for some models that support mixed inference; \"Disable (Recommended)\" is the suggested choice.</li><li><strong>Chunk Size/Concurrency, etc.</strong>: Advanced parameters for adjusting performance and API request behavior; usually, the defaults are fine.</li></ul></li><li><strong>Glossary</strong>:<ul><li><strong>Upload Glossary (OptionaLine truncated
"tutorialUnderstandBtn": "Tôi đã hiểu", "tutorialUnderstandBtn": "Saya Mengerti",
"contributorsModalTitle": "Cảm ơn vì đã đóng góp", "contributorsModalTitle": "Terima Kasih Kontribusi",
"contributorsPara1": "DocuTranslate là một dự án mã nguồn mở! Nhu cầu và việc sử dụng của cộng đồng là động lực thúc đẩy sự tiến bộ của dự án.", "contributorsPara1": "DocuTranslate adalah proyek open-source!",
"contributorsPara2": "Cảm ơn tất cả những người đã tài trợ, gửi mã, đóng góp ý kiến quý báu và gắn sao (star) cho dự án!", "contributorsPara2": "Terima kasih kepada semua sponsor dan kontributor!",
"contributorsWelcome": "Hoan nghênh bạn đóng góp qua các cách sau:", "contributorsWelcome": "Anda dapat berkontribusi melalui:",
"contributorsGithub": "Trang GitHub", "contributorsGithub": "Halaman GitHub",
"contributorsPR": "Gửi Pull Request", "contributorsPR": "Kirim Pull Request",
"contributorsIssue": "Báo cáo vấn đề (Issue)", "contributorsIssue": "Laporkan Issue",
"contributorsQQ": "Hoặc liên hệ tác giả qua nhóm QQ: 1047781902", "contributorsQQ": "Atau hubungi penulis melalui grup QQ: 1047781902",
"glossaryModalTitle": "Bảng thuật ngữ hiện tại", "glossaryModalTitle": "Glosarium Saat Ini",
"glossaryTableSource": "Nguồn (src)", "glossaryTableSource": "Sumber (src)",
"glossaryTableDestination": "Đích (dst)", "glossaryTableDestination": "Tujuan (dst)",
"engineOptionIdentity": "Đã là định dạng Markdown", "engineOptionIdentity": "Sudah Format Markdown",
"engineOptionMineru": "Mineru (Khuyên dùng)", "engineOptionMineru": "Mineru (Disarankan)",
"engineOptionDocling": "Docling", "engineOptionDocling": "Docling",
"engineOptionMineru_deploy": "Dịch vụ Mineru Deploy", "engineOptionMineru_deploy": "Layanan Mineru Deploy",
"apiHrefInfo302ai": "👈 Đăng ký qua liên kết này để nhận $1 tín dụng miễn phí", "apiHrefInfo302ai": "👈 Daftar melalui tautan ini untuk kredit gratis $1",
"glossaryEmpty": "Bảng thuật ngữ trống", "glossaryEmpty": "Glosarium kosong",
"status_fillRequired": "Vui lòng điền tất cả các trường bắt buộc!", "status_fillRequired": "Harap isi semua kolom yang diperlukan!",
"btn_initializing": "Đang khởi tạo...", "btn_initializing": "Menginisialisasi...",
"btn_cancelTranslation": "Hủy dịch", "btn_cancelTranslation": "Batalkan Terjemahan",
"status_cancelling": "Đang hủy...", "status_cancelling": "Membatalkan...",
"btn_reTranslate": "Dịch lại", "btn_reTranslate": "Terjemahkan Ulang",
"preview_cantPreviewType": "Không thể xem trước loại tệp này", "preview_cantPreviewType": "Tidak dapat melihat pratinjau tipe file ini",
"preview_noOriginalCache": "Không có tệp gốc được lưu trong bộ nhớ đệm để xem trước.", "preview_noOriginalCache": "Tidak ada file asli yang di-cache.",
"pdf_preparing": "Đang chuẩn bị PDF...", "pdf_preparing": "Menyiapkan PDF...",
"preview_bilingual": "Xem trước song ngữ", "preview_bilingual": "Pratinjau Dwi Bahasa",
"preview_translatedOnly": "Xem trước chỉ bản dịch", "preview_translatedOnly": "Pratinjau Hanya Terjemahan",
"configImportSuccess": "Nhập cấu hình thành công!", "configImportSuccess": "Konfigurasi berhasil diimpor!",
"configImportError": "Không thể phân tích tệp cấu hình, vui lòng kiểm tra định dạng tệp.", "configImportError": "Gagal memproses file konfigurasi.",
"providerLabel": "Nhà cung cấp" "providerLabel": "Provider"
} }
} }
+37 -46
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="auto"> <html lang="en" data-bs-theme="auto">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -159,15 +159,6 @@
white-space: pre; white-space: pre;
} }
.bottom-left-controls {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 1050;
display: flex;
gap: 0.5rem;
}
.step-number { .step-number {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
@@ -226,6 +217,31 @@
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<h4 class="mb-0 me-3 fw-bold" :title="t('pageTitle')">DocuTranslate</h4> <h4 class="mb-0 me-3 fw-bold" :title="t('pageTitle')">DocuTranslate</h4>
</div> </div>
<!-- Language & Theme Controls -->
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-translate me-1"></i><span>{{ {zh:'中文',en:'English',id:'Bahasa'}[currentLang] || 'Language' }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" :class="{active: currentLang==='zh'}" href="#"
@click.prevent="setLang('zh')">中文</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='en'}" href="#"
@click.prevent="setLang('en')">English</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='id'}" href="#"
@click.prevent="setLang('id')">Bahasa Indonesia</a></li>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown"><i
class="bi bi-circle-half"></i></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" @click="setTheme('light')"><i class="bi bi-sun-fill me-2"></i>Light</button></li>
<li><button class="dropdown-item" @click="setTheme('dark')"><i class="bi bi-moon-stars-fill me-2"></i>Dark</button></li>
<li><button class="dropdown-item" @click="setTheme('auto')"><i class="bi bi-circle-half me-2"></i>Auto</button></li>
</ul>
</div>
</div>
</div> </div>
<form id="translateForm" @submit.prevent> <form id="translateForm" @submit.prevent>
@@ -923,40 +939,7 @@
</div> </div>
<iframe id="printFrame" ref="printFrame" style="display: none;"></iframe> <iframe id="printFrame" ref="printFrame" style="display: none;"></iframe>
<!-- Controls --> <!-- Header controls now in left panel top-right -->
<div class="bottom-left-controls">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i
class="bi bi-translate"></i></button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" :class="{active: currentLang==='zh'}" href="#"
@click.prevent="setLang('zh')">中文</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='en'}" href="#"
@click.prevent="setLang('en')">English</a></li>
<li><a class="dropdown-item" :class="{active: currentLang==='vi'}" href="#"
@click.prevent="setLang('vi')">Tiếng Việt</a></li>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"><i
class="bi bi-circle-half"></i></button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" @click="setTheme('light')"><i class="bi bi-sun-fill me-2"></i> Light
</button>
</li>
<li>
<button class="dropdown-item" @click="setTheme('dark')"><i class="bi bi-moon-stars-fill me-2"></i>
Dark
</button>
</li>
<li>
<button class="dropdown-item" @click="setTheme('auto')"><i class="bi bi-circle-half me-2"></i> Auto
</button>
</li>
</ul>
</div>
</div>
</div> </div>
<script src="/static/bootstrap.bundle.min.js"></script> <script src="/static/bootstrap.bundle.min.js"></script>
@@ -1048,7 +1031,14 @@
components: {SliderControl, ModelPresetSelector}, components: {SliderControl, ModelPresetSelector},
setup() { setup() {
const version = ref(""); const version = ref("");
const currentLang = ref(localStorage.getItem('ui_language') || 'zh'); function detectBrowserLang() {
const nav = navigator.language || navigator.userLanguage || '';
const lang = nav.split('-')[0].toLowerCase();
if (['zh', 'en', 'id'].includes(lang)) return lang;
if (lang === 'zh') return 'zh';
return 'en'; // default to English for unrecognized languages
}
const currentLang = ref(localStorage.getItem('ui_language') || detectBrowserLang());
const i18nData = ref({}); const i18nData = ref({});
const glossaryData = ref({}); const glossaryData = ref({});
const tasks = ref([]); const tasks = ref([]);
@@ -1868,7 +1858,8 @@
const setLang = (l) => { const setLang = (l) => {
currentLang.value = l; currentLang.value = l;
localStorage.setItem('ui_language', l); localStorage.setItem('ui_language', l);
document.documentElement.lang = l === 'zh' ? 'zh-CN' : 'en'; const langMap = {zh: 'zh-CN', en: 'en', id: 'id'};
document.documentElement.lang = langMap[l] || 'en';
}; };
const setTheme = (t) => { const setTheme = (t) => {
localStorage.setItem('theme', t); localStorage.setItem('theme', t);
@@ -326,33 +326,58 @@ class DocxTranslator(AiTranslator):
runs = element_info["runs"] runs = element_info["runs"]
if not runs: return if not runs: return
first_real_run_index = -1 # Filter to runs that are still attached to the document
# 找到第一个可以写入文本的run valid_runs = []
for i, run in enumerate(runs): for run in runs:
if run.element.getparent() is not None: if run.element.getparent() is not None:
# 如果 run 是副本的一部分,其 _parent 可能仍然指向原始文档的段落
# 但我们需要确保它与 element_info["paragraph"] 同步
run._parent = element_info["paragraph"] run._parent = element_info["paragraph"]
run.text = final_text valid_runs.append(run)
first_real_run_index = i
break
# 如果没有找到有效的run(例如,它们都已被删除),则记录警告 if not valid_runs:
if first_real_run_index == -1:
self.logger.warning(f"无法应用翻译 '{final_text}',因为找不到有效的run。") self.logger.warning(f"无法应用翻译 '{final_text}',因为找不到有效的run。")
return return
# 删除所有后续的run,因为它们的文本已经被合并到第一个run中了 if len(valid_runs) == 1:
for i in range(first_real_run_index + 1, len(runs)): # Single run: just write the translation
run = runs[i] valid_runs[0].text = final_text
parent_element = run.element.getparent() return
if parent_element is not None:
try: # Multiple runs: proportionally distribute translated text to preserve formatting
parent_element.remove(run.element) orig_lengths = [len(r.text) for r in valid_runs]
except ValueError: total_orig = sum(orig_lengths)
# 在某些复杂情况下,一个run可能已经被其父元素隐式删除 final_len = len(final_text)
self.logger.debug(f"尝试删除一个不存在的run元素。这通常是安全的。")
pass if total_orig == 0:
valid_runs[0].text = final_text
for run in valid_runs[1:]:
self._remove_run_element(run)
return
# Distribute characters proportionally
char_pos = 0
for i, run in enumerate(valid_runs):
if i == len(valid_runs) - 1:
# Last run gets all remaining text
run.text = final_text[char_pos:]
else:
ratio = orig_lengths[i] / total_orig
run_char_count = max(1, round(final_len * ratio))
run_char_count = min(run_char_count, final_len - char_pos - (len(valid_runs) - i - 1))
if run_char_count <= 0:
# Remove runs that would get zero characters
self._remove_run_element(run)
continue
run.text = final_text[char_pos:char_pos + run_char_count]
char_pos += run_char_count
def _remove_run_element(self, run) -> None:
"""Safely remove a run element from its parent."""
parent_element = run.element.getparent()
if parent_element is not None:
try:
parent_element.remove(run.element)
except ValueError:
self.logger.debug(f"尝试删除一个不存在的run元素。这通常是安全的。")
# ---------- FIX START: 新增用于清理副本段落的辅助方法 ---------- # ---------- FIX START: 新增用于清理副本段落的辅助方法 ----------
def _prune_unwanted_elements_from_copy(self, p_element: OxmlElement): def _prune_unwanted_elements_from_copy(self, p_element: OxmlElement):