Files
epub_bilingual_translator/src/assembly/backfiller.py
T

125 lines
5.0 KiB
Python

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"<div>{html_content}</div>", '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