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