From b10c3816c853c2701cca42c4e5f651be46703f57 Mon Sep 17 00:00:00 2001 From: xunbu Date: Sun, 19 Oct 2025 00:06:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96docx=E7=BF=BB=E8=AF=91v4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai_translator/docx_translator.py | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/docutranslate/translator/ai_translator/docx_translator.py b/docutranslate/translator/ai_translator/docx_translator.py index 84b2caf..bd1e952 100644 --- a/docutranslate/translator/ai_translator/docx_translator.py +++ b/docutranslate/translator/ai_translator/docx_translator.py @@ -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: """检查一个 Run 是否包含图片。""" 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: """ - 检查一个 Run 是否只包含空白字符,但应用了应保留的直接格式(如下划线)。 + [v6.2] 检查一个 Run 是否只包含空白字符,但应用了应保留的“显著”格式。 这些 Run 应被视为翻译段的边界并保持不变。 """ - # 如果 Run 不只包含空白,或者完全为空,则不符合条件 if not (run.text and run.text.isspace()): return False rPr = run.element.rPr - # 如果 rPr 元素存在且有任何子元素(例如 表示下划线), - # 这意味着样式被直接应用到了这个空白 Run 上。 - return rPr is not None and len(list(rPr)) > 0 + if rPr is None: + return False + + # 仅当 Run 的属性中包含我们定义的“显著”样式之一时,才返回 True。 + return any(child.tag in SIGNIFICANT_STYLES for child in rPr) # ---------------- 配置类 ---------------- @@ -63,19 +80,19 @@ class DocxTranslator(AiTranslator): 一个基于高级结构化解析的 .docx 文件翻译器。 它能高精度保留样式,并正确处理正文、表格、页眉/脚、脚注/尾注、超链接和目录(TOC)等复杂元素。 + [v6.2 - 精确格式保留] + - 根据用户反馈,精确定义了构成“重要格式”的样式范围。 + - 现在仅在空白 Run 包含下划线、背景色、边框等显著视觉样式时才将其视为边界。 + - 忽略了加粗、倾斜、字体/字号变化等对空格本身无视觉效果或不重要的样式, + 避免了因此对翻译句子造成不必要的切分,提升了翻译质量。 + [v6.1 - 格式保留修复版] - 修复了因合并 Run 导致下划线等格式在翻译后丢失的问题。 - 通过引入 is_styled_whitespace_run 检查,将仅包含空格但带有样式的 Run(如下划线空格) 视为与图片类似的不可翻译边界。 - - 这可以防止这些关键的格式化 Run 被合并到文本段中或在应用翻译时被错误地删除, - 从而确保了下划线、加粗空格等格式的完整保留。 [v6.0 - 语义切分重构版] - - 核心思想重构:不再试图通过复杂的状态机去识别和“跳过”特定类型的域(如页码、序号)。 - - 默认提取所有文本:所有域的结果(PAGEREF, SEQ 等)现在都会被提取出来进行处理。 - - 简化切分逻辑:仅使用域的开始(begin)和结束(end)标记作为强制性的语义边界,在此处切分文本段。 - 这确保了域结果(如交叉引用"[1]")与其前后的文本在语义上分离,同时又避免了因跳过逻辑导致的文本丢失风险。 - - 提升鲁棒性:新逻辑对未知或复杂的域结构更具适应性,确保了文本提取的完整性。 + - 重构核心逻辑,不再跳过域结果,而是将其作为语义边界来切分文本,增强了鲁棒性。 """ IGNORED_TAGS = { qn('w:proofErr'), qn('w:lastRenderedPageBreak'), qn('w:bookmarkStart'), @@ -124,7 +141,6 @@ class DocxTranslator(AiTranslator): self._process_element_children(child, elements, texts, state) continue - # --- [v6.0 Refactored] 简化的域处理逻辑 --- field_char_element = child.find(qn('w:fldChar')) if isinstance(child, CT_R) else None if field_char_element is not None: fld_type = field_char_element.get(qn('w:fldCharType')) @@ -134,29 +150,23 @@ class DocxTranslator(AiTranslator): if isinstance(child, CT_R): 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): - # 这些 Run 是边界,不应包含在可翻译段落中。 - # 我们刷新任何之前的文本,然后这个 Run 本身被跳过, - # 从而在文档中保持原样。 flush_segment() else: - # 这是一个包含实际可翻译文本的 Run,将其添加到当前段落。 state['current_runs'].append(run) else: - # 如果子元素不是 Run,它也充当一个边界。 flush_segment() def _process_paragraph(self, para: Paragraph, elements: List[Dict[str, Any]], texts: List[str]): - if not para.text.strip(): - return + # [v6.2] 此处无需检查 para.text.strip(),因为一个段落可能只包含一个带样式的空白 Run, + # 这种 Run 我们需要保留,而 .text.strip() 会将其视为空。 + # 具体的文本提取逻辑在 _process_element_children 中处理。 state = { 'current_runs': [], } self._process_element_children(para._p, elements, texts, state) - # 刷新段落末尾任何剩余的 runs current_runs = state['current_runs'] if current_runs: full_text = "".join(r.text for r in current_runs) @@ -189,36 +199,26 @@ class DocxTranslator(AiTranslator): return parent_element = None - # 首先获取包含内容的顶层 XML 元素 if isinstance(container, (DocumentObject, Part)): - # 对于 Document 和 Part 对象 (如 FootnotesPart),使用 .element parent_element = container.element.body if hasattr(container.element, 'body') else container.element elif isinstance(container, (_Cell, _Header, _Footer)): - # 对于内部块容器,使用 ._element parent_element = container._element else: - # 如果遇到未知类型,记录警告并返回 self.logger.warning(f"跳过未知类型的容器: {type(container)}") return - # 检查是否是需要特殊处理的嵌套结构 (如脚注/尾注) if parent_element is not None and parent_element.tag in [qn('w:footnotes'), qn('w:endnotes')]: - # 遍历每个 元素 for note_element in parent_element: - # 在每个注释元素内部处理其包含的段落和表格 self._process_body_elements(note_element, container, elements, texts) elif parent_element is not None: - # 对于其他所有容器 (body, tc, hdr, ftr),直接处理其子元素 self._process_body_elements(parent_element, container, elements, texts) def _pre_translate(self, document: Document) -> Tuple[DocumentObject, List[Dict[str, Any]], List[str]]: doc = docx.Document(BytesIO(document.content)) elements, texts = [], [] - # 1. 处理主文档内容 self._traverse_container(doc, elements, texts) - # 2. 处理所有节的页眉和页脚 for section in doc.sections: self._traverse_container(section.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.even_page_footer, elements, texts) - # 3. 处理脚注和尾注 if hasattr(doc.part, 'footnotes_part') and doc.part.footnotes_part is not None: self._traverse_container(doc.part.footnotes_part, elements, texts) 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 for i, run in enumerate(runs): - # 确保run仍然在文档树中 if run.element.getparent() is not None: - # 找到第一个可以写入文本的run run.text = final_text first_real_run_index = i break @@ -253,7 +250,6 @@ class DocxTranslator(AiTranslator): self.logger.warning(f"无法应用翻译 '{final_text}',因为找不到有效的run。") return - # 从第一个有效的run之后开始,删除所有多余的run for i in range(first_real_run_index + 1, len(runs)): run = runs[i] parent_element = run.element.getparent() @@ -261,7 +257,6 @@ class DocxTranslator(AiTranslator): try: parent_element.remove(run.element) except ValueError: - # 如果元素已经被其他操作移除,这里会抛出ValueError,可以安全地忽略 self.logger.debug(f"尝试删除一个不存在的run元素。这通常是安全的。") pass