优化docx翻译v4.1
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user