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:
谭凯
2026-01-31 22:49:44 +08:00
parent 9ef82393be
commit 7a93c52b42
306 changed files with 30313 additions and 1071 deletions
+235
View File
@@ -0,0 +1,235 @@
"""
双语 EPUB 构建器模块 (集成 V2)
基于 FineGrainedExtractor 的 DOM 回填机制,确保 100% 的内容对齐和格式保留。
"""
from ebooklib import epub
import ebooklib
from bs4 import BeautifulSoup
from typing import Dict, List
from pathlib import Path
from loguru import logger
import uuid
from .fine_grained_extractor import FineGrainedExtractor
class BilingualEPUBBuilder:
"""双语 EPUB 构建器"""
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_bilingual_epub_with_mapping(self, translation_map: Dict[str, str],
paragraph_map: Dict[str, Dict],
output_path: str) -> str:
"""
创建双语 EPUB。使用 ordered_ids 确保与 Manifest 严格一致。
Args:
translation_map: { global_item_id: translated_text_with_ph }
paragraph_map: { global_item_id: item_metadata_dict }
"""
try:
new_book = epub.EpubBook()
self._copy_metadata(new_book)
# 安全清理 TOC (虽然预处理已做,但构建新书对象时再次确保合规)
new_book.toc = self._sanitize_toc(self.original_book.toc)
# 1. 准备每个文件的有序ID列表
# 目的是将扁平的 map 重新按文件和顺序组织
file_ordered_ids = {}
# paragraph_map 的 key 是 global_id,通常包含顺序信息或我们依赖 items 的插入顺序
# 更好的方式是依赖 item ID 的数字部分排序,如果它们是 'id_0', 'id_1'...
# 假设 ID 包含顺序信息。
sorted_pids = sorted(paragraph_map.keys(), key=lambda x: self._extract_id_index(x))
for pid in sorted_pids:
info = paragraph_map[pid]
fname = info['file_name']
if fname not in file_ordered_ids:
file_ordered_ids[fname] = []
file_ordered_ids[fname].append(pid)
processed_item_ids = set()
item_map = {}
# 特殊处理:封面图片
self._handle_cover(new_book, processed_item_ids, item_map)
# 2. 复制所有非文档资源 (图片, CSS, 字体)
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
# 3. 处理并回填文档
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()
new_item = item # 默认使用原 Item
# 如果该文件有翻译内容
if file_name in file_ordered_ids:
target_ids = file_ordered_ids[file_name]
# 执行回填
new_content = self._process_document_content(
item.get_content().decode('utf-8'),
file_name,
target_ids,
translation_map
)
# 创建新 item 避免污染原对象
new_item = epub.EpubHtml(
title=item.title,
file_name=file_name,
lang='zh-CN', # 双语版主要语言
uid=item.id
)
new_item.set_content(new_content.encode('utf-8'))
# 复制原 item 的其他属性如 style
for link in item.get_links():
# 这里不做深度复制,简单引用
pass
# 重新添加 links (特别是 CSS)
# 注意: EpubHtml 构造时不会自动带原来的 links,需要手动加
# 但我们在 dirty hack 里,直接 set_content 了 HTML。
# 如果 HTML head 里有 link, ebooklib 可能会解析并注册?
# Ebooklib 的行为是: 只有通过 add_link 加的才会出现在 opf manifest。
# 我们需要把原 item 的 links 复制过来
if hasattr(item, 'links'):
for link in item.links:
new_item.add_link(**link) # 不是很安全,视 ebooklib 版本而定
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, {})
logger.info(f"双语 EPUB 生成成功: {output_file}")
return output_file
except Exception as e:
logger.error(f"创建双语 EPUB 失败: {e}", exc_info=True)
raise
def _process_document_content(self, content: str, file_name: str,
target_ids: List[str], translation_map: Dict[str, str]) -> str:
"""
处理单个文档的内容:提取 -> 注入翻译 -> 回填
"""
try:
# 1. 再次提取,建立 DOM 映射
# 必须使用与 TextProcessing 阶段完全一致的参数
extractor = FineGrainedExtractor(translate_toc=self.translate_toc)
items = extractor.extract(content, file_name)
# 2. 筛选出应该翻译的项目
translatable_items = [i for i in items if i['should_translate']]
# 3. 一致性检查
if len(translatable_items) != len(target_ids):
logger.error(
f"严重错误 [{file_name}]: 提取项数 ({len(translatable_items)}) "
f"与 Manifest 记录数 ({len(target_ids)}) 不一致! "
"将跳过此文件的翻译回填以防错位。"
)
# Fallback: 返回原始内容
return content
# 4. 注入翻译
for extract_item, pid in zip(translatable_items, target_ids):
translation = translation_map.get(pid)
if translation:
extract_item['translation'] = translation
# 5. 回填
# 获取输出模式
output_mode = self.config.get('output', {}).get('mode', 'bilingual')
bilingual_mode = (output_mode == 'bilingual')
new_html = extractor.backfill(items, bilingual=bilingual_mode)
return new_html
except Exception as e:
logger.error(f"处理文档内容失败 {file_name}: {e}", exc_info=True)
return content
def _extract_id_index(self, pid: str) -> int:
"""从 ID 字符串中提取数字索引用于排序 (如 'p_10' -> 10)"""
try:
# 尝试常见格式 p_123, id_456
parts = pid.split('_')
if len(parts) > 1 and parts[-1].isdigit():
return int(parts[-1])
return 0
except:
return 0
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 _handle_cover(self, new_book, processed_item_ids, item_map):
cover_id_meta = self.original_book.get_metadata('OPF', 'cover')
if cover_id_meta:
cover_item = self.original_book.get_item_with_id(cover_id_meta[0][0])
if cover_item:
new_book.add_item(cover_item)
processed_item_ids.add(cover_item.id)
item_map[cover_item.id] = cover_item
# 复制 cover metadata
new_book.add_metadata('OPF', 'cover', cover_item.id)
def _copy_metadata(self, new_book):
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"bilingual-{uuid.uuid4().hex[:12]}")
def _generate_output_filename(self, output_path: str) -> str:
# 根据模式生成不同的后缀
output_mode = self.config.get('output', {}).get('mode', 'bilingual')
suffix = "chinese" if output_mode == "chinese" else "bilingual"
title_meta = self.original_book.get_metadata('DC', 'title')
title = title_meta[0][0] if title_meta else "bilingual_book"
safe_title = "".join([c for c in title if c.isalnum() or c in (' ', '-', '_')]).strip()
Path(output_path).mkdir(parents=True, exist_ok=True)
return str(Path(output_path) / f"{safe_title}_{suffix}.epub")