""" 双语 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")