- Refactor codebase into src/ (preprocessing, translation, assembly) - Add pipeline/ scripts for individual stages - Externalize configuration to config/config.yaml - Fix Cover Image preservation - Update documentation and manuals
227 lines
9.4 KiB
Python
227 lines
9.4 KiB
Python
"""
|
|
纯中文 EPUB 构建器模块
|
|
|
|
负责:
|
|
1. 复制原始 EPUB 结构
|
|
2. 使用译文替换原文
|
|
3. 调用 FormatRestorer 将译文占位符还原为 HTML 标签
|
|
"""
|
|
|
|
from ebooklib import epub
|
|
import ebooklib
|
|
from bs4 import BeautifulSoup
|
|
from typing import Dict, List, Any
|
|
from pathlib import Path
|
|
from loguru import logger
|
|
import uuid
|
|
from .fine_grained_extractor import FineGrainedExtractor
|
|
|
|
class ChineseEPUBBuilder:
|
|
"""纯中文 EPUB 构建器 (DOM Safe)"""
|
|
|
|
def __init__(self, original_book, config: Dict):
|
|
self.original_book = original_book
|
|
self.config = config
|
|
self.translate_toc = config['translation'].get('translate_toc', False)
|
|
|
|
def create_chinese_epub_with_mapping(self,
|
|
items: List[Any], # List[ManifestItem]
|
|
output_path: str) -> str:
|
|
"""
|
|
创建纯中文 EPUB。
|
|
"""
|
|
try:
|
|
new_book = epub.EpubBook()
|
|
self._copy_metadata(new_book)
|
|
# 安全清理 TOC
|
|
new_book.toc = self._sanitize_toc(self.original_book.toc)
|
|
|
|
# 1. 按文件分组 Manifest Items
|
|
# 假设 items 已经是按全局 ID 排序的 (ManifestManager.get_items 返回有序列表)
|
|
file_items_map = {}
|
|
for item in items:
|
|
fname = item.source_file
|
|
if fname not in file_items_map:
|
|
file_items_map[fname] = []
|
|
file_items_map[fname].append(item)
|
|
|
|
processed_item_ids = set()
|
|
item_map = {}
|
|
|
|
# 特殊处理:封面图片
|
|
|
|
# 复制资源
|
|
for item in self.original_book.get_items():
|
|
if item.get_type() != ebooklib.ITEM_DOCUMENT:
|
|
if item.id not in processed_item_ids:
|
|
new_book.add_item(item)
|
|
processed_item_ids.add(item.id)
|
|
item_map[item.id] = item
|
|
|
|
# 重建 Spine
|
|
new_spine = []
|
|
for spine_id, linear in self.original_book.spine:
|
|
item = self.original_book.get_item_with_id(spine_id)
|
|
if not item: continue
|
|
|
|
if item.get_type() == ebooklib.ITEM_DOCUMENT:
|
|
file_name = item.get_name()
|
|
if file_name in file_items:
|
|
new_item = self._create_chinese_document(
|
|
item, file_items[file_name]
|
|
)
|
|
new_item.id = item.id
|
|
else:
|
|
new_item = item
|
|
|
|
if new_item.id not in processed_item_ids:
|
|
new_book.add_item(new_item)
|
|
processed_item_ids.add(new_item.id)
|
|
new_spine.append(new_item)
|
|
else:
|
|
if item.id in item_map:
|
|
new_spine.append(item_map[item.id])
|
|
|
|
new_book.spine = new_spine
|
|
new_book.add_item(epub.EpubNcx())
|
|
new_book.add_item(epub.EpubNav())
|
|
|
|
output_file = self._generate_output_filename(output_path)
|
|
epub.write_epub(output_file, new_book, {})
|
|
return output_file
|
|
|
|
except Exception as e:
|
|
logger.error(f"创建纯中文 EPUB 失败: {e}", exc_info=True)
|
|
raise
|
|
|
|
def _sanitize_toc(self, toc):
|
|
"""确保 TOC 中的所有节点都有 ID"""
|
|
for item in toc:
|
|
if isinstance(item, (epub.Link, epub.Section)):
|
|
if not getattr(item, 'uid', None):
|
|
item.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
|
elif isinstance(item, tuple) and len(item) == 2:
|
|
section, children = item
|
|
if isinstance(section, (epub.Link, epub.Section)):
|
|
if not getattr(section, 'uid', None):
|
|
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
|
|
self._sanitize_toc(children)
|
|
return toc
|
|
|
|
def _copy_metadata(self, new_book):
|
|
try:
|
|
for namespace, meta_dict in self.original_book.metadata.items():
|
|
for name, values in meta_dict.items():
|
|
for value, other in values:
|
|
if name and hasattr(name, 'lower') and name.lower() == 'identifier': continue
|
|
new_book.add_metadata(namespace, name, value, other)
|
|
new_book.add_metadata('DC', 'language', 'zh-CN')
|
|
new_book.set_identifier(f"chinese-{uuid.uuid4().hex[:12]}")
|
|
except Exception as e:
|
|
logger.error(f"元数据复制出错: {e}")
|
|
|
|
def _create_chinese_document(self, original_item, manifest_items: list):
|
|
try:
|
|
from .text_processor import TextProcessor
|
|
soup = BeautifulSoup(original_item.get_content().decode('utf-8'), 'html.parser')
|
|
|
|
# 获取文本元素
|
|
text_elements = TextProcessor.get_valid_text_elements(soup)
|
|
|
|
current_idx = 0
|
|
for element in text_elements:
|
|
if not TextProcessor.clean_element_text(element): continue
|
|
|
|
if current_idx < len(manifest_items):
|
|
m_item = manifest_items[current_idx]
|
|
|
|
# 只有当非导航元素时才尝试替换内容
|
|
if not TextProcessor.is_navigation_element(element):
|
|
# 检查是否是嵌套容器(需要特殊处理)
|
|
is_nested = TextProcessor.is_nested_container(element)
|
|
|
|
# 优先使用带格式的翻译,降级到纯文本翻译
|
|
if m_item.translation_with_original_html:
|
|
if is_nested:
|
|
self._replace_direct_content(element, m_item.translation_with_original_html)
|
|
else:
|
|
self._replace_content(element, m_item.translation_with_original_html)
|
|
elif m_item.translation:
|
|
# 降级:使用纯文本翻译(无格式)
|
|
if is_nested:
|
|
self._replace_direct_content(element, m_item.translation)
|
|
else:
|
|
self._replace_content(element, m_item.translation)
|
|
|
|
current_idx += 1
|
|
|
|
|
|
new_item = epub.EpubHtml(title=original_item.title, file_name=original_item.get_name(), lang='zh-CN')
|
|
new_item.set_content(str(soup).encode('utf-8'))
|
|
return new_item
|
|
except Exception as e:
|
|
logger.error(f"创建中文文档失败 {original_item.get_name()}: {e}")
|
|
return original_item
|
|
|
|
def _replace_content(self, element, translated_html: str):
|
|
"""用译文替换元素的 inner_html"""
|
|
try:
|
|
# 将译文 HTML 字符串解析为 BeautifulSoup 对象
|
|
new_soup = BeautifulSoup(translated_html, 'html.parser')
|
|
# 清空原元素并填入新内容
|
|
element.clear()
|
|
# 重要:必须先转换为 list,否则 append 会修改 contents 导致跳过元素
|
|
for content in list(new_soup.contents):
|
|
element.append(content)
|
|
except Exception as e:
|
|
logger.error(f"替换内容失败: {e}")
|
|
|
|
def _replace_direct_content(self, element, translated_html: str):
|
|
"""
|
|
替换嵌套容器元素的直接文本内容(保留子块级元素)
|
|
|
|
策略:
|
|
1. 保存所有子块级元素
|
|
2. 清空元素内容
|
|
3. 填入译文
|
|
4. 在末尾追加保存的子块
|
|
"""
|
|
try:
|
|
from bs4 import NavigableString
|
|
block_tags = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'li', 'td']
|
|
|
|
# 1. 保存所有子块级元素
|
|
saved_blocks = []
|
|
for child in element.find_all(block_tags, recursive=False):
|
|
# 只保存直接子元素中的块
|
|
saved_blocks.append(child.extract())
|
|
|
|
# 2. 额外保存嵌套在 span 等内联元素中的块
|
|
for inline in element.find_all(['span', 'a', 'em', 'strong'], recursive=True):
|
|
for child in inline.find_all(block_tags, recursive=False):
|
|
saved_blocks.append(child.extract())
|
|
|
|
# 3. 清空并填入译文
|
|
new_soup = BeautifulSoup(translated_html, 'html.parser')
|
|
element.clear()
|
|
for content in list(new_soup.contents):
|
|
element.append(content)
|
|
|
|
# 4. 追加保存的子块
|
|
for block in saved_blocks:
|
|
element.append(block)
|
|
|
|
except Exception as e:
|
|
logger.error(f"替换嵌套内容失败: {e}")
|
|
# 降级到普通替换
|
|
self._replace_content(element, translated_html)
|
|
|
|
|
|
|
|
def _generate_output_filename(self, output_path: str) -> str:
|
|
from .utils import sanitize_filename
|
|
title = self.original_book.get_metadata('DC', 'title')
|
|
clean_title = sanitize_filename(title[0][0]) if title else "chinese_book"
|
|
Path(output_path).mkdir(parents=True, exist_ok=True)
|
|
return str(Path(output_path) / f"{clean_title}_chinese.epub")
|