feat: Release v0.10 - Modular Architecture & External Config
- 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
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
纯中文 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")
|
||||
Reference in New Issue
Block a user