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,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")
|
||||
Reference in New Issue
Block a user