优化html译文附加效果
This commit is contained in:
@@ -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:
|
||||
@@ -260,4 +230,4 @@ class HtmlTranslator(AiTranslator):
|
||||
document.content = await asyncio.to_thread(
|
||||
self._after_translate, soup, translatable_items, translated_texts, original_texts
|
||||
)
|
||||
return self
|
||||
return self
|
||||
Reference in New Issue
Block a user