From 6c570d58c1afd2d78fc991e4cab7a27f83c470a6 Mon Sep 17 00:00:00 2001 From: xunbu Date: Sat, 25 Oct 2025 13:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96html=E8=AF=91=E6=96=87?= =?UTF-8?q?=E9=99=84=E5=8A=A0=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai_translator/html_translator.py | 218 ++++++++---------- 1 file changed, 94 insertions(+), 124 deletions(-) diff --git a/docutranslate/translator/ai_translator/html_translator.py b/docutranslate/translator/ai_translator/html_translator.py index 3fceaa3..2902dd9 100644 --- a/docutranslate/translator/ai_translator/html_translator.py +++ b/docutranslate/translator/ai_translator/html_translator.py @@ -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中处理片段的标准做法,避免了自动添加。 - temp_soup = BeautifulSoup(f"
{new_content_str}
", 'html.parser') - new_elements = temp_soup.div.contents - - # 2. 将解析出的新元素(可能是文本节点和
标签的混合) - # 以相反的顺序插入到原始节点之后。这样做可以保持它们的原始顺序。 - 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 = "" - # 在属性值中,
将被视为普通文本,这是正确的行为 - separator_for_attr = self.separator.replace('
', ' ').replace('
', ' ') - 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 \ No newline at end of file