Release v0.13: Typography V3 (Safe mode) & Endnote Extraction Fix

This commit is contained in:
谭凯
2026-02-01 11:51:36 +08:00
parent 8e58415173
commit 40491e6386
95 changed files with 8493 additions and 11 deletions
+124
View File
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,39 @@
/* Bilingual Translation Overrides */
/* --- Common Base --- */
.translation,
.translation-no-indent {
/* text-align: justify; <-- REMOVED: Restore original alignment (left/center/justify) */
line-height: 1.6;
clear: both;
/* Fix overlap with floating elements like Drop Caps */
margin-top: 0.5em;
/* Ensure separation from previous block */
display: block;
/* Ensure block formatting context */
}
/* --- Standard Indent (2 chars) --- */
p.translation {
text-indent: 2em;
}
/* --- No Indent (For Drop Caps) --- */
p.translation-no-indent {
text-indent: 0 !important;
/* REMOVED: margin/padding resets that broke centering */
}
/* --- Fix Drop Cap Font Mismatch --- */
.translation span,
.translation-no-indent span,
.translation b,
.translation-no-indent b,
.translation i,
.translation-no-indent i,
.translation em,
.translation-no-indent em,
.translation strong,
.translation-no-indent strong {
font-family: inherit !important;
}
+319
View File
@@ -0,0 +1,319 @@
import shutil
import uuid
from pathlib import Path
from ebooklib import epub
from src.common.data_model import BookStructure
from src.common.utils import setup_logger, ensure_directory
logger = setup_logger("bilingual_builder")
class BilingualBuilder:
"""
Assembles the final EPUB from BookStructure.
Preserves the original TOC structure by reading it from the original EPUB.
"""
def __init__(self, work_dir: Path, original_epub_path: Path = None):
self.work_dir = work_dir
self.assets_dir = work_dir / "assets"
self.original_epub_path = original_epub_path
self._original_book = None
def _load_original_book(self):
"""Lazy load original book for TOC extraction."""
if self._original_book is None and self.original_epub_path and self.original_epub_path.exists():
try:
self._original_book = epub.read_epub(str(self.original_epub_path))
logger.debug(f"Loaded original EPUB for TOC: {self.original_epub_path}")
except Exception as e:
logger.warning(f"Failed to load original EPUB: {e}")
return self._original_book
def _sanitize_toc(self, toc):
"""
Ensure all TOC nodes have IDs (for ebooklib compatibility).
From v0.08 bilingual_builder.py
"""
result = []
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]}"
result.append(item)
elif isinstance(item, tuple) and len(item) == 2:
# Handle (Section, [children]) structure
section, children = item
if isinstance(section, (epub.Link, epub.Section)):
if not getattr(section, 'uid', None):
section.uid = f"navPoint-{uuid.uuid4().hex[:8]}"
sanitized_children = self._sanitize_toc(children)
result.append((section, sanitized_children))
else:
result.append(item)
return result
def _validate_and_fix_toc(self, toc, book_items):
"""
Recursively validate and fix TOC links.
Removes nodes with broken links that cannot be fixed.
"""
fixed_toc = []
for item in toc:
if isinstance(item, (epub.Link, epub.Section)):
# Check href
href = getattr(item, 'href', '')
if href:
# Remove anchor for check
clean_href = href.split('#')[0]
# Check if item exists in book (by file_name)
found = False
for existing_item in book_items.values():
if existing_item.file_name == clean_href:
found = True
break
if clean_href.endswith(existing_item.file_name) or existing_item.file_name.endswith(clean_href):
item.href = existing_item.file_name
found = True
break
if not found:
if 'c0.xhtml' in clean_href:
for existing_item in book_items.values():
if 'titlepage' in existing_item.file_name or 'cover' in existing_item.file_name.lower():
if existing_item.media_type == "application/xhtml+xml":
logger.info(f"Fixed TOC link: {href} -> {existing_item.file_name}")
item.href = existing_item.file_name
found = True
break
if not found:
logger.warning(f"Removing broken TOC link: {href}")
continue
if isinstance(item, tuple) and len(item) == 2:
section, children = item
fixed_children = self._validate_and_fix_toc(children, book_items)
fixed_toc.append((section, fixed_children))
else:
fixed_toc.append(item)
return fixed_toc
def build(self, structure: BookStructure, output_path: Path) -> Path:
"""
Builds the EPUB file.
Returns the path to the generated EPUB.
"""
logger.info(f"Building final EPUB: {output_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
book = epub.EpubBook()
# 1. Metadata
book.set_identifier(structure.metadata.identifier or f"uuid-{uuid.uuid4().hex[:12]}")
book.set_title(structure.metadata.title)
book.set_language(structure.metadata.language)
book.add_author(structure.metadata.author)
# 2. Copy TOC from original EPUB if available
original_book = self._load_original_book()
if original_book and hasattr(original_book, 'toc') and original_book.toc:
book.toc = self._sanitize_toc(original_book.toc)
logger.info("Copied TOC structure from original EPUB")
# 3. Add Resources - First pass: Collect CSS items
items_map = {} # id -> epub_item
css_items = [] # List of CSS EpubItem for linking
html_items = [] # List of (item_id, EpubHtml) tuples
for item_id, resource in structure.resources.items():
# Skip NCX - we'll handle it separately
if resource.media_type == "application/x-dtbncx+xml":
continue
if resource.media_type == "application/xhtml+xml":
# HTML Item - create but don't add yet (need to add CSS links)
item = epub.EpubHtml(
uid=item_id,
file_name=resource.href,
media_type=resource.media_type,
content=resource.content.encode('utf-8')
)
html_items.append((item_id, item))
elif resource.file_path:
# Binary/Asset Item
asset_full_path = self.work_dir / resource.file_path
if not asset_full_path.exists():
logger.warning(f"Asset missing: {asset_full_path}")
continue
with open(asset_full_path, 'rb') as f:
content = f.read()
# Check if this is the cover image
if structure.metadata.cover_image_id == item_id:
logger.info(f"Setting cover image: {item_id}")
# Manually add cover metadata to avoid "set_cover" creating a duplicate item with hardcoded ID
# 1. Add item normally (as EpubImage)
item = epub.EpubImage(
uid=item_id,
file_name=resource.href,
media_type=resource.media_type,
content=content
)
book.add_item(item)
items_map[item_id] = item
# 2. Add metadata pointing to it
book.add_metadata(None, 'meta', item_id, {'name': 'cover', 'content': item_id})
continue
if "image" in resource.media_type:
item = epub.EpubImage(
uid=item_id,
file_name=resource.href,
media_type=resource.media_type,
content=content
)
else:
item = epub.EpubItem(
uid=item_id,
file_name=resource.href,
media_type=resource.media_type,
content=content
)
# Track CSS items
if resource.media_type == "text/css":
css_items.append(item)
book.add_item(item)
items_map[item_id] = item
else:
logger.warning(f"Skipping resource {item_id}: No content or file path.")
continue
# 3.1 Inject Bilingual Style CSS
bilingual_css_item = None
try:
# We assume the CSS file is in the same directory as this builder.py
# Or use package resources. simpler: relative to file
css_source_path = Path(__file__).parent / "bilingual_style.css"
if css_source_path.exists():
with open(css_source_path, "rb") as f:
css_content = f.read()
bilingual_css_item = epub.EpubItem(
uid="bilingual_style_css",
file_name="Styles/bilingual_style.css",
media_type="text/css",
content=css_content
)
book.add_item(bilingual_css_item)
css_items.append(bilingual_css_item) # Add to list so it gets linked
logger.info("Injected bilingual_style.css")
else:
logger.warning(f"bilingual_style.css not found at {css_source_path}")
except Exception as e:
logger.error(f"Failed to inject bilingual CSS: {e}")
# 4. Add HTML items with CSS links
for item_id, item in html_items:
html_dir = Path(item.file_name).parent
for css_item in css_items:
css_path = Path(css_item.file_name)
# Calculate relative path from HTML directory to CSS file
try:
relative_css_path = Path(css_path).relative_to(html_dir)
except ValueError:
# Not a subpath, calculate full relative
# Go up from html_dir, then down to css_path
up_count = len(html_dir.parts)
# Handle case where html_dir is empty (root)
if str(html_dir) == ".":
prefix = ""
else:
prefix = "../" * up_count
relative_css_path = Path(f"{prefix}{css_path}")
item.add_link(href=str(relative_css_path), rel='stylesheet', type='text/css')
book.add_item(item)
items_map[item_id] = item
# 5. Copy missing items from original EPUB (cover, etc.)
# This ensures TOC links don't break
if original_book:
added_hrefs = {item.file_name for item in items_map.values() if hasattr(item, 'file_name')}
for orig_item in original_book.get_items():
orig_name = orig_item.get_name()
# Check for existence (exact or suffix overlap to handle EPUB/OEBPS prefixes)
is_duplicate = False
if orig_name in added_hrefs:
is_duplicate = True
else:
for added in added_hrefs:
if added.endswith(orig_name) or orig_name.endswith(added):
is_duplicate = True
break
if is_duplicate:
continue
# Skip NCX and NAV - we generate these
if 'ncx' in orig_name.lower() or orig_name.endswith('nav.xhtml'):
continue
# Double check if it's an image we likely already processed
if orig_item.media_type and orig_item.media_type.startswith("image/"):
# If we didn't match via filename, verify if we missed it or if it's truly new
# But suffix check usually catches it.
pass
# Copy the item directly
try:
book.add_item(orig_item)
items_map[orig_item.id] = orig_item
logger.debug(f"Copied missing item from original: {orig_name}")
except Exception as e:
logger.warning(f"Failed to copy item {orig_name}: {e}")
# 6. Spine
book.spine = []
for item_id in structure.spine:
if item_id in items_map:
book.spine.append(items_map[item_id])
else:
logger.warning(f"Spine item {item_id} not found in resources.")
# Add missing spine items from original
if original_book:
for spine_id, _ in original_book.spine:
if spine_id not in [i.id for i in book.spine]:
orig_item = original_book.get_item_with_id(spine_id)
if orig_item and orig_item.id in items_map:
book.spine.append(items_map[orig_item.id])
# Validate and fix TOC
if original_book and hasattr(book, 'toc') and book.toc:
try:
book.toc = self._validate_and_fix_toc(book.toc, items_map)
logger.info("Validated and fixed TOC links")
except Exception as e:
logger.error(f"Failed to validate TOC: {e}")
# 7. Navigation - NCX and Nav
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
# 7. Write
epub.write_epub(str(output_path), book)
logger.info(f"EPUB created successfully at {output_path}")
return output_path
@@ -0,0 +1,72 @@
import re
from typing import Dict, Tuple, List, Optional
from loguru import logger
from src.common.utils import setup_logger
logger = setup_logger("format_restorer")
class FormatRestorer:
"""
Restores HTML formatting from placeholders.
"""
PLACEHOLDER_REGEX = re.compile(r'φ(/?\d+)φ')
def restore(self, text_with_placeholders: str, placeholder_map: Dict[str, str], context_id: str = None) -> Tuple[str, bool]:
"""
Restores HTML from text with placeholders.
Returns (restored_html, success).
"""
if not placeholder_map:
return text_with_placeholders or "", True
if not text_with_placeholders:
prefix = placeholder_map.get("_prefix", "")
suffix = placeholder_map.get("_suffix", "")
return prefix + suffix, True
prefix = placeholder_map.get("_prefix", "")
suffix = placeholder_map.get("_suffix", "")
inner_map = {k: v for k, v in placeholder_map.items() if not k.startswith("_")}
found_ids = set(self.PLACEHOLDER_REGEX.findall(text_with_placeholders))
expected_ids = set(inner_map.keys())
success = True
context_msg = f" [ID: ...{context_id[-6:]}]" if context_id else ""
missing_ids = expected_ids - found_ids
if missing_ids:
logger.warning(f"Restoration warning: missing placeholders {missing_ids}{context_msg}")
success = False
unknown_ids = found_ids - expected_ids
if unknown_ids:
real_unknowns = set()
for pid in unknown_ids:
if pid.startswith('/') and pid[1:] in expected_ids:
continue
real_unknowns.add(pid)
if real_unknowns:
logger.warning(f"Restoration warning: unknown placeholders {real_unknowns}{context_msg}")
success = False
def replace_match(match):
pid = match.group(1)
if pid in inner_map:
return inner_map[pid]
else:
return ""
try:
restored_inner = self.PLACEHOLDER_REGEX.sub(replace_match, text_with_placeholders)
restored_html = prefix + restored_inner + suffix
return restored_html, success
except Exception as e:
logger.error(f"Restoration failed: {e}")
return prefix + self._strip_placeholders(text_with_placeholders) + suffix, False
def _strip_placeholders(self, text: str) -> str:
return self.PLACEHOLDER_REGEX.sub("", text)
@@ -0,0 +1,106 @@
import asyncio
from typing import List, Optional
from loguru import logger
from src.common.data_model import ManifestEntry
from src.assembly.format_restorer import FormatRestorer
from src.translation.llm_client import LLMClient
from src.common.utils import add_spacing
class RestorationEngine:
"""
Handles post-translation format restoration:
1. Spacing optimization (Pangu)
2. HTML tag restoration (FormatRestorer)
3. LLM-based repair (if restoration fails)
"""
def __init__(self, llm_client: Optional[LLMClient] = None):
self.restorer = FormatRestorer()
self.llm_client = llm_client
async def restore_entries(self, entries: List[ManifestEntry], force: bool = False) -> int:
"""
Process a list of entries, updating their translated_html field.
Returns the number of successfully restored entries.
"""
success_count = 0
for entry in entries:
if not entry.translated_text:
continue
# Idempotency check: Skip if already restored (unless forced)
if entry.translated_html and not force:
success_count += 1
continue
# 1. Spacing Fix
spaced_text = add_spacing(entry.translated_text)
# 2. Format Restoration
restored_html, success = self.restorer.restore(
spaced_text,
entry.placeholders,
context_id=entry.entry_id
)
# 3. LLM Repair (if needed and available)
if not success and self.llm_client:
logger.debug(f"Attempting LLM repair for {entry.entry_id}...")
repaired_text = await self._repair_placeholders(entry, spaced_text)
if repaired_text:
# Retry with repaired text
repaired_html, repaired_success = self.restorer.restore(
repaired_text,
entry.placeholders,
context_id=f"{entry.entry_id}-REPAIR"
)
if repaired_success:
logger.info(f"LLM Repair successful for {entry.entry_id}")
restored_html = repaired_html
# We do NOT update translated_text here to preserve original LLM output?
# Actually user might want the spaced and repaired text as the "text".
# But let's keep translated_text as raw-ish, and translated_html as final.
# Update: To ensure consistency, maybe we should update translated_text?
# The implementation plan says "Save result to ManifestEntry.translated_html".
# It implicitly leaves translated_text alone or updates it?
# Let's keep translated_text as is (except maybe spacing? no, keep it raw).
# But wait, if we repair key text, next time we run, we might want to use the repaired text?
# For now, only populate translated_html.
success = True
else:
logger.warning(f"LLM Repair failed validation for {entry.entry_id}")
# 4. Save Result
# Even if validation failed, we often get a "best effort" restored_html from restorer (fallback).
# The restorer usually returns *something*.
# If completely failed (e.g. mismatch), restorer might return raw text or partial?
# FormatRestorer.restore returns (restored_str, success_bool).
# Even if success=False, restored_str is produced (often just stripping unused placeholders or keeping them raw).
entry.translated_html = restored_html
if success:
success_count += 1
return success_count
async def _repair_placeholders(self, entry: ManifestEntry, current_text: str) -> Optional[str]:
"""Ask LLM to fix placeholders."""
try:
system_prompt = "You are a translation repair assistant."
user_prompt = f"""
The following translation has incorrect placeholders.
Please fix the placeholders in the Translated text so they match the Original text structure EXACTLY.
Do NOT change the Chinese translation content, only fix the φXφ tags.
Original: {entry.original_text}
Translated (Broken): {current_text}
Output ONLY the fixed Translated text.
"""
repaired = await self.llm_client.raw_chat_completion(system_prompt, user_prompt)
return repaired.strip()
except Exception as e:
logger.error(f"LLM Repair error: {e}")
return None