优化html译文附加效果

This commit is contained in:
xunbu
2025-10-25 13:23:52 +08:00
parent 1f3f13fe1b
commit 6c570d58c1

View File

@@ -4,7 +4,7 @@ import asyncio
from dataclasses import dataclass
from typing import Self, Literal, Set, Dict, List, Tuple
from bs4 import BeautifulSoup, NavigableString, Comment
from bs4 import BeautifulSoup, NavigableString, Comment, Tag
from docutranslate.agents.segments_agent import SegmentsTranslateAgentConfig, SegmentsTranslateAgent
from docutranslate.ir.document import Document
@@ -13,38 +13,18 @@ from docutranslate.translator.ai_translator.base import AiTranslatorConfig, AiTr
# --- 规则定义 ---
# 1. 不可翻译标签(黑名单)
# 这些标签及其内容在任何情况下都不应被翻译,因为它们通常包含代码、样式或元数据。
# 在预处理阶段,这些标签及其所有子元素将被直接从文档中移除,以确保它们不会被意外修改。
NON_TRANSLATABLE_TAGS: Set[str] = {
'script', # JavaScript代码
'style', # CSS样式
'pre', # 预格式化文本,通常用于代码块
'code', # 行内代码
'kbd', # 键盘输入
'samp', # 示例输出
'var', # 变量
'noscript', # script未启用时的内容
'meta', # 元数据
'link', # 外部资源链接
'head', # 文档头部,通常不包含可见的可翻译内容
'script', 'style', 'pre', 'code', 'kbd', 'samp', 'var', 'noscript', 'meta', 'link', 'head',
}
# 2. 可翻译标签(白名单)
# 定义一组被认为是“安全”的HTML标签这些标签中的直接文本内容适合被翻译
# 这种白名单策略与上面的黑名单结合,提供了双重保障。
SAFE_TAGS: Set[str] = {
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'li', 'blockquote', 'q', 'caption',
'span', 'a', 'strong', 'em', 'b', 'i', 'u',
'td', 'th',
'button', 'label', 'legend', 'option',
'figcaption', 'summary', 'details',
'div', # div 比较通用,但我们的逻辑只提取其顶层文本节点,相对安全
# 2. 可作为独立翻译单元的块级标签(白名单)
# 这些标签将被视为一个整体进行翻译并且在append/prepend模式下会触发结构化操作
TRANSLATABLE_BLOCK_TAGS: Set[str] = {
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'q', 'caption',
'td', 'th', 'button', 'legend', 'figcaption', 'summary', 'details', 'div',
}
# 3. 可翻译属性(白名单)
# 定义一组“安全”的属性,这些属性的值通常是给用户看的可读文本。
# 格式为: { 'tag_name': ['attr1', 'attr2'], ... }
SAFE_ATTRIBUTES: Dict[str, List[str]] = {
'img': ['alt', 'title'],
'a': ['title'],
@@ -52,36 +32,22 @@ SAFE_ATTRIBUTES: Dict[str, List[str]] = {
'textarea': ['placeholder', 'title'],
'abbr': ['title'],
'area': ['alt'],
# 对于所有标签title属性通常是可翻译的
'*': ['title']
}
@dataclass
class HtmlTranslatorConfig(AiTranslatorConfig):
"""
HTML翻译器的配置类。
Attributes:
insert_mode (Literal["replace", "append", "prepend"]):
指定如何插入翻译文本。
- "replace": 用译文替换原文。
- "append": 在原文后追加译文。
- "prepend": 在原文前追加译文。
separator (str): 在 "append""prepend" 模式下,用于分隔原文和译文的字符串。
"""
insert_mode: Literal["replace", "append", "prepend"] = "replace"
separator: str = " " # HTML中用空格作为默认分隔符可能更合适
separator: str = "\n"
class HtmlTranslator(AiTranslator):
"""
一个用于翻译 HTML 文件内容的翻译器。
它采用黑白名单结合的策略,以最大程度地保留页面样式和功能:
1. 黑名单:首先,完全移除 script, style, code 等明确不可翻译的标签及其内容
2. 白名单然后在剩余的HTML中只提取和翻译指定安全标签和属性中的文本内容
3. 注释保护显式地跳过HTML注释确保它们不被翻译。
这种方法能有效避免破坏页面结构、脚本、样式和注释。
【结构化修改版】: 借鉴 Docx/EpubTranslator 的实现,将块级元素作为整体翻译单元。
在 append/prepend 模式下,对常规块级元素创建新标签存放译文,对表格单元格则在内部追加内容
以保证文档结构的清晰和样式的绝对一致性,同时保留了强大的黑白名单安全规则
"""
def __init__(self, config: HtmlTranslatorConfig):
@@ -109,121 +75,128 @@ class HtmlTranslator(AiTranslator):
self.separator = config.separator
def _pre_translate(self, document: Document) -> Tuple[BeautifulSoup, List[Dict], List[str]]:
"""
解析HTML文档根据规则提取所有需要翻译的文本节点和属性。
步骤:
1. 使用黑名单移除所有不可翻译的标签,从根本上防止它们被处理。
2. 遍历剩余的HTML元素根据白名单提取可翻译的文本和属性值同时跳过注释。
"""
soup = BeautifulSoup(document.content, 'lxml')
# 步骤 1: 移除所有不可翻译的标签及其内容
for tag in soup.find_all(NON_TRANSLATABLE_TAGS):
tag.decompose()
translatable_items = []
original_texts = []
# 步骤 2: 遍历所有剩余标签,提取可翻译内容
for tag in soup.find_all(True):
# --- 2a. 翻译安全标签内的文本节点 ---
if tag.name in SAFE_TAGS:
# 只处理标签的直接子节点中的文本,这是保留样式的关键。
for child in list(tag.children):
# 【关键修改】确保处理的是纯文本节点而不是注释Comment是NavigableString的子类
if isinstance(child, NavigableString) and not isinstance(child, Comment) and child.strip():
text = str(child)
translatable_items.append({'type': 'node', 'object': child})
original_texts.append(text)
# --- 1. 提取块级标签进行翻译 ---
all_potential_blocks = soup.find_all(TRANSLATABLE_BLOCK_TAGS)
all_potential_blocks_set = set(all_potential_blocks)
# --- 2b. 翻译安全标签内的安全属性 ---
tags_to_process = []
for tag in all_potential_blocks:
# 采用“Bottom-Up”逻辑只选择不包含其他可翻译块级标签的“叶子”标签。
contains_other_block = tag.find(
lambda child_tag: child_tag in all_potential_blocks_set and child_tag is not tag
)
if not contains_other_block:
tags_to_process.append(tag)
for tag in tags_to_process:
if tag.get_text(strip=True):
translatable_items.append({'type': 'block_tag', 'tag': tag})
original_texts.append(tag.decode_contents())
# --- 2. 提取安全属性进行翻译 ---
for tag in soup.find_all(True):
attributes_to_check = SAFE_ATTRIBUTES.get(tag.name, []) + SAFE_ATTRIBUTES.get('*', [])
for attr in set(attributes_to_check): # 使用set去重
for attr in set(attributes_to_check):
if tag.has_attr(attr) and tag[attr].strip():
value = tag[attr]
translatable_items.append({'type': 'attribute', 'tag': tag, 'attribute': attr})
original_texts.append(value)
original_texts.append(tag[attr])
return soup, translatable_items, original_texts
def _after_translate(self, soup: BeautifulSoup, translatable_items: list,
translated_texts: list[str], original_texts: list[str]) -> bytes:
"""
将翻译后的文本写回到BeautifulSoup对象中对应的节点或属性并返回最终的HTML字节流。
【版本 3.0: 修正了对HTML分隔符的支持】
"""
if len(translatable_items) != len(translated_texts):
self.logger.error("翻译前后的文本片段数量不匹配 (%d vs %d),跳过写入操作以防损坏文件。",
len(translatable_items), len(translated_texts))
return soup.encode('utf-8')
for i, item in enumerate(translatable_items):
translated_text = translated_texts[i]
original_text = original_texts[i]
translated_text = translated_texts[i]
tag = item['tag']
if item['type'] == 'node':
node = item['object']
if not node.parent: # 确保节点仍然在树中
continue
# --- 构造包含HTML的新内容字符串 ---
new_content_str = ""
if self.insert_mode == "replace":
leading_space = original_text[:len(original_text) - len(original_text.lstrip())]
trailing_space = original_text[len(original_text.rstrip()):]
new_content_str = leading_space + translated_text + trailing_space
elif self.insert_mode == "append":
new_content_str = original_text + self.separator + translated_text
elif self.insert_mode == "prepend":
new_content_str = translated_text + self.separator + original_text
else:
self.logger.error(f"不正确的HtmlTranslatorConfig参数: insert_mode='{self.insert_mode}'")
new_content_str = original_text
# --- 核心修改正确地将HTML字符串片段插入DOM ---
# 1. 使用一个临时的父标签(如此处的'div'来解析HTML片段
# 这是在BeautifulSoup中处理片段的标准做法避免了自动添加<html><body>。
temp_soup = BeautifulSoup(f"<div>{new_content_str}</div>", 'html.parser')
new_elements = temp_soup.div.contents
# 2. 将解析出的新元素(可能是文本节点和<br>标签的混合)
# 以相反的顺序插入到原始节点之后。这样做可以保持它们的原始顺序。
for element in reversed(new_elements):
node.insert_after(element)
# 3. 移除原始的文本节点
node.decompose()
elif item['type'] == 'attribute':
# --- 属性逻辑保持不变因为属性值不支持HTML ---
tag = item['tag']
# --- 分类处理:属性 vs. 块级标签 ---
if item['type'] == 'attribute':
attr = item['attribute']
separator_for_attr = self.separator.replace('\n', ' ').strip()
new_attr_value = ""
# 在属性值中,<br>将被视为普通文本,这是正确的行为
separator_for_attr = self.separator.replace('<br>', ' ').replace('<br/>', ' ')
if self.insert_mode == "replace":
new_attr_value = translated_text
elif self.insert_mode == "append":
new_attr_value = original_text + separator_for_attr + translated_text
new_attr_value = f"{original_text}{separator_for_attr}{translated_text}"
elif self.insert_mode == "prepend":
new_attr_value = translated_text + separator_for_attr + original_text
else:
new_attr_value = original_text
new_attr_value = f"{translated_text}{separator_for_attr}{original_text}"
tag[attr] = new_attr_value.strip()
elif item['type'] == 'block_tag':
is_table_cell = tag.name in ['td', 'th']
if self.insert_mode == "replace":
tag.clear()
new_content_soup = BeautifulSoup(translated_text, 'html.parser')
for node in list(new_content_soup.children):
tag.append(node.extract())
elif is_table_cell:
# 表格单元格:在内部组合内容
tag.clear()
original_nodes = BeautifulSoup(original_text, 'html.parser').contents
translated_nodes = BeautifulSoup(translated_text, 'html.parser').contents
separator_nodes = []
if self.separator:
lines = self.separator.split('\n')
for j, line in enumerate(lines):
if line: separator_nodes.append(NavigableString(line))
if j < len(lines) - 1: separator_nodes.append(soup.new_tag('br'))
order = [original_nodes, separator_nodes, translated_nodes] if self.insert_mode == "append" else [
translated_nodes, separator_nodes, original_nodes]
for node_list in order:
for node in node_list:
tag.append(node.extract() if isinstance(node, Tag) else node)
else:
# 常规块级元素:创建新标签
translated_tag = soup.new_tag(tag.name, attrs=tag.attrs)
new_content_soup = BeautifulSoup(translated_text, 'html.parser')
for node in list(new_content_soup.children):
translated_tag.append(node.extract())
separator_tag = None
if self.separator:
separator_tag = soup.new_tag('p')
lines = self.separator.split('\n')
for j, line in enumerate(lines):
if line: separator_tag.append(NavigableString(line))
if j < len(lines) - 1: separator_tag.append(soup.new_tag('br'))
if self.insert_mode == "append":
current_node = tag
if separator_tag:
current_node.insert_after(separator_tag)
current_node = separator_tag
current_node.insert_after(translated_tag)
elif self.insert_mode == "prepend":
tag.insert_before(translated_tag)
if separator_tag:
translated_tag.insert_after(separator_tag)
return soup.encode('utf-8')
def translate(self, document: Document) -> Self:
"""
同步翻译HTML文档。
"""
soup, translatable_items, original_texts = self._pre_translate(document)
if not translatable_items:
self.logger.info("\nHTML文件中没有找到符合安全规则的可翻译内容。")
# 即使没有翻译内容,也返回经过清理(移除非翻译标签)的文档内容
document.content = soup.encode('utf-8')
return self
@@ -239,9 +212,6 @@ class HtmlTranslator(AiTranslator):
return self
async def translate_async(self, document: Document) -> Self:
"""
异步翻译HTML文档。
"""
soup, translatable_items, original_texts = await asyncio.to_thread(self._pre_translate, document)
if not translatable_items: