A CLI tool that translates EPUB ebooks while preserving the original formatting, structure, and metadata. It runs a three-agent pipeline — Translator, Reviewer, Formatter — with each agent independently configurable to use any LLM provider (OpenAI-compatible, Anthropic, or Google).
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
EPUB ─ extract ─► Translator │ ─► │ Reviewer │ ─► │ Formatter │ ─ rebuild ─► EPUB.<lang>.epub
│ (per ¶ //) │ │ (per chap.) │ │ (XHTML chk) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
┌──────┴──────────────────┴──────────────────┴──────┐
│ Provider layer · OpenAI / Anthropic / Google │
└───────────────────────────────────────────────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Cache (SQLite, by paragraph hash) │ Glossary │
└──────────────────────────────────────────────────┘
- Multi-provider: every agent picks its own provider, base URL, API key, and model. Mix local llama.cpp with cloud APIs.
- Format-preserving: cover, table of contents, images, CSS, inline tags
(
<em>,<a href>,<strong>), and<dc:language>are kept intact. - Three-agent pipeline:
- Translator runs in parallel, paragraph-by-paragraph, with the accumulated per-book glossary in context.
- Reviewer re-reads each chapter end-to-end and returns delta revisions (only paragraphs that need changes) plus glossary additions for terminology consistency.
- Formatter runs in parallel, validates inline tag structure (names + attributes), and only falls back to an LLM repair when validation fails.
- Bootstrap pass: the first non-empty chapter is translated and reviewed sequentially before the parallel run, so the rest of the book translates against a populated glossary.
- Resumable: a SQLite cache keyed by
(book_sha256, paragraph_hash, langs, agent_role)skips work that already succeeded. Re-running after a crash is near-instant. - Cost estimation:
estimatecounts tokens locally and prints expected USD before any API call. - Calibre integration:
listreadsmetadata.dbfrom a Calibre library to show available EPUBs.
The recommended install is via pipx (the binary lands in ~/.local/bin):
pipx install -e /path/to/ebook-translator
ebook-translator --helpEditable mode is convenient because pipx follows source changes
automatically. For a stand-alone venv:
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/ebook-translator --helpPython 3.11+. Tested on 3.14.
Book text is sent directly to the provider configured for each agent. The project does not operate an intermediary service, but provider retention and training policies still apply. Use local OpenAI-compatible models for material that must not leave your machine, keep API keys in environment variables, and do not commit source books, translated books, caches, or local configuration.
See SECURITY.md for vulnerability reporting and disclosure guidance.
ebook-translator reads its configuration from one of:
--config <path>(CLI flag, takes precedence)./config.yaml(current working directory)~/.config/ebook-translator/config.yaml
A complete reference is in src/ebook_translator/config.example.yaml (bundled with the package; run ebook-translator config init to copy it to your config path). The minimum:
agents:
translator:
provider: openai # openai | anthropic | google
api_key: ${ANTHROPIC_API_KEY}
base_url: null # set for OpenAI-compatible servers
model: claude-sonnet-4-6
max_tokens: 4096
temperature: 0.3
reviewer:
provider: anthropic
api_key: ${ANTHROPIC_API_KEY}
model: claude-opus-4-7
max_tokens: 32768
temperature: 0.2
formatter:
provider: openai
api_key: ${OPENAI_API_KEY}
base_url: https://api.openai.com/v1
model: gpt-4o-mini
max_tokens: 8192
temperature: 0.0
translation:
source_language: auto
target_language: pt
concurrency: 8
retries: 3
retry_backoff_seconds: 2
storage:
cache_db: ~/.local/share/ebook-translator/cache.sqlite
glossary_db: ~/.local/share/ebook-translator/glossary.sqlite${VAR} placeholders are expanded from the environment at load time.
Validate the resolved configuration before running:
ebook-translator config checkprovider: anthropic
api_key: ${ANTHROPIC_API_KEY}
model: claude-sonnet-4-6 # or claude-opus-4-7, claude-haiku-4-5provider: openai
api_key: ${OPENAI_API_KEY}
base_url: https://api.openai.com/v1
model: gpt-4o-mini # or gpt-5, gpt-5-mini, o3-miniprovider: google
api_key: ${GOOGLE_API_KEY}
model: gemini-2.5-flash # or gemini-2.5-proAny server that speaks the chat-completions API works. Point base_url at it
and pick a placeholder API key for SDKs that demand a non-empty value.
provider: openai
api_key: sk-no-key
base_url: http://localhost:4329/v1
model: hunyuan-mt-7b-translateagents:
translator: # local, free, fast translation specialist
provider: openai
api_key: sk-no-key
base_url: http://localhost:4329/v1
model: hunyuan-mt-7b-translate
reviewer: # cloud, large context for chapter coherence
provider: openai
api_key: ${QUANTMIND_API_KEY}
base_url: https://api.quantmind.com.br/v1
model: MiniMax-M2.7
formatter: # cloud, called only on validation mismatch
provider: openai
api_key: ${QUANTMIND_API_KEY}
base_url: https://api.quantmind.com.br/v1
model: MiniMax-M2.7estimate counts tokens with the same provider tokenizer that will be used
for the real run, then multiplies by a built-in price table.
ebook-translator estimate ./book.epub --to pt# Default: write next to the source as book.<lang>.epub
ebook-translator translate ./book.epub --to pt
# Specify source language explicitly (otherwise: auto)
ebook-translator translate ./book.epub --to pt --from en
# Override concurrency for this run
ebook-translator translate ./book.epub --to pt --concurrency 16
# Custom output path
ebook-translator translate ./book.epub --to pt --output /tmp/out.epub
# Custom config
ebook-translator translate ./book.epub --to pt --config ./custom.yamltranslate is idempotent: re-running on the same book reuses cached
translations and reviews. To force a full re-run, clear the cache:
sqlite3 ~/.local/share/ebook-translator/cache.sqlite \
"DELETE FROM translation_cache"ebook-translator list --library "/home/diogo/Biblioteca do calibre"Reads metadata.db read-only and prints EPUBs with their author, title, and
on-disk path.
ebook-translator config check
ebook-translator config check --config ./alt.yamlPrints the resolved agents (API keys redacted) and any validation errors. Exit code is non-zero on errors.
-
Extract —
ebooklibopens the EPUB;lxmlwalks each spine document. For every block-level element (<p>,<h1..h6>,<li>,<blockquote>,<td>, …) that contains no nested block, the inline content is captured as raw HTML, including<em>,<strong>,<a href>, etc. Each block gets a stable id{spine_index}:{ord_in_chapter}. -
Bootstrap (scout) — the first non-empty chapter is translated and reviewed sequentially, before the parallel pass. Goal: populate the glossary with proper nouns and recurring terminology so the rest of the book translates against consistent vocabulary.
-
Translate (parallel, per paragraph) — each paragraph is hashed; cache hits skip the LLM. Otherwise the translator agent is called with a small system prompt plus a glossary block of relevant terms (substring match on the paragraph text). The prompt instructs the model to keep all HTML tags verbatim and translate only the visible text.
-
Review (sequential, per chapter) — the reviewer agent receives every paragraph in the chapter side-by-side (source + draft translation) and returns a JSON object with delta revisions (only paragraphs that need changes) plus glossary additions. Paragraphs the reviewer omits keep their draft. Revisions overwrite the buffer; glossary entries are persisted per book.
-
Format (parallel, per paragraph) —
lxmlparses both source and translated text; the formatter compares the structural signature(tag_local_name, sorted(attribs))for every inline element. If signatures match and no stray markdown leaks (**bold**,__under__) are present, the translation is accepted as-is. Otherwise an LLM repair is requested with both originals. -
Reassemble —
_replace_block_contentreconstructs each block under its parent element's namespace;<html lang>and<dc:language>are updated to the target language;ebooklibwrites the new EPUB.
src/ebook_translator/
├── cli.py # Typer commands
├── config.py # YAML loader + validation
├── orchestrator.py # async pipeline (asyncio + tenacity)
├── epub_io.py # ebooklib I/O
├── chunking.py # XHTML → paragraphs (raw inline HTML)
├── cache.py # SQLite translation cache
├── glossary.py # SQLite glossary
├── cost.py # token counting + price tables
├── agents/
│ ├── translator.py
│ ├── reviewer.py
│ └── formatter.py
├── providers/
│ ├── base.py # Protocol + AgentConfig dataclass
│ ├── anthropic.py
│ ├── openai_compat.py
│ └── google.py
└── prompts/
├── translator.md
├── reviewer.md
└── formatter.md
tests/
├── conftest.py # mini.epub fixture
├── test_chunking.py
├── test_epub_io.py
├── test_providers.py
├── test_formatter.py
├── test_config.py
└── test_e2e_smoke.py # full pipeline with mocked provider
| File | Purpose |
|---|---|
~/.local/share/ebook-translator/cache.sqlite |
Per-paragraph translations (translator + reviewer roles, per source/target pair). |
~/.local/share/ebook-translator/glossary.sqlite |
Per-book glossary built up by the reviewer. |
Cache key columns: (book_sha256, paragraph_hash, source_lang, target_lang, agent_role). Two paragraphs with identical text share one row.
For the local translator path used in this repo's example, the model is
served by llama-server from llama.cpp.
A model-loader profile that fits an RTX 3090 + AMD Ryzen 9 9950X3D for ebook-translator's workload (16 parallel slots, prefix-cache reuse, threads pinned to physical cores) is included as guidance. Reference args:
--port 4329 --ngl 99 --flash-attn on --parallel 16
--ctx-size 32768 --batch-size 4096 --ubatch-size 1024
--cache-type-k q8_0 --cache-type-v q8_0 --cache-reuse 256
--cont-batching --kv-unified --metrics --mlock
--threads 16 --threads-batch 16
Then point the translator agent at http://localhost:4329/v1.
Reference run: Driven to Distraction (E. M. Hallowell), 53 chapters, 2,194 paragraphs, English → Portuguese.
| Stage | Calls | Notes |
|---|---|---|
| Translator (Hunyuan-MT-7B local) | 2,194 | Free, ~10 min on RTX 3090. |
| Reviewer (MiniMax-M2.7) | 51 | One per chapter (two skipped via cache after a previous crash). |
| Formatter (MiniMax-M2.7) | 193 | ~9 % of paragraphs needed structural repair. |
| Tokens (cloud only) | ~1.2 M | input 815k + output 385k. |
End-to-end wall time on this machine: ~70 minutes (most of it formatter, which currently runs sequentially).
- Glossary scoping. Recurring proper nouns are tracked per book, not per series. Translating multiple books from one author won't share terminology.
- Prices in
cost.pyare best-effort estimates. Verify on the provider's pricing page before relying on the number. The formatter cost in particular uses a 5 % LLM-repair rate heuristic; reference run hit 9 %. - No
epubcheckstep. Output is rebuilt withlxml+ebookliband passes typical readers, but no formal IDPF validation is enforced. - Single-language output. No bilingual / side-by-side EPUB mode (yet).
- Reviewer JSON parse failures fall back silently to drafts. When the reviewer model returns malformed JSON, the parser keeps the translator's draft for every paragraph in the chapter — quality degrades without an error. See "Troubleshooting" below.
translate_book is the single async entry point used by the CLI. Embed it
directly in your own tooling:
import asyncio
from pathlib import Path
from ebook_translator.config import load_config
from ebook_translator.orchestrator import translate_book
async def main():
cfg = load_config(Path("./config.yaml"))
out, stats = await translate_book(
Path("./book.epub"),
app=cfg,
target_lang="pt",
progress=lambda stage, cur, tot: print(f"{stage}: {cur}/{tot}"),
)
print(out, stats)
asyncio.run(main())progress fires after each paragraph (translate / format) or chapter
(review). See docs/ARCHITECTURE.md for the full data flow.
- Resolution order.
--config <path>>./config.yaml>~/.config/ebook-translator/config.yaml. The first hit wins. - Env vars.
${VAR}placeholders expand from the environment at load time. Unset variables silently expand to the empty string.validate_configflags an emptyapi_keyonly for theanthropicprovider — cloud OpenAI/Google fail at first call instead, and local OpenAI-compatible servers may legitimately need no key. extrafield. Each agent block accepts anextramapping for forward- looking keys. No provider currently consumes it.
OutputTruncatedError: empty content with finish_reason=length— reasoning models (e.g. MiniMax, OpenAI o-series) can exhaustmax_tokenson chain-of-thought before producing any text. Raisemax_tokensor disable reasoning in the model config.- Reviewer revisions silently equal drafts. The reviewer JSON parser is
tolerant: on parse failure it returns the original drafts unchanged. If a
chapter looks unimproved by the reviewer, suspect a malformed JSON
response. Inspect
stats.reviewer_callsand per-chapter token usage. api_keyempty after env interpolation.${VAR}withVARunset in your shell becomes""silently. Re-run with the env var exported, or confirm withebook-translator config check(api keys appear redacted but(unset)for empty).- Stale venv shebang after moving the project directory.
.venv/bin/<tool>may break because it bakes in the absolute interpreter path. Fall back toPYTHONPATH=src .venv/bin/python -m pytest tests/ -qor recreate the venv. - Force a fresh translation. Clear the cache rows for that book:
All other state (glossary, source EPUB) is untouched.
sqlite3 ~/.local/share/ebook-translator/cache.sqlite "DELETE FROM translation_cache"
- Reviewer JSON parsing tolerance. Code fences are stripped; the first
{...}block is greedy-matched; missing paragraph ids fall back to the draft. Keep prompts simple — exotic Markdown wrappings around the JSON can cause the greedy match to capture the wrong span.
# Run the test suite (offline; uses mocked providers + a synthetic mini.epub)
pytest tests/ -q
# Linter / type checker (optional)
pyright src/ tests/The tests/conftest.py builds an in-memory mini EPUB; the smoke tests
exercise the full orchestrator with a stub FakeProvider so no network calls
happen.
MIT.