125 lines
5.0 KiB
Python
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
|