PDF Translate
Translate a PDF with the bundled preservation engine. Keep the source file unchanged and produce a separate PDF with the same page structure.
Resolve the skill root
This skill may be installed globally while the user's files live elsewhere. Resolve the absolute directory containing this SKILL.md before running anything. Call its scripts and dependency files by absolute path; do not assume the current working directory is the skill directory.
Use the interpreter inside <skill-root>/.venv:
- Windows:
<skill-root>\.venv\Scripts\python.exe - macOS/Linux:
<skill-root>/.venv/bin/python
Choose a mode
| Mode | Translator | Use when |
|---|---|---|
| OpenRouter (default) | openrouter.ai API | High quality with one API key, no agent needed; translates any language pair |
translate.google.com | Books, batches, first drafts, or when no OPENROUTER_API_KEY is available | |
| Handoff | The active agent | Terminology, context, or translation quality matters |
Default to OpenRouter: it translates any language pair with one API key and no coding agent. Offer Google when the user wants to avoid token cost or has no OPENROUTER_API_KEY. Offer handoff when terminology or translation quality matters and an agent is available.
Boundaries
- Use the bundled
pdf2zh/core. Never substitute the PyPIpdf2zhpackage; the runner checks the upstream core version and the preservation ruleset, and refuses an external core. - OpenRouter mode sends extracted document text to OpenRouter and, through it, to the model provider selected. Google mode sends it to Google. Tell the user which third party will receive their document before processing sensitive material, and obtain explicit confirmation unless their request already authorizes that disclosure. Handoff mode sends nothing over the network.
- Any language code is accepted as the source or target (e.g.
en,zh-CN,ja,ar). The default engine is OpenRouter, which translates between arbitrary languages. Non-Latin targets (CJK, right-to-left, Thai, Devanagari, complex shaping) are accepted by the engines, but the bundledGoNotoKurrentfont only covers Latin. The runner checks the font before laying out a page and fails with a message naming the missing script rather than producing blank boxes; supply a font with the needed glyphs viaNOTO_FONT_PATHto proceed.
- Any language code is accepted as the source or target (e.g.
- Google mode caps a segment at 5,000 characters and now reports anything longer as untranslated instead of truncating it. Use OpenRouter for documents with long paragraphs.
- There is no OCR. If a source page is image-only, report that OCR is required instead of claiming it was translated.
- Text that was rasterised into an embedded image (figures, diagrams, slides) is not reached by the text engines. Use
--translate-imagesto let a VLM translate those images: each image XObject is extracted at its source size, sent to the model, and the result is resized back to the source size before being swapped into the PDF. This needsOPENROUTER_API_KEYand adds the translation as a caption band along the bottom of each image rather than replacing the text in place; the original pixels are kept and the image is resized back so the page layout does not move. SetOPENROUTER_VISION_MODELin.env, or pass--vlm-image-model. - Text inside detected tables, figures, contents pages, indexes, symbol lists, or references may intentionally remain in the source language. Report material untranslated regions as partial translation.
- Preserve the source. Write results to a separate output directory. Do not pass
--overwritewithout explicit replacement authorization.
Read the preservation contract before changing layout behavior, diagnosing preserved pages, or investigating untranslated regions.
Set up the runtime
Use Python 3.11 or 3.12. Create <skill-root>/.venv and install <skill-root>/requirements.txt if the environment is absent or stale. Keep this environment separate from the user's project.
The source distribution downloads layout and font assets on its first translation, so the first run needs network access and takes longer. The packaged Windows app already contains these assets.
Windows:
python -m venv "<skill-root>\.venv"
& "<skill-root>\.venv\Scripts\python.exe" -m pip install -r "<skill-root>\requirements.txt"
macOS/Linux:
python3 -m venv "<skill-root>/.venv"
"<skill-root>/.venv/bin/python" -m pip install -r "<skill-root>/requirements.txt"
Shared runner options include --target-language (default vi), --source-language auto, one-based --pages 1,3-5, --threads 1..8 (default 4), --ignore-cache, and --overwrite.
When a document reuses a term whose translation must not drift between pages, write it into a JSON glossary and pass --glossary <file>; see glossary.example.json. Only a segment that is entirely one glossary term is substituted, so a term inside a sentence still goes to the engine.
Google mode
Run one command per file. Use absolute paths for the input and output directory.
Windows:
& "<skill-root>\.venv\Scripts\python.exe" "<skill-root>\scripts\translate_pdf.py" "<input.pdf>" --output-dir "<output-dir>"
macOS/Linux:
"<skill-root>/.venv/bin/python" "<skill-root>/scripts/translate_pdf.py" "<input.pdf>" --output-dir "<output-dir>"
For a batch, process files individually and report progress. A failure on one file must not stop the remaining files; collect and report all failures at the end.
Handoff mode
Handoff extracts translatable segments to JSONL, lets the active agent translate them, then rebuilds the PDF. Warn about token and time cost before starting a large document. For long documents, suggest a representative sample such as --pages 1-5 first.
1. Extract
An output directory is not required during extraction because the pass-one PDF is discarded.
<python> <skill-root>/scripts/translate_pdf.py <input.pdf> --engine handoff --emit-segments <segments.jsonl>
2. Translate
Read segments.jsonl in manageable batches. Write one JSON object per line to translations.jsonl:
{"src":"exact source text","dst":"translated text"}
Copy each src value exactly. Preserve URLs, paths, identifiers, citation markers, and numbers.
Formula and code placeholders such as <b0></b0> are immutable. Every opening and closing tag must retain the same identifier, count, and order as the source. The loader rejects a record whose placeholders differ, leaving that segment untranslated.
Inline emphasis markers are immutable as balanced pairs: <s1>...</s1> is
bold, <s2>...</s2> is italic, and <s3>...</s3> is bold italic. Complete
style pairs may move with the translated phrase, but none may be dropped,
duplicated, or cross-nested. Invalid style markup leaves the segment
untranslated instead of silently losing emphasis.
3. Rebuild
<python> <skill-root>/scripts/translate_pdf.py <input.pdf> --engine handoff --segments <translations.jsonl> --output-dir <output-dir> --emit-segments <still-missing.jsonl>
The command prints the remaining untranslated segment count. If it is nonzero, translate still-missing.jsonl, append valid records to translations.jsonl, and rebuild again. Stop only at zero or when a segment cannot be translated safely; then report the exact remaining limitation.
Extraction and rebuild each run the layout pass, so handoff uses roughly twice the local PDF processing of Google mode in addition to the agent's translation work.
OpenRouter mode
OpenRouter calls an LLM directly through one API key, so it needs no coding
agent. All model selection lives in .env (edit it to change models; no code
change needed). Copy .env.example to .env and fill in:
OPENROUTER_API_KEY=sk-or-...
OPENROUTER_MODEL=thinkingmachines/inkling:free
OPENROUTER_MODEL_2=nvidia/nemotron-3.5-lightning:free # optional fallback
OPENROUTER_REASONING=true
Set the key once, then run a single command per file. .env is auto-loaded by
the runner; OPENROUTER_MODEL is the primary model and OPENROUTER_MODEL_2 is
tried only if the primary call fails.
Windows:
$env:OPENROUTER_API_KEY = "sk-or-..."
& "<skill-root>\.venv\Scripts\python.exe" "<skill-root>\scripts\translate_pdf.py" "<input.pdf>" --engine openrouter --output-dir "<output-dir>"
macOS/Linux:
"<skill-root>/.venv/bin/python" "<skill-root>/scripts/translate_pdf.py" "<input.pdf>" --engine openrouter --output-dir "<output-dir>"
You may still pin a model on the command line; it overrides OPENROUTER_MODEL:
"<skill-root>/.venv/bin/python" "<skill-root>/scripts/translate_pdf.py" "<input.pdf>" --engine openrouter:anthropic/claude-opus-5 --output-dir "<output-dir>"
OpenRouter is billed per token, so prefer a smaller model for long documents.
The engine enables OpenRouter reasoning when OPENROUTER_REASONING=true and
preserves formula and style placeholders exactly as the Google and Handoff modes
do.
Verify before delivery
- Confirm the output exists and the source still exists unchanged.
- Confirm source and output page counts match.
- Extract text page by page and check for substantial untranslated passages, missing formulas, damaged URLs, or lost identifiers.
- When page rendering or image inspection is available, render every output page and inspect for blank pages, missing glyphs, clipping, overlap, and displaced tables or figures.
- If full visual inspection is unavailable, say which checks were completed. Do not present a partially verified or partially translated file as fully complete.