from pathlib import Path from typing import List, Dict, Optional from bs4 import BeautifulSoup, NavigableString from src.common.data_model import BookStructure, ManifestEntry from src.common.utils import setup_logger logger = setup_logger("backfill_engine") class BackfillEngine: """ Applies translations back to the BookStructure. Now simplified to only inject 'translated_html' or fallback to text. No format restoration logic here. """ def __init__(self): # No more Restorer or LLM Client pass async def backfill(self, structure: BookStructure, manifest_entries: List[ManifestEntry], mode: str = "bilingual") -> BookStructure: """ Modifies the BookStructure in-place with translations. Args: structure: The BookStructure (from book_structure.json). manifest_entries: List of translations. mode: 'bilingual' or 'target_only'. """ logger.info(f"Backfilling with mode: {mode}") # Index manifest manifest_map: Dict[str, Dict[str, ManifestEntry]] = {} for entry in manifest_entries: if not entry.translated_text: continue if entry.file_path not in manifest_map: manifest_map[entry.file_path] = {} manifest_map[entry.file_path][entry.element_id] = entry # Iterate resources for item_id, resource in structure.resources.items(): if resource.media_type != "application/xhtml+xml" or resource.href not in manifest_map: continue file_entries = manifest_map[resource.href] if not file_entries: continue logger.debug(f"Processing {resource.href} with {len(file_entries)} translations") soup = BeautifulSoup(resource.content, 'html.parser') modified = False for element_id, entry in file_entries.items(): element = soup.find(id=element_id) if not element: logger.warning(f"Element {element_id} not found in {resource.href}") continue # Determine content to inject # Prefer translated_html (rich text), fallback to translated_text (plain text) html_content = entry.translated_html plain_text = entry.translated_text # Create translated tag new_tag = soup.new_tag(element.name) if html_content: # Parse HTML fragment # Wrap in div to handle multiple top-level nodes inner_soup = BeautifulSoup(f"
{html_content}
", 'html.parser') container = inner_soup.find('div') if container: for child in list(container.children): new_tag.append(child) else: new_tag.string = plain_text else: # Fallback to plain text new_tag.string = plain_text # Copy attributes # Copy classes and add 'translation' or 'translation-no-indent' classes = element.get('class', []) if isinstance(classes, str): classes = classes.split() # Heuristic: Check for Drop Cap / No-Indent indicators # Keywords: 'drop', 'first', 'no-indent', 'noindent' # REMOVED: 'chapter', 'start', 'opener' (too broad, catches titles) special_keywords = ['drop', 'no-indent', 'noindent'] # 'first' can be risky (e.g. 'first-title'). # Be conservative: match 'first' only if combined with 'para' or 'letter' or similar? # For now, let's keep 'first' but be aware. # Actually, user said minimal change. Let's stick to 'drop' and explicit 'no-indent'. # Checking 'calibre1' etc is useless. is_special_para = any(k in c.lower() for k in special_keywords for c in classes) if is_special_para: new_tag['class'] = classes + ['translation-no-indent'] else: new_tag['class'] = classes + ['translation'] # Copy style style = element.get('style') if style: new_tag['style'] = style # Injection Strategy if mode == "bilingual": element.insert_after(new_tag) else: element.replace_with(new_tag) modified = True if modified: resource.content = str(soup) return structure