优化docx翻译v4.1

This commit is contained in:
xunbu
2025-10-19 00:06:03 +08:00
parent ce1ffddfa7
commit b10c3816c8

View File

@@ -21,6 +21,22 @@ from docutranslate.translator.ai_translator.base import AiTranslatorConfig, AiTr
# ---------------- 辅助函数 ---------------- # ---------------- 辅助函数 ----------------
# [v6.2] 定义一组具有显著视觉效果的格式标签。
# 我们只在 Run 包含这些格式时才将其视为空白格式边界。
# 这避免了因字体、字号等微小变化导致的过度文本切分。
SIGNIFICANT_STYLES = frozenset([
qn('w:u'), # 下划线
qn('w:strike'), # 删除线
qn('w:dstrike'), # 双删除线
qn('w:shd'), # 底纹/背景色
qn('w:highlight'), # 荧光笔高亮
qn('w:bdr'), # 边框
qn('w:effectLst'), # 文本效果 (如发光、阴影)
qn('w:em'), # 强调标记 (着重号)
])
def is_image_run(run: Run) -> bool: def is_image_run(run: Run) -> bool:
"""检查一个 Run 是否包含图片。""" """检查一个 Run 是否包含图片。"""
xml = getattr(run.element, 'xml', '') xml = getattr(run.element, 'xml', '')
@@ -37,17 +53,18 @@ def is_formatting_only_run(run: Run) -> bool:
def is_styled_whitespace_run(run: Run) -> bool: def is_styled_whitespace_run(run: Run) -> bool:
""" """
检查一个 Run 是否只包含空白字符,但应用了应保留的直接格式(如下划线) [v6.2] 检查一个 Run 是否只包含空白字符,但应用了应保留的“显著”格式
这些 Run 应被视为翻译段的边界并保持不变。 这些 Run 应被视为翻译段的边界并保持不变。
""" """
# 如果 Run 不只包含空白,或者完全为空,则不符合条件
if not (run.text and run.text.isspace()): if not (run.text and run.text.isspace()):
return False return False
rPr = run.element.rPr rPr = run.element.rPr
# 如果 rPr 元素存在且有任何子元素(例如 <w:u> 表示下划线), if rPr is None:
# 这意味着样式被直接应用到了这个空白 Run 上。 return False
return rPr is not None and len(list(rPr)) > 0
# 仅当 Run 的属性中包含我们定义的“显著”样式之一时,才返回 True。
return any(child.tag in SIGNIFICANT_STYLES for child in rPr)
# ---------------- 配置类 ---------------- # ---------------- 配置类 ----------------
@@ -63,19 +80,19 @@ class DocxTranslator(AiTranslator):
一个基于高级结构化解析的 .docx 文件翻译器。 一个基于高级结构化解析的 .docx 文件翻译器。
它能高精度保留样式,并正确处理正文、表格、页眉/脚、脚注/尾注、超链接和目录(TOC)等复杂元素。 它能高精度保留样式,并正确处理正文、表格、页眉/脚、脚注/尾注、超链接和目录(TOC)等复杂元素。
[v6.2 - 精确格式保留]
- 根据用户反馈,精确定义了构成“重要格式”的样式范围。
- 现在仅在空白 Run 包含下划线、背景色、边框等显著视觉样式时才将其视为边界。
- 忽略了加粗、倾斜、字体/字号变化等对空格本身无视觉效果或不重要的样式,
避免了因此对翻译句子造成不必要的切分,提升了翻译质量。
[v6.1 - 格式保留修复版] [v6.1 - 格式保留修复版]
- 修复了因合并 Run 导致下划线等格式在翻译后丢失的问题。 - 修复了因合并 Run 导致下划线等格式在翻译后丢失的问题。
- 通过引入 is_styled_whitespace_run 检查,将仅包含空格但带有样式的 Run如下划线空格 - 通过引入 is_styled_whitespace_run 检查,将仅包含空格但带有样式的 Run如下划线空格
视为与图片类似的不可翻译边界。 视为与图片类似的不可翻译边界。
- 这可以防止这些关键的格式化 Run 被合并到文本段中或在应用翻译时被错误地删除,
从而确保了下划线、加粗空格等格式的完整保留。
[v6.0 - 语义切分重构版] [v6.0 - 语义切分重构版]
- 核心思想重构:不再试图通过复杂的状态机去识别和“跳过”特定类型的域(如页码、序号) - 重构核心逻辑,不再跳过域结果,而是将其作为语义边界来切分文本,增强了鲁棒性
- 默认提取所有文本所有域的结果PAGEREF, SEQ 等)现在都会被提取出来进行处理。
- 简化切分逻辑仅使用域的开始begin和结束end标记作为强制性的语义边界在此处切分文本段。
这确保了域结果(如交叉引用"[1]")与其前后的文本在语义上分离,同时又避免了因跳过逻辑导致的文本丢失风险。
- 提升鲁棒性:新逻辑对未知或复杂的域结构更具适应性,确保了文本提取的完整性。
""" """
IGNORED_TAGS = { IGNORED_TAGS = {
qn('w:proofErr'), qn('w:lastRenderedPageBreak'), qn('w:bookmarkStart'), qn('w:proofErr'), qn('w:lastRenderedPageBreak'), qn('w:bookmarkStart'),
@@ -124,7 +141,6 @@ class DocxTranslator(AiTranslator):
self._process_element_children(child, elements, texts, state) self._process_element_children(child, elements, texts, state)
continue continue
# --- [v6.0 Refactored] 简化的域处理逻辑 ---
field_char_element = child.find(qn('w:fldChar')) if isinstance(child, CT_R) else None field_char_element = child.find(qn('w:fldChar')) if isinstance(child, CT_R) else None
if field_char_element is not None: if field_char_element is not None:
fld_type = field_char_element.get(qn('w:fldCharType')) fld_type = field_char_element.get(qn('w:fldCharType'))
@@ -134,29 +150,23 @@ class DocxTranslator(AiTranslator):
if isinstance(child, CT_R): if isinstance(child, CT_R):
run = Run(child, None) run = Run(child, None)
# [v6.1 FIX] 将带样式的空白 Run如下划线视为与图片一样的边界 # [v6.2] 使用更精确的检查来识别作为边界的 Run。
# 以防止其格式在翻译过程中丢失。
if is_image_run(run) or is_formatting_only_run(run) or is_styled_whitespace_run(run): if is_image_run(run) or is_formatting_only_run(run) or is_styled_whitespace_run(run):
# 这些 Run 是边界,不应包含在可翻译段落中。
# 我们刷新任何之前的文本,然后这个 Run 本身被跳过,
# 从而在文档中保持原样。
flush_segment() flush_segment()
else: else:
# 这是一个包含实际可翻译文本的 Run将其添加到当前段落。
state['current_runs'].append(run) state['current_runs'].append(run)
else: else:
# 如果子元素不是 Run它也充当一个边界。
flush_segment() flush_segment()
def _process_paragraph(self, para: Paragraph, elements: List[Dict[str, Any]], texts: List[str]): def _process_paragraph(self, para: Paragraph, elements: List[Dict[str, Any]], texts: List[str]):
if not para.text.strip(): # [v6.2] 此处无需检查 para.text.strip(),因为一个段落可能只包含一个带样式的空白 Run
return # 这种 Run 我们需要保留,而 .text.strip() 会将其视为空。
# 具体的文本提取逻辑在 _process_element_children 中处理。
state = { state = {
'current_runs': [], 'current_runs': [],
} }
self._process_element_children(para._p, elements, texts, state) self._process_element_children(para._p, elements, texts, state)
# 刷新段落末尾任何剩余的 runs
current_runs = state['current_runs'] current_runs = state['current_runs']
if current_runs: if current_runs:
full_text = "".join(r.text for r in current_runs) full_text = "".join(r.text for r in current_runs)
@@ -189,36 +199,26 @@ class DocxTranslator(AiTranslator):
return return
parent_element = None parent_element = None
# 首先获取包含内容的顶层 XML 元素
if isinstance(container, (DocumentObject, Part)): if isinstance(container, (DocumentObject, Part)):
# 对于 Document 和 Part 对象 (如 FootnotesPart),使用 .element
parent_element = container.element.body if hasattr(container.element, 'body') else container.element parent_element = container.element.body if hasattr(container.element, 'body') else container.element
elif isinstance(container, (_Cell, _Header, _Footer)): elif isinstance(container, (_Cell, _Header, _Footer)):
# 对于内部块容器,使用 ._element
parent_element = container._element parent_element = container._element
else: else:
# 如果遇到未知类型,记录警告并返回
self.logger.warning(f"跳过未知类型的容器: {type(container)}") self.logger.warning(f"跳过未知类型的容器: {type(container)}")
return return
# 检查是否是需要特殊处理的嵌套结构 (如脚注/尾注)
if parent_element is not None and parent_element.tag in [qn('w:footnotes'), qn('w:endnotes')]: if parent_element is not None and parent_element.tag in [qn('w:footnotes'), qn('w:endnotes')]:
# 遍历每个 <w:footnote> 或 <w:endnote> 元素
for note_element in parent_element: for note_element in parent_element:
# 在每个注释元素内部处理其包含的段落和表格
self._process_body_elements(note_element, container, elements, texts) self._process_body_elements(note_element, container, elements, texts)
elif parent_element is not None: elif parent_element is not None:
# 对于其他所有容器 (body, tc, hdr, ftr),直接处理其子元素
self._process_body_elements(parent_element, container, elements, texts) self._process_body_elements(parent_element, container, elements, texts)
def _pre_translate(self, document: Document) -> Tuple[DocumentObject, List[Dict[str, Any]], List[str]]: def _pre_translate(self, document: Document) -> Tuple[DocumentObject, List[Dict[str, Any]], List[str]]:
doc = docx.Document(BytesIO(document.content)) doc = docx.Document(BytesIO(document.content))
elements, texts = [], [] elements, texts = [], []
# 1. 处理主文档内容
self._traverse_container(doc, elements, texts) self._traverse_container(doc, elements, texts)
# 2. 处理所有节的页眉和页脚
for section in doc.sections: for section in doc.sections:
self._traverse_container(section.header, elements, texts) self._traverse_container(section.header, elements, texts)
self._traverse_container(section.first_page_header, elements, texts) self._traverse_container(section.first_page_header, elements, texts)
@@ -227,7 +227,6 @@ class DocxTranslator(AiTranslator):
self._traverse_container(section.first_page_footer, elements, texts) self._traverse_container(section.first_page_footer, elements, texts)
self._traverse_container(section.even_page_footer, elements, texts) self._traverse_container(section.even_page_footer, elements, texts)
# 3. 处理脚注和尾注
if hasattr(doc.part, 'footnotes_part') and doc.part.footnotes_part is not None: if hasattr(doc.part, 'footnotes_part') and doc.part.footnotes_part is not None:
self._traverse_container(doc.part.footnotes_part, elements, texts) self._traverse_container(doc.part.footnotes_part, elements, texts)
if hasattr(doc.part, 'endnotes_part') and doc.part.endnotes_part is not None: if hasattr(doc.part, 'endnotes_part') and doc.part.endnotes_part is not None:
@@ -242,9 +241,7 @@ class DocxTranslator(AiTranslator):
first_real_run_index = -1 first_real_run_index = -1
for i, run in enumerate(runs): for i, run in enumerate(runs):
# 确保run仍然在文档树中
if run.element.getparent() is not None: if run.element.getparent() is not None:
# 找到第一个可以写入文本的run
run.text = final_text run.text = final_text
first_real_run_index = i first_real_run_index = i
break break
@@ -253,7 +250,6 @@ class DocxTranslator(AiTranslator):
self.logger.warning(f"无法应用翻译 '{final_text}'因为找不到有效的run。") self.logger.warning(f"无法应用翻译 '{final_text}'因为找不到有效的run。")
return return
# 从第一个有效的run之后开始删除所有多余的run
for i in range(first_real_run_index + 1, len(runs)): for i in range(first_real_run_index + 1, len(runs)):
run = runs[i] run = runs[i]
parent_element = run.element.getparent() parent_element = run.element.getparent()
@@ -261,7 +257,6 @@ class DocxTranslator(AiTranslator):
try: try:
parent_element.remove(run.element) parent_element.remove(run.element)
except ValueError: except ValueError:
# 如果元素已经被其他操作移除这里会抛出ValueError可以安全地忽略
self.logger.debug(f"尝试删除一个不存在的run元素。这通常是安全的。") self.logger.debug(f"尝试删除一个不存在的run元素。这通常是安全的。")
pass pass