diff --git a/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub b/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub new file mode 100644 index 0000000..60d928e Binary files /dev/null and b/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub differ diff --git a/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub:Zone.Identifier b/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/Mona___s_Eyes___40_Thomas_Schlesser__Hildegarde_Serle___40_translator__41___41_.epub:Zone.Identifier differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b61d4eb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +ebooklib==0.18 +beautifulsoup4==4.13.4 +tqdm==4.67.1 +lxml==5.4.0 +torch>=2.0.0 +transformers>=4.51.0 +accelerate>=1.6.0 +bitsandbytes>=0.45.0 +sentencepiece>=0.2.0 diff --git a/translate_epub.py b/translate_epub.py new file mode 100644 index 0000000..4accf5a --- /dev/null +++ b/translate_epub.py @@ -0,0 +1,661 @@ +#!/usr/bin/env python3 +""" +Bilingual EPUB Translator (English → English/Vietnamese) +========================================================= +Translates an English .epub into a bilingual English-Vietnamese .epub +by loading the LLM directly in Python via HuggingFace Transformers. + +NO external server (Ollama, vLLM, etc.) is needed — the model runs +inside this process. It is downloaded once from HuggingFace Hub and +cached at ~/.cache/huggingface for all future runs. + +Features: + - Direct model loading (no server dependency) + - Chunk-based translation (configurable paragraphs per chunk) + - Checkpoint/resume so you can restart without losing progress + - tqdm progress bars for chapter and chunk tracking + - CSS-styled bilingual output with vocabulary boxes + +Usage: + python translate_epub.py input.epub + python translate_epub.py input.epub -o output.epub --chunk-size 3 +""" + +import argparse +import copy +import hashlib +import os +import re +import sys +import time +from pathlib import Path + +import ebooklib +from ebooklib import epub +from bs4 import BeautifulSoup, NavigableString, Tag +from tqdm import tqdm + +# Lazy-imported at model load time: +# torch, transformers + +# ────────────────────────────────────────────────────────────── +# Configuration Defaults +# ────────────────────────────────────────────────────────────── +DEFAULT_MODEL_ID = "Qwen/Qwen2.5-72B-Instruct" +CHUNK_SIZE = 5 # paragraphs per inference call +MAX_NEW_TOKENS = 8192 # max tokens the model generates per chunk + +# ────────────────────────────────────────────────────────────── +# Translation Prompt +# ────────────────────────────────────────────────────────────── +SYSTEM_PROMPT = ( + "You are creating a bilingual English-Vietnamese book for a student " + "learning English. I will provide a section of the book. Format the " + "output strictly using HTML tags. For EVERY paragraph, output the " + "English first, then the Vietnamese, like this: " + "

[Original English Paragraph]

" + "

[Vietnamese Translation]

. " + "Do not miss any paragraphs. At the end of the section, add a " + "vocabulary breakdown highlighting 3-5 difficult English words/idioms " + "with their Vietnamese explanations wrapped in
tags." +) + +# ────────────────────────────────────────────────────────────── +# CSS Stylesheet for Bilingual Layout +# ────────────────────────────────────────────────────────────── +BILINGUAL_CSS = """\ +/* Bilingual EN-VI Stylesheet */ +body { + font-family: Georgia, "Times New Roman", serif; + line-height: 1.8; + margin: 1.2em; + color: #2c2c2c; + background-color: #fefefe; +} + +h1, h2, h3, h4, h5, h6 { + color: #1a1a2e; + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.3; +} + +p.english { + font-weight: bold; + color: #1a1a2e; + margin-bottom: 0.15em; + margin-top: 0.8em; + font-size: 1em; + line-height: 1.7; +} + +p.vietnamese { + font-style: italic; + color: #34495e; + margin-top: 0; + margin-bottom: 1em; + padding-left: 0.8em; + border-left: 3px solid #e74c3c; + font-size: 0.95em; + line-height: 1.6; +} + +div.vocab { + background-color: #f9f3e3; + border: 1px solid #ddd; + border-left: 5px solid #e74c3c; + border-radius: 6px; + padding: 1em 1.2em; + margin: 2em 0; + font-size: 0.9em; + line-height: 1.6; + page-break-inside: avoid; +} + +div.vocab h4, +div.vocab strong { + color: #c0392b; + margin-top: 0; +} + +div.vocab ul { + padding-left: 1.2em; +} + +div.vocab li { + margin-bottom: 0.4em; +} + +hr { + border: none; + border-top: 1px solid #e0e0e0; + margin: 2em 0; +} +""" + + +# ────────────────────────────────────────────────────────────── +# Checkpoint System (for resuming multi-hour translations) +# ────────────────────────────────────────────────────────────── +def _checkpoint_dir(input_path: str) -> str: + """Return a checkpoint directory path based on the input file.""" + parent = os.path.dirname(os.path.abspath(input_path)) + book_hash = hashlib.sha256( + os.path.basename(input_path).encode() + ).hexdigest()[:12] + return os.path.join(parent, f".bilingual_cache_{book_hash}") + + +def _checkpoint_path(input_path: str, item_name: str) -> str: + safe = re.sub(r'[^\w\-.]', '_', item_name) + return os.path.join(_checkpoint_dir(input_path), f"{safe}.html") + + +def load_checkpoint(input_path: str, item_name: str) -> str | None: + """Load previously translated content, or None if not cached.""" + path = _checkpoint_path(input_path, item_name) + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return f.read() + return None + + +def save_checkpoint(input_path: str, item_name: str, content: str) -> None: + """Persist translated chapter HTML for resume capability.""" + cdir = _checkpoint_dir(input_path) + os.makedirs(cdir, exist_ok=True) + path = _checkpoint_path(input_path, item_name) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def clear_checkpoints(input_path: str) -> None: + """Remove all checkpoint files for a given input.""" + cdir = _checkpoint_dir(input_path) + if os.path.isdir(cdir): + import shutil + shutil.rmtree(cdir) + print(f" 🗑 Cleared checkpoint cache: {cdir}") + + +# ────────────────────────────────────────────────────────────── +# Model Loading (runs once, stays in memory) +# ────────────────────────────────────────────────────────────── +_model = None +_tokenizer = None + + +def load_model(model_id: str, load_in_8bit: bool = True) -> None: + """ + Load the model + tokenizer into memory. Called once at startup. + + The model is downloaded from HuggingFace Hub on the first run and + cached at ~/.cache/huggingface — subsequent runs load from cache + with no re-download. + + Args: + model_id: HuggingFace model identifier + (e.g. "Qwen/Qwen2.5-72B-Instruct") + load_in_8bit: If True, quantize to INT8 (~72 GB for 72B). + If False, load in FP32 (~288 GB) or FP16/BF16. + """ + global _model, _tokenizer + + import torch + from transformers import AutoModelForCausalLM, AutoTokenizer + + print(f"\n🔄 Loading model: {model_id}") + print(f" Quantization : {'INT8 (bitsandbytes)' if load_in_8bit else 'auto (float16/bfloat16)'}") + print(f" Cache dir : {os.environ.get('HF_HOME', '~/.cache/huggingface')}") + print(" This may take several minutes on first run (downloading weights)…\n") + + t0 = time.time() + + _tokenizer = AutoTokenizer.from_pretrained( + model_id, + trust_remote_code=True, + ) + + if load_in_8bit: + # INT8 quantization via bitsandbytes — fits ~72 GB for a 72B model + _model = AutoModelForCausalLM.from_pretrained( + model_id, + device_map="auto", + load_in_8bit=True, + trust_remote_code=True, + torch_dtype=torch.float16, + ) + else: + # Auto dtype (usually bfloat16 on modern CPUs) + _model = AutoModelForCausalLM.from_pretrained( + model_id, + device_map="auto", + trust_remote_code=True, + torch_dtype="auto", + ) + + elapsed = time.time() - t0 + print(f"✅ Model loaded in {elapsed:.0f}s and pinned in memory.\n") + + +# ────────────────────────────────────────────────────────────── +# Inference (replaces Ollama API calls) +# ────────────────────────────────────────────────────────────── +def clean_llm_response(text: str) -> str: + """Strip markdown code fences and stray wrappers from LLM output.""" + text = re.sub(r"^```(?:html|HTML)?\s*\n", "", text.strip()) + text = re.sub(r"\n?```\s*$", "", text.strip()) + return text.strip() + + +def generate_translation( + chunk_html: str, + chunk_idx: int, + total_chunks: int, + *, + max_new_tokens: int, +) -> str: + """ + Run the loaded model on a chunk of HTML and return bilingual HTML. + + Uses the chat template so the model sees system + user messages. + """ + import torch + + if _model is None or _tokenizer is None: + raise RuntimeError("Model not loaded. Call load_model() first.") + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"Here is the section to translate:\n\n{chunk_html}", + }, + ] + + try: + # Apply the model's chat template + input_text = _tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + ) + inputs = _tokenizer(input_text, return_tensors="pt").to(_model.device) + + with torch.no_grad(): + output_ids = _model.generate( + **inputs, + max_new_tokens=max_new_tokens, + temperature=0.3, + do_sample=True, + top_p=0.9, + repetition_penalty=1.05, + ) + + # Decode only the newly generated tokens + generated_ids = output_ids[0][inputs["input_ids"].shape[1]:] + raw_output = _tokenizer.decode(generated_ids, skip_special_tokens=True) + return clean_llm_response(raw_output) + + except Exception as exc: + tqdm.write( + f" ✗ Generation error on chunk {chunk_idx}/{total_chunks}: {exc}" + ) + return f"\n{chunk_html}" + + +# ────────────────────────────────────────────────────────────── +# Chapter Translation Logic +# ────────────────────────────────────────────────────────────── +def chunk_list(lst: list, n: int): + """Yield successive n-sized chunks from a list.""" + for i in range(0, len(lst), n): + yield lst[i : i + n] + + +def translate_chapter( + html_content: str, + chapter_label: str, + *, + chunk_size: int, + max_new_tokens: int, +) -> str: + """ + Translate a chapter's HTML into bilingual (EN + VI) format. + + Strategy: + 1. Parse the chapter and find all

tags. + 2. Group paragraphs into chunks of `chunk_size`. + 3. For each chunk, run the model to get bilingual HTML. + 4. Replace original paragraphs in-place with translated content, + preserving headings, images, and other non-paragraph elements + in their original positions. + """ + soup = BeautifulSoup(html_content, "html.parser") + paragraphs = soup.find_all("p") + + if not paragraphs: + return html_content # nothing to translate + + chunks = list(chunk_list(paragraphs, chunk_size)) + total_chunks = len(chunks) + + for i, para_chunk in enumerate( + tqdm(chunks, desc=" Chunks", unit="chunk", leave=False) + ): + # Build the raw HTML for this chunk + chunk_html = "\n".join(str(p) for p in para_chunk) + + translated_html = generate_translation( + chunk_html, + i + 1, + total_chunks, + max_new_tokens=max_new_tokens, + ) + + # Parse the LLM's HTML response + translated_soup = BeautifulSoup(translated_html, "html.parser") + + # Insert all translated elements right before the first

in + # this chunk, then remove the original paragraphs. + anchor = para_chunk[0] + for new_element in list(translated_soup.children): + anchor.insert_before(copy.copy(new_element)) + + for p in para_chunk: + p.decompose() + + return str(soup) + + +# ────────────────────────────────────────────────────────────── +# EPUB Processing Pipeline +# ────────────────────────────────────────────────────────────── +def process_epub( + input_path: str, + output_path: str, + *, + model_id: str, + chunk_size: int, + max_new_tokens: int, + resume: bool, + load_in_8bit: bool, +) -> None: + """Read an English EPUB, translate it, and write a bilingual EPUB.""" + + # ── Load model into memory (once) ──────────────────────── + load_model(model_id, load_in_8bit=load_in_8bit) + + print(f"📖 Reading EPUB: {input_path}") + book = epub.read_epub(input_path, {"ignore_ncx": True}) + + # ── Create output book & copy metadata ────────────────── + out_book = epub.EpubBook() + + identifiers = book.get_metadata("DC", "identifier") + base_id = identifiers[0][0] if identifiers else "unknown" + out_book.set_identifier(f"{base_id}-bilingual-en-vi") + + titles = book.get_metadata("DC", "title") + original_title = titles[0][0] if titles else "Untitled" + out_book.set_title(f"{original_title} (Bilingual EN-VI)") + out_book.set_language("en") + out_book.add_metadata("DC", "language", "vi") + + for author in book.get_metadata("DC", "creator"): + out_book.add_author(author[0]) + + # ── Add bilingual CSS ──────────────────────────────────── + css_item = epub.EpubItem( + uid="bilingual_css", + file_name="style/bilingual.css", + media_type="text/css", + content=BILINGUAL_CSS.encode("utf-8"), + ) + out_book.add_item(css_item) + + # ── Gather chapter items ───────────────────────────────── + all_items = list(book.get_items()) + doc_items = [ + item + for item in all_items + if item.get_type() == ebooklib.ITEM_DOCUMENT + ] + + print(f"📚 Found {len(doc_items)} document items to process.\n") + + spine_items: list = ["nav"] + toc_items: list = [] + + for idx, item in enumerate( + tqdm(doc_items, desc="📝 Translating chapters", unit="ch") + ): + item_name = item.get_name() or f"chapter_{idx + 1}.xhtml" + chapter_label = item_name.rsplit("/", 1)[-1] + + html_content = item.get_content().decode("utf-8", errors="replace") + soup_check = BeautifulSoup(html_content, "html.parser") + para_count = len(soup_check.find_all("p")) + + if para_count > 0: + # ── Check for cached checkpoint ────────────────── + cached = load_checkpoint(input_path, item_name) if resume else None + + if cached is not None: + tqdm.write( + f" ⏩ Resuming (cached): {chapter_label} " + f"({para_count} paragraphs)" + ) + translated_html = cached + else: + tqdm.write( + f"\n 📄 Translating: {chapter_label} " + f"({para_count} paragraphs)" + ) + translated_html = translate_chapter( + html_content, + chapter_label, + chunk_size=chunk_size, + max_new_tokens=max_new_tokens, + ) + save_checkpoint(input_path, item_name, translated_html) + + # Inject CSS link into the + final_soup = BeautifulSoup(translated_html, "html.parser") + head = final_soup.find("head") + if head: + link_tag = final_soup.new_tag( + "link", + rel="stylesheet", + type="text/css", + href="../style/bilingual.css", + ) + head.append(link_tag) + + new_item = epub.EpubHtml( + title=chapter_label, + file_name=item_name, + lang="en", + ) + new_item.set_content(str(final_soup).encode("utf-8")) + new_item.add_item(css_item) + else: + # Non-content item (cover page, TOC, etc.) — copy as-is + new_item = epub.EpubHtml( + title=chapter_label, + file_name=item_name, + lang="en", + ) + new_item.set_content(item.get_content()) + + out_book.add_item(new_item) + spine_items.append(new_item) + toc_items.append(new_item) + + # ── Copy non-document items (images, fonts, etc.) ──────── + for item in all_items: + if item.get_type() == ebooklib.ITEM_DOCUMENT: + continue # already handled + + if item.get_type() == ebooklib.ITEM_IMAGE: + img = epub.EpubImage() + img.file_name = item.get_name() + img.media_type = item.media_type + img.content = item.get_content() + out_book.add_item(img) + + elif item.get_type() == ebooklib.ITEM_STYLE: + css = epub.EpubItem( + file_name=item.get_name(), + media_type=item.media_type, + content=item.get_content(), + ) + out_book.add_item(css) + + elif item.get_type() == ebooklib.ITEM_FONT: + font = epub.EpubItem( + file_name=item.get_name(), + media_type=item.media_type, + content=item.get_content(), + ) + out_book.add_item(font) + + elif item.get_type() not in ( + ebooklib.ITEM_NAVIGATION, + ebooklib.ITEM_COVER, + ): + try: + other = epub.EpubItem( + file_name=item.get_name(), + media_type=item.media_type, + content=item.get_content(), + ) + out_book.add_item(other) + except Exception: + pass # skip items that can't be copied + + # ── Build TOC, spine, and navigation ───────────────────── + out_book.toc = toc_items + out_book.spine = spine_items + out_book.add_item(epub.EpubNcx()) + out_book.add_item(epub.EpubNav()) + + # ── Write output EPUB ──────────────────────────────────── + print(f"\n💾 Writing bilingual EPUB: {output_path}") + epub.write_epub(output_path, out_book, {}) + print("✅ Done! Your bilingual EPUB is ready.\n") + + +# ────────────────────────────────────────────────────────────── +# CLI Entry Point +# ────────────────────────────────────────────────────────────── +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Translate an English EPUB into a bilingual " + "English-Vietnamese EPUB. Loads the LLM directly " + "via HuggingFace Transformers (no server needed)." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + python translate_epub.py book.epub + python translate_epub.py book.epub -o bilingual_book.epub + python translate_epub.py book.epub --chunk-size 3 + python translate_epub.py book.epub --no-8bit # load full precision + python translate_epub.py book.epub --no-resume # ignore cache + """, + ) + + parser.add_argument( + "input", + help="Path to the input English .epub file", + ) + parser.add_argument( + "-o", "--output", + help="Output .epub path (default: _bilingual.epub)", + ) + parser.add_argument( + "--model", + default=DEFAULT_MODEL_ID, + help=f"HuggingFace model ID (default: {DEFAULT_MODEL_ID})", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=CHUNK_SIZE, + help=f"Paragraphs per inference call (default: {CHUNK_SIZE})", + ) + parser.add_argument( + "--max-new-tokens", + type=int, + default=MAX_NEW_TOKENS, + help=f"Max tokens per generation (default: {MAX_NEW_TOKENS})", + ) + parser.add_argument( + "--no-8bit", + action="store_true", + help="Disable INT8 quantization (uses more RAM but may be higher quality)", + ) + parser.add_argument( + "--no-resume", + action="store_true", + help="Ignore cached checkpoints and re-translate everything", + ) + parser.add_argument( + "--clear-cache", + action="store_true", + help="Delete all cached checkpoints for this input file and exit", + ) + + args = parser.parse_args() + + # ── Validate input ─────────────────────────────────────── + input_path = Path(args.input).resolve() + if not input_path.exists(): + print(f"❌ Error: File not found: {input_path}") + sys.exit(1) + if input_path.suffix.lower() != ".epub": + print(f"❌ Error: Expected .epub file, got: {input_path.suffix}") + sys.exit(1) + + # ── Handle --clear-cache ───────────────────────────────── + if args.clear_cache: + clear_checkpoints(str(input_path)) + sys.exit(0) + + # ── Derive output path ─────────────────────────────────── + if args.output: + output_path = args.output + else: + output_path = str(input_path.with_suffix("")) + "_bilingual.epub" + + # ── Print banner ───────────────────────────────────────── + resume = not args.no_resume + load_in_8bit = not args.no_8bit + print("=" * 62) + print(" 📚 Bilingual EPUB Translator (EN → EN/VI)") + print(" Direct model loading — no server needed") + print("=" * 62) + print(f" Input : {input_path.name}") + print(f" Output : {Path(output_path).name}") + print(f" Model : {args.model}") + print(f" Quantization : {'INT8' if load_in_8bit else 'auto (full precision)'}") + print(f" Chunk size : {args.chunk_size} paragraphs") + print(f" Max tokens : {args.max_new_tokens} per chunk") + print(f" Resume : {'ON (cached chapters reused)' if resume else 'OFF'}") + print("=" * 62) + + process_epub( + str(input_path), + output_path, + model_id=args.model, + chunk_size=args.chunk_size, + max_new_tokens=args.max_new_tokens, + resume=resume, + load_in_8bit=load_in_8bit, + ) + + +if __name__ == "__main__": + main()