Layout-preserving PDF translation via LLM — side-by-side bilingual or full substitution, any language pair.
- Features
- How It Works
- Install
- Quick Start
- Providers
- CLI Reference
- Configuration
- Output Modes
- Architecture
- Known Limitations
- Project Layout
- ✅ Any LLM provider — works with OpenRouter, Anthropic, OpenAI, Google Gemini, Groq, Together.ai, xAI, Ollama, LM Studio, and any other LiteLLM-supported provider
- ✅ Any language pair — source and target language are model-dependent; modern LLMs cover 50+ languages
- ✅ Layout preserved — images, rules, backgrounds, page numbers and all non-text elements survive untouched
- ✅ Side-by-side bilingual output — original on the left, translation on the right; ideal as a reading or language-learning aid
- ✅ Full substitution mode — translation only, typeset at the original baseline and alignment
- ✅ Smart font matching — bundled Noto Sans/Serif (Latin, Cyrillic, Greek); serif/sans-serif automatically matched per line
- ✅ Disk cache — completed page translations are cached; interrupted runs resume from where they left off with no extra API cost
- ✅ Automatic repair — lines the model echoes unchanged are detected and retried; missing lines fall back to per-line translation
- ✅ Page range selection — process any subset of pages (
1-5,7,20-30)
The translator works in three stages per page:
- Scan — PyMuPDF extracts every text span; fragments sharing a baseline are re-merged into visual lines; page numbers and other non-body elements are frozen.
- Translate — all lines on the page are sent to the LLM as a single keyed JSON batch so the model has full-page context. The key scheme (
"0"…"N") aligns responses even if the model re-orders or skips lines. Missing lines fall back to per-line calls; echoed lines are repaired. - Render — the original page is stamped as the base layer; each translated line gets an opaque background-coloured mask over the source text, then the translation is drawn at the original baseline and alignment.
This means everything not explicitly translated is preserved for free — no image reconstruction, no per-element layout parsing.
Requires Python 3.14+. A uv-managed .venv is already present in the repo.
uv pip install -e .An API key is only required for cloud providers. Local providers (Ollama, LM Studio) need no key at all. See Providers for setup per provider.
# Side-by-side bilingual PDF, pages 1–40, Lithuanian → English (default provider: OpenRouter)
export OPENROUTER_API_KEY="sk-or-v1-..."
pdf-translate -i "book.pdf" -o "book-en.pdf" -t English -p 1-40
# Full substitution (translation only)
pdf-translate -i "book.pdf" -o "book-en.pdf" -t English --mode substitute
# Specific page ranges (comma-separated)
pdf-translate -i "book.pdf" -o "book-en.pdf" -t Spanish -p 5-8,12,20-25
# Force fresh translations (ignore cache)
pdf-translate -i "book.pdf" -o "book-en.pdf" -t German --ignore-cacheThe translator uses LiteLLM under the hood, which means it works with any provider LiteLLM supports. Set the appropriate environment variable for your provider and pass the matching --model string. No --api-key flag needed — LiteLLM reads the provider's own env var automatically.
export OPENROUTER_API_KEY="sk-or-v1-..."
# Default (fast + cheap)
pdf-translate -i book.pdf -o out.pdf -m openrouter/google/gemini-2.5-flash-lite
# Higher quality
pdf-translate -i book.pdf -o out.pdf -m openrouter/anthropic/claude-sonnet-4-5
pdf-translate -i book.pdf -o out.pdf -m openrouter/openai/gpt-4oexport ANTHROPIC_API_KEY="sk-ant-..."
pdf-translate -i book.pdf -o out.pdf -m anthropic/claude-haiku-4-5-20251001
pdf-translate -i book.pdf -o out.pdf -m anthropic/claude-sonnet-4-6export OPENAI_API_KEY="sk-..."
pdf-translate -i book.pdf -o out.pdf -m openai/gpt-4o-mini
pdf-translate -i book.pdf -o out.pdf -m openai/gpt-4oexport GEMINI_API_KEY="..."
pdf-translate -i book.pdf -o out.pdf -m gemini/gemini-2.0-flash
pdf-translate -i book.pdf -o out.pdf -m gemini/gemini-2.5-flashexport XAI_API_KEY="..."
pdf-translate -i book.pdf -o out.pdf -m xai/grok-2export GROQ_API_KEY="..."
pdf-translate -i book.pdf -o out.pdf -m groq/llama-3.3-70b-versatileexport TOGETHER_API_KEY="..."
pdf-translate -i book.pdf -o out.pdf -m together_ai/meta-llama/Llama-3-70b-chat-hf# Start Ollama with your chosen model first:
# ollama pull llama3
pdf-translate -i book.pdf -o out.pdf -m ollama/llama3
pdf-translate -i book.pdf -o out.pdf -m ollama/mistralLiteLLM connects to Ollama at http://localhost:11434 by default. Override with --api-base if your Ollama runs elsewhere.
# Start LM Studio's local server first (default port 1234).
# The model name must match what LM Studio reports.
pdf-translate -i book.pdf -o out.pdf \
-m openai/lmstudio-community/Meta-Llama-3-8B-Instruct-GGUF \
--api-base http://localhost:1234/v1pdf-translate [OPTIONS]
Options:
-i, --input PATH Source PDF (required)
-o, --output PATH Output PDF (required)
-t, --target TEXT Target language [default: English]
-m, --model TEXT LiteLLM model string [default: openrouter/google/gemini-2.5-flash-lite]
--mode TEXT Output mode: side-by-side | substitute [default: side-by-side]
-p, --pages TEXT Page range, e.g. 1-5,7 or all [default: all]
--api-key TEXT API key override (usually not needed — set the provider env var instead)
--api-base TEXT Provider base URL override (for local providers, e.g. http://localhost:1234/v1)
--cache-dir PATH Translation cache directory [default: .translation_cache]
--ignore-cache Discard cached translations before running
-v, --verbose Debug logging
--help Show this message and exit
| Setting | Default | How to set |
|---|---|---|
| Model | openrouter/google/gemini-2.5-flash-lite |
--model |
| Target language | English |
--target |
| Output mode | side-by-side |
--mode |
| API key | (provider env var) | Provider env var (e.g. OPENROUTER_API_KEY) or --api-key to override |
| API base URL | (provider default) | --api-base (local providers only) |
| Cache directory | .translation_cache/ |
--cache-dir |
For most books, a fast, cheap model is indistinguishable from a premium one. Rough guidance:
| Use case | Suggested model |
|---|---|
| General books, fast & cheap | openrouter/google/gemini-2.5-flash-lite |
| Technical / specialised vocabulary | openai/gpt-4o or anthropic/claude-sonnet-4-6 |
| Fully offline / private | ollama/llama3 or ollama/mistral |
| Free tier / no sign-up | ollama/llama3 |
The output page is twice the original width. The left half shows the original page; the right half shows the translated overlay. Useful for:
- Language learning (compare original and translation side by side)
- Reviewing translation accuracy
- Documents where you want to keep the source for reference
The output page is the same size as the original. The translation is drawn directly over the source text. Useful for:
- Reading a book in the target language
- Sharing a translated document without the original
src/pdftranslator/
cli.py Typer entry point — argument parsing, wires components together
orchestrator.py Page loop, range parsing, cross-page context threading
scanner.py PyMuPDF extraction → baseline merging → frozen/translatable split
translate.py Keyed-batch LLM translation, sentinel padding, fallback, repair, cache
render.py Base-layer stamp + mask + in-place text overlay
models.py Span / Line / PageContent dataclasses
fonts.py Bundled Noto Sans/Serif + text-width measurement
Data flow:
fitz.Page
└─► DocumentScanner.extract() → PageContent (frozen_spans + lines[])
└─► Translator.translate_page() → lines[].translated filled
└─► PdfRenderer.render_page() → output page appended to writer
Translation robustness pipeline (per page):
1. Cache hit? → return immediately
2. Keyed-batch call (whole page, with sentinel padding)
├─ All N keys present? → done
└─ Missing keys? → per-line fallback for each missing key
3. Repair pass: lines returned unchanged? → retry per-line
4. Final safety net: still None? → keep source text
5. Cache result
| Issue | Status |
|---|---|
| Translated lines are ragged-right (not re-justified) | Planned: word-spacing fill to match original column width |
| Long translations shrink font size to fit | By design (no overflow/collision); cosmetic only |
| Background mask is flat colour | Works well on solid pages; visible over photos/gradients |
| Multi-column layouts and tables | Single text-column assumed; columns may merge |
| Bold / italic not reproduced | Only Noto Regular bundled; per-span weight/style is planned |
| Scanned pages without a text layer | Copied as-is (no OCR) |
| Non-Latin targets (CJK, Arabic, etc.) | Needs matching Noto font variants |
| Issue | Status |
|---|---|
| Running headers translate inconsistently across pages | Planned: detect recurring lines, translate once |
| Mixed-script residue (proper names, discussed foreign words) | Conservative by design to avoid damaging correct cases |
| Stylised logos OCR as noise | Graphics-only elements; a logo-freeze heuristic is planned |
PDFTranslator/
├── src/pdftranslator/
│ ├── cli.py Entry point
│ ├── orchestrator.py Page loop
│ ├── scanner.py Text extraction
│ ├── translate.py LLM translation + cache
│ ├── render.py PDF rendering
│ ├── models.py Data models
│ └── fonts.py Font management
├── NotoSans-Regular.ttf Bundled font (Latin/Cyrillic/Greek sans-serif)
├── NotoSerif-Regular.ttf Bundled font (Latin/Cyrillic/Greek serif)
├── pyproject.toml
└── README.md